feat(ui): topbar primitives — IconButton, SegmentedControl, Avatar
Adds the shared primitives the planner topbar redesign needs, on the design-system tokens: - IconButton — icon-only button (undo/redo), ghost/secondary variants, requires an accessible label. - SegmentedControl — generic single-select toggle (color mode Plain/Elevation/Surface), radiogroup semantics. - Avatar — colored initial circle for participants, per-user color with an accent fallback. Co-located jsdom unit tests for each (roles, aria-checked, onChange, disabled, color fallback). Extends the dev-only /dev/ui gallery with sections for all three. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c9ad475e59
commit
1bd5f18a33
8 changed files with 371 additions and 2 deletions
|
|
@ -1,5 +1,32 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Badge, Button, Card, Input } from "@trails-cool/ui";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
IconButton,
|
||||
Input,
|
||||
SegmentedControl,
|
||||
} from "@trails-cool/ui";
|
||||
|
||||
const UndoIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="1 4 1 10 7 10" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
);
|
||||
const RedoIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const COLOR_MODES = [
|
||||
{ value: "plain", label: "Plain" },
|
||||
{ value: "elevation", label: "Elevation" },
|
||||
{ value: "surface", label: "Surface" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Dev-only gallery for the shared UI primitives. Renders every primitive in
|
||||
|
|
@ -19,6 +46,9 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
|
|||
}
|
||||
|
||||
export default function DevUi() {
|
||||
const [colorMode, setColorMode] =
|
||||
useState<(typeof COLOR_MODES)[number]["value"]>("elevation");
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl space-y-10 p-10">
|
||||
<header className="space-y-1">
|
||||
|
|
@ -58,6 +88,45 @@ export default function DevUi() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="IconButton">
|
||||
<IconButton label="Undo">
|
||||
<UndoIcon />
|
||||
</IconButton>
|
||||
<IconButton label="Redo">
|
||||
<RedoIcon />
|
||||
</IconButton>
|
||||
<IconButton label="Undo (disabled)" disabled>
|
||||
<UndoIcon />
|
||||
</IconButton>
|
||||
<IconButton label="Undo" variant="secondary">
|
||||
<UndoIcon />
|
||||
</IconButton>
|
||||
</Section>
|
||||
|
||||
<Section title="SegmentedControl · color mode">
|
||||
<SegmentedControl
|
||||
options={COLOR_MODES}
|
||||
value={colorMode}
|
||||
onChange={setColorMode}
|
||||
label="Color mode"
|
||||
/>
|
||||
<span className="text-sm text-text-md">
|
||||
selected: <code className="font-mono">{colorMode}</code>
|
||||
</span>
|
||||
</Section>
|
||||
|
||||
<Section title="Avatar · participants">
|
||||
<Avatar name="ulle" color="#4A6B40" />
|
||||
<Avatar name="Mara" color="#8B6D3A" />
|
||||
<Avatar name="Sam" color="#C46040" />
|
||||
<Avatar name="ulle" color="#4A6B40" size="sm" />
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-text-hi">
|
||||
<Avatar name="ulle" color="#4A6B40" size="sm" />
|
||||
ulle
|
||||
<Badge tone="accent">Host</Badge>
|
||||
</span>
|
||||
</Section>
|
||||
|
||||
<Section title="Card">
|
||||
<Card className="w-64">
|
||||
<h3 className="font-medium text-text-hi">Subtle card</h3>
|
||||
|
|
|
|||
31
packages/ui/src/Avatar.test.tsx
Normal file
31
packages/ui/src/Avatar.test.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Avatar", () => {
|
||||
it("shows the uppercased first initial and the name as accessible label", () => {
|
||||
render(<Avatar name="ulle" />);
|
||||
const el = screen.getByLabelText("ulle");
|
||||
expect(el.textContent).toBe("U");
|
||||
});
|
||||
|
||||
it("applies a per-user color as the background", () => {
|
||||
render(<Avatar name="Mara" color="rgb(74, 107, 64)" />);
|
||||
const el = screen.getByLabelText("Mara");
|
||||
expect(el.style.backgroundColor).toBe("rgb(74, 107, 64)");
|
||||
expect(el.className).not.toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("falls back to the accent token when no color is given", () => {
|
||||
render(<Avatar name="Sam" />);
|
||||
expect(screen.getByLabelText("Sam").className).toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("renders a placeholder initial for a blank name", () => {
|
||||
render(<Avatar name=" " />);
|
||||
expect(screen.getByText("?").textContent).toBe("?");
|
||||
});
|
||||
});
|
||||
46
packages/ui/src/Avatar.tsx
Normal file
46
packages/ui/src/Avatar.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "./cn.ts";
|
||||
|
||||
export type AvatarSize = "sm" | "md";
|
||||
|
||||
export interface AvatarProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
name: string;
|
||||
/** Per-user color (e.g. Yjs awareness color). Falls back to the accent. */
|
||||
color?: string;
|
||||
size?: AvatarSize;
|
||||
}
|
||||
|
||||
const sizes: Record<AvatarSize, string> = {
|
||||
sm: "h-6 w-6 text-[10px]",
|
||||
md: "h-8 w-8 text-xs",
|
||||
};
|
||||
|
||||
function initial(name: string): string {
|
||||
return name.trim().charAt(0).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
name,
|
||||
color,
|
||||
size = "md",
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps) {
|
||||
return (
|
||||
<span
|
||||
title={name}
|
||||
aria-label={name}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full font-semibold text-text-inv select-none",
|
||||
!color && "bg-accent",
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
style={color ? { backgroundColor: color, ...style } : style}
|
||||
{...props}
|
||||
>
|
||||
{initial(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
43
packages/ui/src/IconButton.test.tsx
Normal file
43
packages/ui/src/IconButton.test.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { IconButton } from "./IconButton.tsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const Icon = () => <svg data-testid="icon" aria-hidden />;
|
||||
|
||||
describe("IconButton", () => {
|
||||
it("exposes its label as the accessible name and title", () => {
|
||||
render(
|
||||
<IconButton label="Undo">
|
||||
<Icon />
|
||||
</IconButton>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Undo" });
|
||||
expect(btn).toHaveProperty("title", "Undo");
|
||||
});
|
||||
|
||||
it("keeps an explicit title over the label", () => {
|
||||
render(
|
||||
<IconButton label="Undo" title="Undo (⌘Z)">
|
||||
<Icon />
|
||||
</IconButton>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Undo" })).toHaveProperty(
|
||||
"title",
|
||||
"Undo (⌘Z)",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fire onClick when disabled", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<IconButton label="Redo" onClick={onClick} disabled>
|
||||
<Icon />
|
||||
</IconButton>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Redo" }));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
50
packages/ui/src/IconButton.tsx
Normal file
50
packages/ui/src/IconButton.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "./cn.ts";
|
||||
|
||||
export type IconButtonVariant = "ghost" | "secondary";
|
||||
export type IconButtonSize = "sm" | "md";
|
||||
|
||||
export interface IconButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> {
|
||||
/** Required accessible name — the button has no visible text. */
|
||||
label: string;
|
||||
variant?: IconButtonVariant;
|
||||
size?: IconButtonSize;
|
||||
}
|
||||
|
||||
const base =
|
||||
"inline-flex items-center justify-center rounded-md transition-colors " +
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent " +
|
||||
"focus-visible:ring-offset-1 focus-visible:ring-offset-bg " +
|
||||
"disabled:pointer-events-none disabled:opacity-30";
|
||||
|
||||
const variants: Record<IconButtonVariant, string> = {
|
||||
ghost: "text-text-md hover:bg-bg-subtle hover:text-text-hi",
|
||||
secondary:
|
||||
"border border-border bg-bg-raised text-text-hi hover:bg-bg-subtle",
|
||||
};
|
||||
|
||||
const sizes: Record<IconButtonSize, string> = {
|
||||
sm: "h-7 w-7",
|
||||
md: "h-9 w-9",
|
||||
};
|
||||
|
||||
export function IconButton({
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "md",
|
||||
className,
|
||||
type = "button",
|
||||
title,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
aria-label={label}
|
||||
title={title ?? label}
|
||||
className={cn(base, variants[variant], sizes[size], className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
packages/ui/src/SegmentedControl.test.tsx
Normal file
49
packages/ui/src/SegmentedControl.test.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { SegmentedControl } from "./SegmentedControl.tsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "plain", label: "Plain" },
|
||||
{ value: "elevation", label: "Elevation" },
|
||||
{ value: "surface", label: "Surface" },
|
||||
] as const;
|
||||
|
||||
describe("SegmentedControl", () => {
|
||||
it("marks the selected option as checked", () => {
|
||||
render(
|
||||
<SegmentedControl options={OPTIONS} value="elevation" onChange={() => {}} />,
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "Elevation" })).toHaveProperty(
|
||||
"ariaChecked",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "Plain" })).toHaveProperty(
|
||||
"ariaChecked",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onChange with the clicked option's value", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SegmentedControl options={OPTIONS} value="plain" onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Surface" }));
|
||||
expect(onChange).toHaveBeenCalledWith("surface");
|
||||
});
|
||||
|
||||
it("renders a labelled radiogroup", () => {
|
||||
render(
|
||||
<SegmentedControl
|
||||
options={OPTIONS}
|
||||
value="plain"
|
||||
onChange={() => {}}
|
||||
label="Colour mode"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("radiogroup", { name: "Colour mode" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
67
packages/ui/src/SegmentedControl.tsx
Normal file
67
packages/ui/src/SegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { cn } from "./cn.ts";
|
||||
|
||||
export type SegmentedSize = "sm" | "md";
|
||||
|
||||
export interface SegmentedOption<T extends string> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps<T extends string> {
|
||||
options: ReadonlyArray<SegmentedOption<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
size?: SegmentedSize;
|
||||
/** Accessible name for the group. */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizes: Record<SegmentedSize, string> = {
|
||||
sm: "h-7 text-xs",
|
||||
md: "h-9 text-sm",
|
||||
};
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
size = "md",
|
||||
label,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 rounded-md bg-bg-subtle p-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const selected = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded px-3 font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
|
||||
sizes[size],
|
||||
selected
|
||||
? "bg-bg-raised text-text-hi shadow-sm"
|
||||
: "text-text-md hover:text-text-hi",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,3 +7,17 @@ export { Card } from "./Card.tsx";
|
|||
export type { CardProps } from "./Card.tsx";
|
||||
export { Input } from "./Input.tsx";
|
||||
export type { InputProps } from "./Input.tsx";
|
||||
export { IconButton } from "./IconButton.tsx";
|
||||
export type {
|
||||
IconButtonProps,
|
||||
IconButtonVariant,
|
||||
IconButtonSize,
|
||||
} from "./IconButton.tsx";
|
||||
export { SegmentedControl } from "./SegmentedControl.tsx";
|
||||
export type {
|
||||
SegmentedControlProps,
|
||||
SegmentedOption,
|
||||
SegmentedSize,
|
||||
} from "./SegmentedControl.tsx";
|
||||
export { Avatar } from "./Avatar.tsx";
|
||||
export type { AvatarProps, AvatarSize } from "./Avatar.tsx";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue