From 40507c76b61433bc83e8ad79f3d0286de4b94a4d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 31 Oct 2026 13:07:14 -0700 Subject: [PATCH 1/3] feat(ui): add ActionBar, a floating bottom action bar Closes CL-11892 --- README.md | 1 + src/index.ts | 1 + src/ui/action-bar.test.tsx | 136 +++++++++ src/ui/action-bar.tsx | 355 ++++++++++++++++++++++ stories/primitives/action-bar.stories.tsx | 245 +++++++++++++++ 5 files changed, 738 insertions(+) create mode 100644 src/ui/action-bar.test.tsx create mode 100644 src/ui/action-bar.tsx create mode 100644 stories/primitives/action-bar.stories.tsx diff --git a/README.md b/README.md index 4a809576..652fdfe7 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,7 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", | `FilterBar` | Row of filters with an active-filter summary | | `FilterChip` | One toggleable filter | | `KindCardGrid` | Selectable card grid | +| `ActionBar` | Grouped view controls, inline or floating | | `BulkActionBar` | Actions for a multi-row selection | | `Table` | Table primitives | | `SortableTable` | Table with sortable columns | diff --git a/src/index.ts b/src/index.ts index 133c8e9e..1cab9f45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export * from "./lib/conversation-activity.js"; export * from "./lib/media-kind.js"; export * from "./lib/attachment-limits.js"; export * from "./lib/prompt-tokens.js"; +export * from "./ui/action-bar.js"; export * from "./ui/animated-number.js"; export * from "./ui/artifact-body.js"; export * from "./ui/artifact-notice.js"; diff --git a/src/ui/action-bar.test.tsx b/src/ui/action-bar.test.tsx new file mode 100644 index 00000000..d5a0fcd2 --- /dev/null +++ b/src/ui/action-bar.test.tsx @@ -0,0 +1,136 @@ +import { describe, expect, mock, test } from "bun:test"; +import { act, createRef, type ReactElement } from "react"; +import { createRoot } from "react-dom/client"; + +import { ActionBar, type ActionBarGroup } from "./action-bar.js"; +import { Menu, MenuTrigger } from "./menu.js"; + +function render(element: ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(element)); + return { + container, + rerender: (next: ReactElement) => act(() => root.render(next)), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +const button = (container: HTMLElement, name: string) => + container.querySelector(`button[aria-label="${name}"]`)!; + +function press(target: HTMLElement, key: string) { + act(() => { + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }); +} + +const groups: readonly ActionBarGroup[] = [ + [ + { id: "zoom-out", label: "Zoom out" }, + { id: "locked", label: "Locked", disabled: true }, + { id: "zoom-in", label: "Zoom in" }, + ], + [ + { id: "status", label: "Saved", note: true }, + { + id: "screens", + label: "Screens", + row: [ + { id: "home", label: "Home", text: "Home", active: true }, + { id: "settings", label: "Settings", text: "Settings" }, + ], + }, + ], +]; + +describe("ActionBar", () => { + test("is one tab stop whose arrow keys skip disabled controls and wrap", () => { + const mounted = render(); + const tabbable = [ + ...mounted.container.querySelectorAll("button[tabindex='0']"), + ]; + expect(tabbable).toEqual([button(mounted.container, "Zoom out")]); + + act(() => button(mounted.container, "Zoom out").focus()); + press(button(mounted.container, "Zoom out"), "ArrowRight"); + expect(document.activeElement).toBe(button(mounted.container, "Zoom in")); + press(button(mounted.container, "Zoom in"), "ArrowRight"); + expect(document.activeElement).toBe(button(mounted.container, "Home")); + press(button(mounted.container, "Home"), "End"); + expect(document.activeElement).toBe(button(mounted.container, "Settings")); + press(button(mounted.container, "Settings"), "ArrowRight"); + expect(document.activeElement).toBe(button(mounted.container, "Zoom out")); + press(button(mounted.container, "Zoom out"), "ArrowLeft"); + expect(document.activeElement).toBe(button(mounted.container, "Settings")); + expect(button(mounted.container, "Settings").getAttribute("tabindex")).toBe( + "0", + ); + mounted.unmount(); + }); + + test("leaves collapsing to the host when controlled", () => { + const onCollapsedChange = mock((_collapsed: boolean) => {}); + const bar = (collapsed: boolean) => ( + + ); + const mounted = render(bar(false)); + + act(() => button(mounted.container, "Hide controls").click()); + expect(onCollapsedChange).toHaveBeenCalledWith(true); + expect(mounted.container.querySelector("[role='toolbar']")).not.toBeNull(); + + mounted.rerender(bar(true)); + expect(mounted.container.querySelector("[role='toolbar']")).toBeNull(); + expect(document.activeElement).toBe( + button(mounted.container, "Show controls"), + ); + + act(() => button(mounted.container, "Show controls").click()); + expect(onCollapsedChange).toHaveBeenLastCalledWith(false); + mounted.rerender(bar(false)); + expect(document.activeElement).toBe( + button(mounted.container, "Hide controls"), + ); + mounted.unmount(); + }); + + test("hands renderTrigger a button that forwards its ref to an asChild trigger", () => { + const ref = createRef(); + const mounted = render( + ( + + {trigger} + + ), + }, + ], + ]} + />, + ); + const trigger = button(mounted.container, "Version"); + expect(ref.current).toBe(trigger); + expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); + expect(trigger.getAttribute("tabindex")).toBe("0"); + mounted.unmount(); + }); +}); diff --git a/src/ui/action-bar.tsx b/src/ui/action-bar.tsx new file mode 100644 index 00000000..7e4b0f69 --- /dev/null +++ b/src/ui/action-bar.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { ChevronDown, ChevronUp } from "lucide-react"; +import { + forwardRef, + Fragment, + useEffect, + useRef, + useState, + type ComponentProps, + type KeyboardEvent, + type ReactElement, + type ReactNode, + type Ref, + type RefObject, +} from "react"; + +import { useControllableState } from "../hooks/use-controllable-state.js"; +import { cn } from "../lib/utils.js"; +import { buttonVariants } from "./button.js"; + +export type ActionBarAction = { + readonly id: string; + /** Accessible name and tooltip. */ + readonly label: string; + readonly icon?: ReactNode | undefined; + /** Visible words before the icon, for a value such as "v4" or the current screen. */ + readonly text?: string | undefined; + readonly active?: boolean | undefined; + readonly disabled?: boolean | undefined; + readonly onSelect?: (() => void) | undefined; + /** Readies what `onSelect` needs, as a pointer or focus reaches the action. */ + readonly onPrepare?: (() => void) | undefined; + /** + * Wraps the action's button, such as in a `Menu` with the button as its + * `MenuTrigger asChild`. The button forwards its ref, so any `asChild` + * trigger works. + */ + readonly renderTrigger?: ((trigger: ReactElement) => ReactNode) | undefined; + /** A quiet badge that says something about the view rather than acting on it. */ + readonly note?: boolean | undefined; + /** A strip named by `label` that scrolls sideways rather than widen the bar. */ + readonly row?: readonly ActionBarAction[] | undefined; + readonly ref?: Ref | undefined; +}; + +export type ActionBarGroup = readonly ActionBarAction[]; + +export type ActionBarProps = { + /** The toolbar's accessible name. */ + readonly label: string; + /** Drawn in order with a rule between; empty groups are skipped. */ + readonly groups: readonly ActionBarGroup[]; + /** + * `floating` pins the bar to the bottom centre of the nearest positioned + * ancestor and lets it collapse to a small handle; `inline` sticks to the + * foot of its scroll container. + */ + readonly placement?: "inline" | "floating" | undefined; + readonly collapsed?: boolean | undefined; + readonly defaultCollapsed?: boolean | undefined; + readonly onCollapsedChange?: ((collapsed: boolean) => void) | undefined; + /** A panel shown just above the bar, such as one an action opens. */ + readonly popover?: ReactNode | undefined; + /** The element around the bar and its popover, so a press on either can count as inside. */ + readonly rootRef?: Ref | undefined; + /** The bar itself, or its handle while collapsed, for measuring the space it covers. */ + readonly barRef?: Ref | undefined; + readonly collapseLabel?: string | undefined; + readonly expandLabel?: string | undefined; + readonly className?: string | undefined; +}; + +const COLLAPSE_ID = "action-bar:collapse"; + +function assignRef(ref: Ref | undefined, node: T | null) { + if (typeof ref === "function") ref(node); + else if (ref !== null && ref !== undefined) + (ref as RefObject).current = node; +} + +function focusableIds(groups: readonly ActionBarGroup[]): string[] { + return groups + .flat() + .flatMap((action) => action.row ?? [action]) + .filter((action) => action.note !== true && action.disabled !== true) + .map((action) => action.id); +} + +// The library's Button takes `ref` as a prop, which React 18 drops from +// function components; an `asChild` trigger needs the button node on 18 too. +const BarButton = forwardRef< + HTMLButtonElement, + ComponentProps<"button"> & { + variant: "ghost" | "secondary"; + size: "icon" | "sm"; + } +>(function BarButton({ variant, size, className, ...props }, ref) { + return ( + + ) : ( +
assignRef(barRef, node)} + role="toolbar" + aria-label={label} + onKeyDown={onKeyDown} + className="flex max-w-full min-w-0 shrink-0 items-center gap-0.5 rounded-full border border-border bg-background p-[3px]" + > + {shown.map((group, index) => ( + action.id).join(" ")}> + {index > 0 ? rule : null} + {group.map((action) => ( + {renderAction(action)} + ))} + + ))} + {floating ? ( + <> + {rule} + setCurrent(COLLAPSE_ID)} + onClick={() => toggle(true)} + > + + + ) : null} +
+ ); + + return ( +
*]:pointer-events-auto", + popover === undefined + ? "bottom-3 z-[2]" + : // Spans the container so a tall popover shrinks to fit rather than leave it. + "inset-y-3 z-[3] justify-end", + ) + : "sticky bottom-3 z-[2] mt-auto max-w-[calc(100%-1rem)] self-center", + className, + )} + > + {popover} + {bar} +
+ ); +} diff --git a/stories/primitives/action-bar.stories.tsx b/stories/primitives/action-bar.stories.tsx new file mode 100644 index 00000000..3ab2f6fa --- /dev/null +++ b/stories/primitives/action-bar.stories.tsx @@ -0,0 +1,245 @@ +import { + Download, + ListChecks, + Maximize2, + Monitor, + RotateCw, + Share, + Smartphone, + Tablet, + TriangleAlert, +} from "lucide-react"; +import { useState, type ReactNode } from "react"; + +import { ActionBar, type ActionBarGroup } from "../../src/ui/action-bar.js"; +import { Menu, MenuContent, MenuItem, MenuTrigger } from "../../src/ui/menu.js"; + +export default { title: "Primitives / ActionBar" }; + +const files: ActionBarGroup = [ + { id: "download", label: "Download", icon: