From 1bd5f18a33e45ca1a404086bba6dc3e478ad7879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 15 Jul 2026 23:43:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20topbar=20primitives=20=E2=80=94=20I?= =?UTF-8?q?conButton,=20SegmentedControl,=20Avatar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/planner/app/routes/dev.ui.tsx | 73 ++++++++++++++++++++++- packages/ui/src/Avatar.test.tsx | 31 ++++++++++ packages/ui/src/Avatar.tsx | 46 ++++++++++++++ packages/ui/src/IconButton.test.tsx | 43 +++++++++++++ packages/ui/src/IconButton.tsx | 50 ++++++++++++++++ packages/ui/src/SegmentedControl.test.tsx | 49 +++++++++++++++ packages/ui/src/SegmentedControl.tsx | 67 +++++++++++++++++++++ packages/ui/src/index.ts | 14 +++++ 8 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/Avatar.test.tsx create mode 100644 packages/ui/src/Avatar.tsx create mode 100644 packages/ui/src/IconButton.test.tsx create mode 100644 packages/ui/src/IconButton.tsx create mode 100644 packages/ui/src/SegmentedControl.test.tsx create mode 100644 packages/ui/src/SegmentedControl.tsx diff --git a/apps/planner/app/routes/dev.ui.tsx b/apps/planner/app/routes/dev.ui.tsx index 9933fd4..e463757 100644 --- a/apps/planner/app/routes/dev.ui.tsx +++ b/apps/planner/app/routes/dev.ui.tsx @@ -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 = () => ( + + + + +); +const RedoIcon = () => ( + + + + +); + +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 (
@@ -58,6 +88,45 @@ export default function DevUi() { +
+ + + + + + + + + + + + +
+ +
+ + + selected: {colorMode} + +
+ +
+ + + + + + + ulle + Host + +
+

Subtle card

diff --git a/packages/ui/src/Avatar.test.tsx b/packages/ui/src/Avatar.test.tsx new file mode 100644 index 0000000..132de34 --- /dev/null +++ b/packages/ui/src/Avatar.test.tsx @@ -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(); + const el = screen.getByLabelText("ulle"); + expect(el.textContent).toBe("U"); + }); + + it("applies a per-user color as the background", () => { + render(); + 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(); + expect(screen.getByLabelText("Sam").className).toContain("bg-accent"); + }); + + it("renders a placeholder initial for a blank name", () => { + render(); + expect(screen.getByText("?").textContent).toBe("?"); + }); +}); diff --git a/packages/ui/src/Avatar.tsx b/packages/ui/src/Avatar.tsx new file mode 100644 index 0000000..a138ff3 --- /dev/null +++ b/packages/ui/src/Avatar.tsx @@ -0,0 +1,46 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "./cn.ts"; + +export type AvatarSize = "sm" | "md"; + +export interface AvatarProps extends HTMLAttributes { + name: string; + /** Per-user color (e.g. Yjs awareness color). Falls back to the accent. */ + color?: string; + size?: AvatarSize; +} + +const sizes: Record = { + 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 ( + + {initial(name)} + + ); +} diff --git a/packages/ui/src/IconButton.test.tsx b/packages/ui/src/IconButton.test.tsx new file mode 100644 index 0000000..bb92ef1 --- /dev/null +++ b/packages/ui/src/IconButton.test.tsx @@ -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 = () => ; + +describe("IconButton", () => { + it("exposes its label as the accessible name and title", () => { + render( + + + , + ); + const btn = screen.getByRole("button", { name: "Undo" }); + expect(btn).toHaveProperty("title", "Undo"); + }); + + it("keeps an explicit title over the label", () => { + render( + + + , + ); + expect(screen.getByRole("button", { name: "Undo" })).toHaveProperty( + "title", + "Undo (⌘Z)", + ); + }); + + it("does not fire onClick when disabled", () => { + const onClick = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Redo" })); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/IconButton.tsx b/packages/ui/src/IconButton.tsx new file mode 100644 index 0000000..012b897 --- /dev/null +++ b/packages/ui/src/IconButton.tsx @@ -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, "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 = { + 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 = { + 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 ( + + ); + })} + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 7d9e600..82bbc79 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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";