diff --git a/README.md b/README.md index b3204ea7..da372cb3 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,12 @@ Requires Node 24 or later. Tested on React 19; the peer range also accepts React Optional peers are needed only by the modules that import them. Those modules are not in the root entry, so import them by subpath: -| Peer | Subpaths | -| ------------------------------- | ------------------------------------------------------------- | -| `@radix-ui/react-dialog` | `ui/dialog`, `ui/command-palette`, `ui/mic-permission-dialog` | -| `@radix-ui/react-dropdown-menu` | `ui/menu` | -| `@radix-ui/react-tooltip` | `ui/tooltip` | -| `sonner` | `ui/toast` | +| Peer | Subpaths | +| ------------------------------- | ---------------------------------------------------------------------------------- | +| `@radix-ui/react-dialog` | `ui/dialog`, `ui/command-palette`, `ui/sidebar-search`, `ui/mic-permission-dialog` | +| `@radix-ui/react-dropdown-menu` | `ui/menu` | +| `@radix-ui/react-tooltip` | `ui/tooltip` | +| `sonner` | `ui/toast` | ## Quickstart @@ -190,27 +190,28 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", #### Layout -| Component | What it is | -| ------------------- | -------------------------------------------- | -| `PageShell` | Page margins and scroll ownership | -| `PagePanel` | Bordered page-content frame | -| `TopBar` | Title, breadcrumbs and actions row | -| `Sidebar` | Collapsible navigation sidebar | -| `SidebarPanel` | Sidebar with pins, body and footer | -| `SidebarItemRow` | One sidebar row | -| `ListDetail` | Index on the left, open item on the right | -| `InspectorShell` | Side inspector rail | -| `Section` | Titled block of content | -| `SettingsPanel` | Settings frame with optional save | -| `LibraryPageHeader` | Title, count and controls for a library page | -| `Card` | Bordered content card | -| `EmptyState` | Title, description, icon and action | -| `RichEmptyState` | Empty state with suggested next-step actions | -| `BootScreen` | Screen shown before the app loads | -| `DitherCanvas` | Animated dithered background | -| `CorbitsMark` | Corbits logo | -| `AuthLayout` | Sign-in page layout | -| `LoginForm` | Sign-in and sign-up form, with optional SSO | +| Component | What it is | +| ------------------- | -------------------------------------------------------------- | +| `PageShell` | Page margins and scroll ownership | +| `PagePanel` | Bordered page-content frame | +| `TopBar` | Title, breadcrumbs and actions row | +| `Sidebar` | Collapsible navigation sidebar | +| `SidebarSearch` | Search row labelled Cmd+K (optional peer, `ui/sidebar-search`) | +| `SidebarPanel` | Sidebar with pins, body and footer | +| `SidebarItemRow` | One sidebar row | +| `ListDetail` | Index on the left, open item on the right | +| `InspectorShell` | Side inspector rail | +| `Section` | Titled block of content | +| `SettingsPanel` | Settings frame with optional save | +| `LibraryPageHeader` | Title, count and controls for a library page | +| `Card` | Bordered content card | +| `EmptyState` | Title, description, icon and action | +| `RichEmptyState` | Empty state with suggested next-step actions | +| `BootScreen` | Screen shown before the app loads | +| `DitherCanvas` | Animated dithered background | +| `CorbitsMark` | Corbits logo | +| `AuthLayout` | Sign-in page layout | +| `LoginForm` | Sign-in and sign-up form, with optional SSO | #### Controls @@ -257,7 +258,7 @@ The host app owns the connection to the hub; components only take props. - Install from npm. Git installs (`github:corbitsdev/react-ui#`) no longer build `dist/`. - Per-module subpaths are gone: import from `@corbits/react-ui` (`import { Button } from "@corbits/react-ui"`). -- `ui/dialog`, `ui/command-palette`, `ui/mic-permission-dialog`, `ui/menu`, `ui/tooltip` and `ui/toast` are subpath-only, and their peers are optional: `import { Toaster, toast } from "@corbits/react-ui/ui/toast"`. Install the peer for each subpath you keep. +- `ui/dialog`, `ui/command-palette`, `ui/sidebar-search`, `ui/mic-permission-dialog`, `ui/menu`, `ui/tooltip` and `ui/toast` are subpath-only, and their peers are optional: `import { Toaster, toast } from "@corbits/react-ui/ui/toast"`. Install the peer for each subpath you keep. - `lib/csv`, `lib/url`, `lib/utils`, `lib/workflow-registry` and `ui/chat-dock-timing` are internal. Replace `cn` with `twMerge(clsx(...))`. - Stateful components ship `"use client"`. Client wrappers you wrote for them still work, and can go. - 84 unused modules are removed, `ui/command` is removed, `ActivityBlock` is renamed `ReasoningBlock`, and `AuthLayout` no longer renders `DitherCanvas` by default. diff --git a/package.json b/package.json index dafd138c..849c980e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,10 @@ "types": "./dist/ui/command-palette.d.ts", "default": "./dist/ui/command-palette.js" }, + "./ui/sidebar-search": { + "types": "./dist/ui/sidebar-search.d.ts", + "default": "./dist/ui/sidebar-search.js" + }, "./ui/dialog": { "types": "./dist/ui/dialog.d.ts", "default": "./dist/ui/dialog.js" diff --git a/src/index.ts b/src/index.ts index 990fc983..8883b2f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ // barrel is one module, so any root import would load that peer. They have // their own entries in the `exports` map instead: // @corbits/react-ui/ui/command-palette +// @corbits/react-ui/ui/sidebar-search // @corbits/react-ui/ui/dialog // @corbits/react-ui/ui/menu // @corbits/react-ui/ui/mic-permission-dialog diff --git a/src/ui/command-palette.tsx b/src/ui/command-palette.tsx index 1f6a3e2e..6ce66eb6 100644 --- a/src/ui/command-palette.tsx +++ b/src/ui/command-palette.tsx @@ -520,36 +520,60 @@ export function CommandPaletteInline({ ); } +const commandShortcutSubscribers = new Set<() => void>(); + +function onCommandShortcutKeyDown(event: KeyboardEvent): void { + if ( + event.key.toLowerCase() !== "k" || + !(event.metaKey || event.ctrlKey) || + event.repeat + ) { + return; + } + const target = event.target as HTMLElement | null; + if ( + target?.isContentEditable === true || + /^(input|textarea|select)$/i.test(target?.tagName ?? "") + ) { + return; + } + const subscriber = commandShortcutSubscribers.values().next().value; + if (subscriber === undefined) return; + event.preventDefault(); + subscriber(); +} + /** * Binds ⌘K / Ctrl-K to a toggle. * * A hook and not built into the palette, because plenty of apps already own * their global key handling and a second listener fighting theirs is worse - * than no listener at all. It ignores repeats from a held key and lets the - * browser keep its own ⌘K where the user is typing into a text field — + * than no listener at all. Every mounted caller shares one document listener, + * and a shortcut invokes exactly one subscriber, so a host toggle and a search + * row cannot undo each other. A later subscriber keeps the listener when the + * one that attached it unmounts. It ignores repeats from a held key and lets + * the browser keep its own ⌘K where the user is typing into a text field — * stealing it there is how a palette becomes the thing that eats your search - * box. + * box. Pass `enabled: false` to stay out of the subscriber set. */ export function useCommandShortcut(onToggle: () => void, enabled = true): void { + const onToggleRef = useRef(onToggle); + onToggleRef.current = onToggle; + useEffect(() => { if (!enabled) return; - const onKeyDown = (event: KeyboardEvent) => { - if ( - event.key.toLowerCase() !== "k" || - !(event.metaKey || event.ctrlKey) || - event.repeat - ) - return; - const target = event.target as HTMLElement | null; - if ( - target?.isContentEditable === true || - /^(input|textarea|select)$/i.test(target?.tagName ?? "") - ) - return; - event.preventDefault(); - onToggle(); + const subscriber = () => { + onToggleRef.current(); + }; + commandShortcutSubscribers.add(subscriber); + if (commandShortcutSubscribers.size === 1) { + document.addEventListener("keydown", onCommandShortcutKeyDown); + } + return () => { + commandShortcutSubscribers.delete(subscriber); + if (commandShortcutSubscribers.size === 0) { + document.removeEventListener("keydown", onCommandShortcutKeyDown); + } }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [onToggle, enabled]); + }, [enabled]); } diff --git a/src/ui/sidebar-search.test.tsx b/src/ui/sidebar-search.test.tsx new file mode 100644 index 00000000..27558b9a --- /dev/null +++ b/src/ui/sidebar-search.test.tsx @@ -0,0 +1,268 @@ +import { expect, test } from "bun:test"; +import { act, StrictMode, useState } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; + +import { CommandPalette, useCommandShortcut } from "./command-palette.js"; +import { Sidebar } from "./sidebar.js"; +import { SidebarSearch } from "./sidebar-search.js"; + +function pressCommandK() { + pressShortcut(true, false); +} + +function pressCtrlK() { + pressShortcut(false, true); +} + +function pressShortcut(metaKey: boolean, ctrlKey: boolean) { + act(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey, + ctrlKey, + bubbles: true, + cancelable: true, + }), + ); + }); +} + +function mount(node: ReactNode) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { + host, + unmount() { + act(() => root.unmount()); + host.remove(); + }, + }; +} + +const paletteProps = { + query: "", + onQueryChange: () => {}, + groups: [], + onSelect: () => {}, +}; + +test("Cmd+K on the search control opens the command palette", () => { + function Host() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(true)} /> + + + + ); + } + + const view = mount(); + + const control = view.host.querySelector("[data-slot='sidebar-search']"); + expect(control?.textContent).toContain("Cmd+K"); + expect(control?.textContent).toContain("Search"); + expect(control?.getAttribute("aria-keyshortcuts")).toBe("Meta+K Control+K"); + expect( + document.body.querySelector("[data-slot='command-palette-input']"), + ).toBeNull(); + + pressCommandK(); + + expect( + document.body.querySelector("[data-slot='command-palette-input']"), + ).not.toBeNull(); + + view.unmount(); +}); + +test("Ctrl+K opens the palette unless a text field is focused", () => { + function Host() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(true)} /> + + + + + ); + } + + const view = mount(); + const field = view.host.querySelector( + "[data-slot='search-field']", + ) as HTMLInputElement; + field.focus(); + act(() => { + field.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + expect( + document.body.querySelector("[data-slot='command-palette-input']"), + ).toBeNull(); + + field.blur(); + pressCtrlK(); + expect( + document.body.querySelector("[data-slot='command-palette-input']"), + ).not.toBeNull(); + + view.unmount(); +}); + +test("a host toggle plus search finishes with the palette open", () => { + function Host() { + const [open, setOpen] = useState(false); + useCommandShortcut(() => setOpen((value) => !value)); + return ( + <> + setOpen(true)} /> + + + ); + } + + const view = mount(); + pressCommandK(); + expect( + document.body.querySelector("[data-slot='command-palette-input']"), + ).not.toBeNull(); + view.unmount(); +}); + +test("two search controls invoke onOpen once per Meta+K", () => { + let calls = 0; + function open() { + calls += 1; + } + + const view = mount( + <> + + + , + ); + pressCommandK(); + expect(calls).toBe(1); + view.unmount(); +}); + +test("a disabled search ignores the shortcut and does not advertise it", () => { + let calls = 0; + const view = mount( + { + calls += 1; + }} + />, + ); + const control = view.host.querySelector( + "[data-slot='sidebar-search']", + ) as HTMLButtonElement; + expect(control.disabled).toBe(true); + expect(control.getAttribute("aria-keyshortcuts")).toBeNull(); + expect(control.querySelector("kbd")).toBeNull(); + expect(control.textContent).not.toContain("Cmd+K"); + + act(() => { + control.click(); + }); + pressCommandK(); + expect(calls).toBe(0); + view.unmount(); +}); + +test("unmounting the subscriber that attached the listener leaves it working", () => { + const calls = { first: 0, second: 0 }; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + function Fixture({ showFirst }: { showFirst: boolean }) { + return ( + <> + {showFirst ? ( + { + calls.first += 1; + }} + /> + ) : null} + { + calls.second += 1; + }} + /> + + ); + } + + act(() => { + root.render(); + }); + act(() => { + root.render(); + }); + pressCommandK(); + expect(calls.first).toBe(0); + expect(calls.second).toBe(1); + + act(() => root.unmount()); + host.remove(); +}); + +test("strict mode remount leaves the shortcut working", () => { + let calls = 0; + const view = mount( + + { + calls += 1; + }} + /> + , + ); + pressCommandK(); + expect(calls).toBe(1); + view.unmount(); +}); + +test("the search label collapses to the icon with the rail", () => { + const view = mount( + + {}} /> + , + ); + + const control = view.host.querySelector("[data-slot='sidebar-search']"); + const icon = control?.querySelector("svg"); + const label = control?.querySelector("span.min-w-0"); + const hint = control?.querySelector("kbd"); + expect(icon).not.toBeNull(); + expect(label?.className).toContain( + "group-data-[collapsed=true]/sidebar:sr-only", + ); + expect(hint?.textContent).toBe("Cmd+K"); + expect(hint?.className).toContain( + "group-data-[collapsed=true]/sidebar:sr-only", + ); + + view.unmount(); +}); diff --git a/src/ui/sidebar-search.tsx b/src/ui/sidebar-search.tsx new file mode 100644 index 00000000..ddd882b4 --- /dev/null +++ b/src/ui/sidebar-search.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Search } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "../lib/utils.js"; +import { useCommandShortcut } from "./command-palette.js"; + +export type SidebarSearchProps = Omit< + React.ComponentProps<"button">, + "onClick" | "children" +> & { + /** + * Opens the host's `CommandPalette`. Click and the palette's existing + * Cmd+K / Ctrl+K listener both call this. The host owns whether the + * palette is open; this control does not. + */ + onOpen: () => void; +}; + +/** + * Search row for the rail. Same item styling as `SidebarItem`, so the label + * and the Cmd+K hint collapse to the icon with the rest of the rail. + * + * Lives beside `sidebar.tsx` rather than in it: binding the shortcut imports + * the command palette, and that module pulls in the optional dialog peer. + * The root barrel must not reach that peer. + */ +export function SidebarSearch({ + onOpen, + className, + disabled, + ...props +}: SidebarSearchProps) { + const shortcutEnabled = disabled !== true; + useCommandShortcut(onOpen, shortcutEnabled); + return ( +
+ +
+ ); +} diff --git a/src/ui/sidebar.test.tsx b/src/ui/sidebar.test.tsx index 2c86e95f..7b2f602f 100644 --- a/src/ui/sidebar.test.tsx +++ b/src/ui/sidebar.test.tsx @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { act } from "react"; import { createRoot } from "react-dom/client"; -import { SidebarItem } from "./sidebar.js"; +import { SidebarCollapseToggle, SidebarItem } from "./sidebar.js"; test("asChild renders the consumer's link with the icon, label and count inside it", () => { const host = document.createElement("div"); @@ -24,3 +24,22 @@ test("asChild renders the consumer's link with the icon, label and count inside act(() => root.unmount()); host.remove(); }); + +test("collapse toggle reports its state through aria-expanded", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + {}} />, + ); + }); + const button = host.querySelector("[data-slot='sidebar-collapse-toggle']"); + expect(button?.getAttribute("aria-expanded")).toBe("true"); + act(() => { + root.render( {}} />); + }); + expect(button?.getAttribute("aria-expanded")).toBe("false"); + act(() => root.unmount()); + host.remove(); +}); diff --git a/stories/primitives/sidebar.stories.tsx b/stories/primitives/sidebar.stories.tsx new file mode 100644 index 00000000..2a4be3c9 --- /dev/null +++ b/stories/primitives/sidebar.stories.tsx @@ -0,0 +1,42 @@ +import { Inbox } from "lucide-react"; +import { useState } from "react"; + +import { + Sidebar, + SidebarCollapseToggle, + SidebarContent, + SidebarHeader, + SidebarItem, + SidebarSection, +} from "../../src/ui/sidebar.js"; +import { SidebarSearch } from "../../src/ui/sidebar-search.js"; + +export default { title: "Primitives / Sidebar" }; + +export const WithSearch = () => { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+ + + + Workspace + + setCollapsed((current) => !current)} + /> + + + {}} /> + + }> + Inbox + + + + +
+ ); +};