Merge pull request #599 from trails-cool/feat/ui-primitives-2

feat(ui): topbar primitives — IconButton, SegmentedControl, Avatar
This commit is contained in:
Ullrich Schäfer 2026-07-15 23:47:48 +02:00 committed by GitHub
commit 9463c5b1e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 371 additions and 2 deletions

View file

@ -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>

View 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("?");
});
});

View 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>
);
}

View 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();
});
});

View 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}
/>
);
}

View 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();
});
});

View 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>
);
}

View file

@ -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";