diff --git a/.agents/skills/frontend-conventions/SKILL.md b/.agents/skills/frontend-conventions/SKILL.md new file mode 100644 index 000000000..3a8650ddb --- /dev/null +++ b/.agents/skills/frontend-conventions/SKILL.md @@ -0,0 +1,104 @@ +--- +name: frontend-conventions +description: Use for creating, modifying, moving, or reviewing React frontend code anywhere in packages/*, including Workshop pages, gatekeeper management apps, shared UI, components, hooks, forms, interactions, styling, accessibility, and frontend tests. +--- + +# Frontend Conventions + +Apply these conventions to the Workshop SPA, gatekeeper management SPAs, and `@gadgets/ui`. +Package-level `AGENTS.md` files add product-specific rules but do not replace this guidance. + +## Ownership And Organization + +Organize code by product ownership before implementation type. Feature directories own product +behavior and may contain components, hooks, tests, and utilities that change for the same reason. + +Start directories flat. Do not introduce `components/`, `hooks/`, `helpers/`, or `tests/` +subdirectories merely to classify files. Introduce a responsibility-named subsystem directory only +when several files form a coherent unit or the flat directory becomes difficult to scan. + +Use PascalCase filenames for components and camelCase filenames for hooks and non-component +modules. Colocate `*.test.ts(x)` files with their subject. Feature organization does not replace +the one-component-per-file model. + +Keep product behavior with its product even when another feature consumes it. Promote code only as +high as its ownership requires: + +- Code shared within one feature belongs at the nearest common feature directory. +- Feature-independent code shared across unrelated areas of one app may live in that app's + `components/` or `hooks/` directory. +- Runtime UI shared by independent frontends belongs in `@gadgets/ui`. +- Do not merge components merely because they look similar. Avoid generic prop-heavy abstractions + that erase domain behavior. + +## Components And Hooks + +Create a separate component when it owns meaningful state, effects, interactions, accessibility +behavior, or reusable responsibility; represents a distinct UI concern; or obscures its parent's +main flow. Keep small stateless render helpers private until they develop an independent concern. + +Extract a hook when it owns a coherent behavior or external synchronization lifecycle, not simply +to shorten a file. Keep code together when an extracted child would mostly forward markup or depend +on the parent's refs, setters, and synchronization callbacks. + +Prefer named arrow-function components and hooks. Type props directly rather than using `React.FC`. +Give wrappers such as `memo` and `forwardRef` stable DevTools names. + +## Component APIs + +Represent props that are valid only together as an object or discriminated union. A controlled +value requires a change callback; otherwise expose an uncontrolled initial value. Do not copy a +controlled prop into local state with an Effect. + +Name callbacks `on` and pass domain values rather than React setters or browser events. +Add `children`, slots, variants, `className`, DOM passthrough, and imperative refs only for current +callers, not speculative reuse. + +Use context for genuinely application-wide values such as authentication, theme, and toasts. Pass +instance-specific feature data and actions through props. + +## Kumo And Styling + +Use Kumo components and semantic tokens by default. Check Kumo and `@gadgets/ui` before creating a +control or interaction pattern. A shared Gadgets component should compose Kumo behavior, not merely +rename or restyle a primitive. + +Do not add custom color literals, arbitrary Tailwind colors, feature-local token systems, or local +replacements for Kumo surfaces, borders, text, status, focus, and interaction tokens unless the user +explicitly requests them. Existing legacy colors are not precedent. + +Tailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and +typography. Use custom CSS only for technical behavior Kumo and utilities cannot express. Global +Kumo token theming is an application-level decision and must not be changed during ordinary feature +work. + +When Kumo is unsuitable, identify the concrete behavioral or accessibility gap before introducing +a shared abstraction. + +## React + +Treat Effects as synchronization with external systems, not as derived-state machinery or a way to +sequence user interactions. Calculate render data during render, keep state near its owner, prefer a +component `key` for identity resets, and use `useSyncExternalStore` for suitable external stores. + +Effects that fetch or subscribe must clean up stale work and remain correct when restarted. Avoid +chains of Effects and do not synchronize two pieces of React state when one can be derived. + +Do not add `useMemo` or `useCallback` without a concrete identity or performance need. Preserve +keyboard behavior, focus management, accessible names, announcements, and mouse/touch/hybrid input +parity. RPC stubs must follow the disposal and React state rules in the root `AGENTS.md`. + +## Comments + +Prefer names and types that communicate intent. Comments should explain non-obvious constraints, +security or performance reasons, and deliberate departures from conventions. Do not narrate the +next line. Remove or update comments when their constraint changes. + +## Tests + +Tests should protect observable behavior, product rules, accessibility, state transitions, races, +and failure paths. Do not test React, JavaScript, Kumo, or another framework's own behavior merely +for coverage. Avoid assertions coupled only to implementation details or trivial passthrough. + +Behavior-preserving moves should keep tests unchanged apart from imports. Add focused coverage only +when an extraction exposes important previously untested logic. diff --git a/AGENTS.md b/AGENTS.md index bca45737c..a9a812019 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ The project structure is: * The RPC protocol is Cap'n Web, which has similar semantics to Cloudflare's Worker-to-Worker RPC system, while being able to run in a browser over WebSocket. Read the readme for details. * packages/configurator-ui: Type-only component helpers used by optional gatekeeper resource configurator UI modules. * Gatekeeper configurator UI modules are compiled by `scripts/build-gatekeeper-configurator.ts` as part of package builds. +* packages/ui: Shared runtime React UI used across the Workshop and gatekeeper management apps. + * Kumo remains the primitive and token foundation. Reusable Gadgets interaction patterns and composed controls belong here rather than being reimplemented in individual apps. * packages/gatekeeper-*: Gatekeeper workers for external service integrations. * Each gatekeeper runs as a separate Cloudflare Worker — with one exception the prefix does not capture: a `gatekeeper-*` package with **no `wrangler.jsonc` is a library, not a worker** (`gatekeeper-kit` here; `gatekeeper-shared` in the internal repo). Deployable discovery is config-gated, not name-gated — `readDeployablePackages` in `scripts/release/manifest-lib.ts` keys solely on the presence of `wrangler.jsonc`, and `run-dev-server.ts` requires it too — so adding one to a library package is what would make it deployable, at which point `workerKind` would classify it a gatekeeper by prefix and the deploy wizard would demand `CLIENT_ID`/`CLIENT_SECRET` for it. `manifest-lib.test.ts` fails first if that ever happens. * Gatekeepers handle OAuth flows and provide sandboxed access to external APIs. A connect URL is a bearer capability, so every connect/reconnect flow ends on the kit's `connectHandoffPageHtml`, which sends the popup to the Workshop's `/connect/handoff` page, and that page redeems the single-use ticket over the popup's own session together with a per-flow nonce (see `docs/connect-handoff.md`); a reconnect stages its new credentials via `gatekeeper-kit/credential-stage` until the Workshop calls `GatekeeperUser.commitReconnect(stageId)` with the id that completion reported; completion is confirmed through the ticket, never through the URL alone. @@ -40,6 +42,24 @@ The project structure is: * `src/configurator/*.tsx` duplicate the resource-URL grammar from `resources.ts` and **must**: `build-gatekeeper-configurator.mjs` transpiles each per-file, stripping only `@gadgets/configurator-ui` and type-only imports, so they cannot import runtime helpers. `__tests__/configurator-url.test.ts` keeps the copies in step, and `configurator-fields.test.ts` drives `render` against a mocked runtime — the runtime's `clearFields` only drops an autocomplete's typed query, so a dependent field must *also* be nulled through `setValues` or the stale value silently survives into the resource URL. * packages/router: The public origin of a deployed gadgets instance. Serves the workshop-frontend assets and routes by path prefix: `/api/*` and `/blueprint-screenshot/*` to the workshop backend, `/gatekeeper//*` to whichever gatekeepers are bound (discovered by scanning its own `GATEKEEPER_*` service bindings, so installing a gatekeeper is purely a binding change). The same worker doubles as the dev router (`pnpm dev-server`): with no `ASSETS` binding it proxies frontend requests to the Vite dev server instead. +Frontend conventions (Workshop, gatekeeper management apps, and shared UI): + +* IMPORTANT: Load the `frontend-conventions` skill before creating, materially changing, moving, or reviewing React frontend code anywhere under `packages/`. The bullets below are the mandatory summary; the skill contains the complete conventions and examples. + +* Organize code by product ownership before implementation type. Keep feature-owned components, hooks, tests, and utilities together; start directories flat and introduce responsibility-named subdirectories only when a subsystem grows. +* Use PascalCase filenames for components and camelCase filenames for hooks and non-component modules. Colocate `*.test.ts(x)` files with their subject. +* Keep small stateless render helpers private. Extract a component or hook when it owns meaningful state, effects, interactions, accessibility behavior, or a reusable responsibility. +* Cross-application UI belongs in `@gadgets/ui`. An app's local `components/` directory is shared only within that app. Product-specific compositions stay with the product even when they use shared primitives. +* Use Kumo components and semantic Kumo tokens by default. Check Kumo and `@gadgets/ui` before creating a control or interaction pattern. Do not add custom color literals, feature-local token systems, or wrappers that only restyle Kumo. +* Tailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and typography. Custom CSS is for technical behavior Kumo and utilities cannot express. +* Represent props that are valid only together as an object or discriminated union. Controlled values require a change callback. Name callbacks `on` and pass domain values rather than React setters or browser events. +* Add slots, variants, DOM passthrough, imperative refs, and other customization surface only for current callers. Do not generalize based on speculative reuse. +* Treat Effects as synchronization with external systems, not as derived-state machinery. Keep state close to its owner, calculate render data during render, clean up subscriptions, and avoid chains of Effects. +* Prefer named arrow-function components and hooks. Do not add `useMemo` or `useCallback` without a concrete identity or performance need. +* Preserve keyboard behavior, focus management, accessible names, and announcements when building or extracting interactions. +* Tests should protect observable behavior, product rules, accessibility, state transitions, races, and failure paths. Do not test framework behavior or implementation details merely for coverage. +* Prefer code that communicates intent through names and types. Comments should explain non-obvious constraints and reasons, not narrate the next line. + Deployment admin settings (the `/admin` panel) follow a few conventions worth knowing when extending them: * `packages/workshop-backend/src/admin-config.ts` defines `AdminConfig` — the deployment's "soft" customizations: agent instructions, banners/theme, and which gatekeeper connectors/resources are offered (plus the three-state mode for auto-provisioning gatekeepers, see `provisioning-policy.ts`). Connectors/resources default to enabled and the admin UI opts them *out*; auto-provisioning gatekeepers default to *optional*. **Authentication/authorization config (sign-in providers via `AUTH_GATEKEEPERS`, password login via `DISABLE_PASSWORD_AUTH`) is deliberately NOT here** — it stays env-var driven (`auth/config.ts`) so it can't be changed by a compromised admin session. diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md new file mode 100644 index 000000000..5dd1966e4 --- /dev/null +++ b/packages/ui/AGENTS.md @@ -0,0 +1,23 @@ +# Shared UI + +`@gadgets/ui` is the shared runtime React UI layer for the Workshop and gatekeeper management apps. +Kumo owns low-level controls and semantic tokens; this package owns reusable Gadgets interaction +patterns and composed components. + +## Ownership + +- Add a component here when at least two independent frontends need the same feature-independent + behavior or when consistency across those frontends is an explicit product requirement. +- Keep product data fetching, routing, RPC, permissions, and domain workflows in the consuming app. +- Check Kumo before adding a primitive. Do not wrap Kumo solely to restyle or rename it. +- Export source directly. This package has no publish or emitted-build step and is marked private. +- Keep React and Kumo as peer dependencies so consumers use one runtime and design-system version. +- A Tailwind consumer must include `packages/ui/src` as an `@source`, because the shared components' + utility classes are compiled by the consuming app. + +## APIs And Tests + +- Prefer a headless behavior primitive plus a Kumo adapter when both are real consumers' needs. +- Keep the headless model free of presentation policy and styled-only fields where practical. +- Preserve accessibility and input parity across mouse, keyboard, touch, and hybrid devices. +- Colocate tests and test observable contracts rather than package boundaries or framework behavior. diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 000000000..bd50498bf --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,42 @@ +{ + "name": "@gadgets/ui", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./hierarchical-list": { + "types": "./src/HierarchicalList/index.ts", + "import": "./src/HierarchicalList/index.ts" + } + }, + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test:run": "vitest run" + }, + "peerDependencies": { + "@cloudflare/kumo": "^2.12.0", + "react": "^19.2.8" + }, + "dependencies": { + "@phosphor-icons/react": "^2.1.10", + "motion": "^13.2.0" + }, + "devDependencies": { + "@cloudflare/kumo": "^2.12.0", + "@gadgets/scripts": "workspace:*", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "jsdom": "^26.1.0", + "motion": "^13.2.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/ui/src/HierarchicalList/HierarchicalList.test.tsx b/packages/ui/src/HierarchicalList/HierarchicalList.test.tsx new file mode 100644 index 000000000..c4496df6b --- /dev/null +++ b/packages/ui/src/HierarchicalList/HierarchicalList.test.tsx @@ -0,0 +1,520 @@ +// @vitest-environment jsdom + +import { DropdownMenu } from "@cloudflare/kumo"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + HierarchicalList, + type HierarchicalListDropDestination, + type HierarchicalListItem, +} from "."; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const items: HierarchicalListItem[] = [ + { + id: "collection", + name: "Engineering", + metadata: "2 skills", + droppable: true, + children: [ + { id: "review", name: "Review code", draggable: true }, + { id: "deploy", name: "Deploy service", draggable: true }, + ], + }, +]; + +describe("HierarchicalList", () => { + let root: Root | undefined; + let container: HTMLDivElement | undefined; + + afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + Reflect.deleteProperty(document, "elementFromPoint"); + }); + + const render = (element: React.ReactNode) => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + act(() => root?.render(element)); + }; + + const buttonFor = (name: string) => Array.from(container!.querySelectorAll("button")) + .find((button) => button.textContent?.includes(name)); + + const rowFor = (name: string) => buttonFor(name); + + const dataTransfer = () => ({ + effectAllowed: "none", + dropEffect: "none", + setData: vi.fn<(format: string, data: string) => void>(), + }); + + const dispatchDrag = ( + target: HTMLElement, + type: string, + transfer: ReturnType, + clientY = 0, + ) => { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientY }); + Object.defineProperty(event, "dataTransfer", { value: transfer }); + act(() => target.dispatchEvent(event)); + }; + + const setRect = ( + element: Element, + { top, left = 0, width = 400, height = 40 }: { + top: number; + left?: number; + width?: number; + height?: number; + }, + ) => { + element.getBoundingClientRect = () => DOMRect.fromRect({ x: left, y: top, width, height }); + }; + + const dispatchTouchPointer = ( + target: HTMLElement, + type: string, + clientX: number, + clientY: number, + pointerId = 1, + ) => { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY }); + Object.defineProperties(event, { + isPrimary: { value: true }, + pointerId: { value: pointerId }, + pointerType: { value: "touch" }, + }); + act(() => target.dispatchEvent(event)); + }; + + it("expands branches and selects leaf items", () => { + const onItemClick = vi.fn<(item: HierarchicalListItem) => void>(); + render( + , + ); + + expect(container?.querySelector("ul")?.getAttribute("aria-label")).toBe("Skills"); + expect(buttonFor("Review code")).toBeUndefined(); + expect(buttonFor("Engineering")?.getAttribute("aria-current")).toBe("true"); + expect(buttonFor("Engineering")?.getAttribute("aria-expanded")).toBe("false"); + + act(() => buttonFor("Engineering")?.click()); + + const skillButton = buttonFor("Review code"); + expect(skillButton).toBeDefined(); + expect(buttonFor("Engineering")?.getAttribute("aria-expanded")).toBe("true"); + expect(skillButton?.hasAttribute("aria-expanded")).toBe(false); + expect(rowFor("Review code")?.draggable).toBe(false); + act(() => skillButton?.focus()); + act(() => skillButton?.dispatchEvent(new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }))); + expect(document.activeElement).toBe(buttonFor("Deploy service")); + act(() => document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + cancelable: true, + }))); + expect(document.activeElement).toBe(skillButton); + act(() => skillButton?.click()); + expect(onItemClick).toHaveBeenCalledWith(items[0].children?.[0]); + }); + + it("renders numeric zero metadata", () => { + render( + , + ); + + expect(rowFor("Empty collection")?.textContent).toContain("Empty collection0"); + }); + + it("scrolls from draggable rows and reorders from their touch handles", () => { + vi.stubGlobal("matchMedia", vi.fn(() => ({ + matches: true, + addEventListener: vi.fn<() => void>(), + removeEventListener: vi.fn<() => void>(), + }))); + const onMove = vi.fn<( + item: HierarchicalListItem, + destination: HierarchicalListDropDestination, + ) => void>(); + const onItemClick = vi.fn<(item: HierarchicalListItem) => void>(); + const touchItems: HierarchicalListItem[] = [ + { id: "source", name: "Source", draggable: true }, + { id: "target", name: "Target" }, + ]; + render( + , + ); + const source = rowFor("Source")!; + const handle = source.querySelector( + "[data-hierarchical-list-touch-drag-handle]", + )!; + const target = rowFor("Target")!; + setRect(container!.firstElementChild!, { top: 0 }); + setRect(source, { top: 0 }); + setRect(target, { top: 40 }); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: vi.fn<(x: number, y: number) => Element | null>(() => target), + }); + + expect(source.draggable).toBe(true); + expect(source.style.touchAction).not.toBe("none"); + expect(handle.style.touchAction).toBe("none"); + expect(handle.getAttribute("aria-hidden")).toBe("true"); + const rowMove = new MouseEvent("pointermove", { + bubbles: true, + cancelable: true, + clientX: 30, + clientY: 30, + }); + Object.defineProperties(rowMove, { + isPrimary: { value: true }, + pointerType: { value: "touch" }, + }); + dispatchTouchPointer(source, "pointerdown", 10, 10); + act(() => source.dispatchEvent(rowMove)); + expect(rowMove.defaultPrevented).toBe(false); + expect(container!.querySelector("[data-touch-drag-preview]")).toBeNull(); + dispatchTouchPointer(source, "pointercancel", 30, 30); + + dispatchTouchPointer(handle, "pointerdown", 10, 10); + dispatchTouchPointer(handle, "pointermove", 20, 20); + expect(container!.querySelector("[data-touch-drag-preview]")).toBeNull(); + dispatchTouchPointer(handle, "pointermove", 30, 30); + expect(container!.querySelector("[data-touch-drag-preview]")?.textContent).toContain("Source"); + expect(container!.querySelector("[data-touch-drag-preview]")?.parentElement + ?.style.pointerEvents).toBe("none"); + dispatchTouchPointer(handle, "pointercancel", 30, 30); + expect(container!.querySelector("[data-touch-drag-preview]")).toBeNull(); + expect(onMove).not.toHaveBeenCalled(); + + dispatchTouchPointer(handle, "pointerdown", 10, 10); + dispatchTouchPointer(handle, "pointermove", 30, 30); + dispatchTouchPointer(handle, "pointerup", 20, 60, 2); + expect(container!.querySelector("[data-touch-drag-preview]")?.textContent).toContain("Source"); + dispatchTouchPointer(handle, "pointermove", 20, 60); + dispatchTouchPointer(handle, "pointerup", 20, 60); + + expect(onMove).toHaveBeenCalledWith(touchItems[0], { parent: null, index: 1 }); + expect(container!.querySelector("[data-touch-drag-preview]")).toBeNull(); + act(() => handle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))); + act(() => source.click()); + expect(onItemClick).toHaveBeenCalledWith(touchItems[0]); + }); + + it("starts native mouse dragging from a visible touch handle on hybrid devices", () => { + vi.stubGlobal("matchMedia", vi.fn(() => ({ + matches: true, + addEventListener: vi.fn<() => void>(), + removeEventListener: vi.fn<() => void>(), + }))); + render( + {} }} + />, + ); + const source = rowFor("Source")!; + const handle = source.querySelector( + "[data-hierarchical-list-touch-drag-handle]", + )!; + const transfer = dataTransfer(); + + dispatchDrag(handle, "dragstart", transfer); + + expect(transfer.setData).toHaveBeenCalledWith("text/plain", "source"); + }); + + it("does not dispatch touch drops outside the originating list", () => { + const onMove = vi.fn<( + item: HierarchicalListItem, + destination: HierarchicalListDropDestination, + ) => void>(); + render( + , + ); + const source = rowFor("Source")!; + const handle = source.querySelector( + "[data-hierarchical-list-touch-drag-handle]", + )!; + const target = rowFor("Target")!; + setRect(source, { top: 0 }); + setRect(target, { top: 40 }); + const unrelatedTarget = document.createElement("div"); + const unrelatedDrop = vi.fn<() => void>(); + unrelatedTarget.addEventListener("drop", unrelatedDrop); + document.body.append(unrelatedTarget); + let hitTarget: Element = target; + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: vi.fn<(x: number, y: number) => Element | null>(() => hitTarget), + }); + + dispatchTouchPointer(handle, "pointerdown", 10, 10); + dispatchTouchPointer(handle, "pointermove", 30, 30); + hitTarget = unrelatedTarget; + dispatchTouchPointer(handle, "pointerup", 30, 60); + + expect(unrelatedDrop).not.toHaveBeenCalled(); + expect(onMove).not.toHaveBeenCalled(); + unrelatedTarget.remove(); + }); + + it("clears touch drag feedback when the source row is removed", () => { + const renderList = (listItems: readonly HierarchicalListItem[]) => ( + {} }} + interaction={{ touchDragThresholdPx: 8 }} + /> + ); + render(renderList([{ id: "source", name: "Source", draggable: true }])); + const source = rowFor("Source")!; + const handle = source.querySelector( + "[data-hierarchical-list-touch-drag-handle]", + )!; + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: vi.fn<(x: number, y: number) => Element | null>(() => source), + }); + dispatchTouchPointer(handle, "pointerdown", 10, 10); + dispatchTouchPointer(handle, "pointermove", 30, 30); + expect(container!.querySelector("[data-touch-drag-preview]")).not.toBeNull(); + + act(() => root!.render(renderList([]))); + + expect(container!.querySelector("[data-touch-drag-preview]")).toBeNull(); + }); + + it("opens an item's action menu from a right click", () => { + render( + Delete} + />, + ); + + const row = rowFor("Review code")!; + act(() => row.dispatchEvent(new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }))); + + expect(document.body.textContent).toContain("Delete"); + expect(container?.querySelectorAll("button")).toHaveLength(1); + }); + + it("opens an item's action drawer from a long press on touch devices", () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", vi.fn(() => ({ + matches: true, + addEventListener: vi.fn<() => void>(), + removeEventListener: vi.fn<() => void>(), + }))); + render( + Delete} + />, + ); + + const pointerDown = new MouseEvent("pointerdown", { bubbles: true, clientX: 20, clientY: 30 }); + Object.defineProperties(pointerDown, { + isPrimary: { value: true }, + pointerType: { value: "touch" }, + }); + act(() => rowFor("Review code")?.dispatchEvent(pointerDown)); + act(() => vi.advanceTimersByTime(499)); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + act(() => vi.advanceTimersByTime(1)); + + expect(document.body.textContent).toContain("Delete"); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); + + it("does not cancel a long press when a different touch ends", () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", vi.fn(() => ({ + matches: true, + addEventListener: vi.fn<() => void>(), + removeEventListener: vi.fn<() => void>(), + }))); + render( + Delete} + />, + ); + const row = rowFor("Review code")!; + + dispatchTouchPointer(row, "pointerdown", 20, 30, 1); + dispatchTouchPointer(row, "pointercancel", 25, 35, 2); + act(() => vi.advanceTimersByTime(500)); + + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); + + it("opens an item's action drawer from a context-menu event on narrow layouts", () => { + vi.stubGlobal("matchMedia", vi.fn(() => ({ + matches: true, + addEventListener: vi.fn<() => void>(), + removeEventListener: vi.fn<() => void>(), + }))); + render( + Delete} + />, + ); + const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + const row = rowFor("Review code")!; + + act(() => row.focus()); + act(() => row.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(true); + expect(document.body.textContent).toContain("Delete"); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.className).toContain("max-h-[calc(100dvh-1rem)]"); + const menu = document.querySelector('[role="menu"]')!; + expect(menu.parentElement?.className).toContain("overflow-y-auto"); + const label = document.getElementById(menu.getAttribute("aria-labelledby")!); + expect(label?.textContent).toBe("Review code"); + const menuItem = document.querySelector('[role="menuitem"]')!; + act(() => menuItem.dispatchEvent(new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }))); + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(row); + }); + + it("does not restore drawer focus over an action's destination", () => { + vi.stubGlobal("matchMedia", vi.fn(() => ({ + matches: true, + addEventListener: vi.fn<() => void>(), + removeEventListener: vi.fn<() => void>(), + }))); + const destination = document.createElement("button"); + destination.textContent = "Dialog control"; + document.body.append(destination); + render( + ( + destination.focus()}>Edit + )} + />, + ); + const row = rowFor("Review code")!; + act(() => row.dispatchEvent(new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }))); + const menuItem = document.querySelector('[role="menuitem"]')!; + + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + act(() => menuItem.dispatchEvent(new MouseEvent(type, { + bubbles: true, + cancelable: true, + }))); + } + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).not.toBe(row); + destination.remove(); + }); + + it("positions drop indicators in the scrolled list content", () => { + render( + {} }} + />, + ); + const listRoot = container!.querySelector("[data-hierarchical-list-root]")!; + const source = rowFor("Source")!; + listRoot.scrollTop = 100; + listRoot.scrollLeft = 25; + setRect(listRoot, { top: 20, left: 10, width: 300 }); + setRect(source, { top: 50, left: 30, width: 200 }); + + dispatchDrag(source, "dragstart", dataTransfer()); + + const indicator = container!.querySelector("[data-drop-indicator]")!; + expect(indicator.style.left).toBe("57px"); + expect(indicator.style.top).toBe("129.25px"); + expect(indicator.style.width).toBe("180px"); + }); + + it("does not suppress clicks when an item has no context actions", () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", vi.fn(() => ({ + matches: true, + addEventListener: vi.fn<() => void>(), + removeEventListener: vi.fn<() => void>(), + }))); + const item: HierarchicalListItem = { id: "skill", name: "Review code" }; + const onItemClick = vi.fn<(item: HierarchicalListItem) => void>(); + render( + null} + />, + ); + + const row = rowFor("Review code")!; + dispatchTouchPointer(row, "pointerdown", 20, 30); + act(() => vi.advanceTimersByTime(500)); + act(() => row.click()); + + expect(onItemClick).toHaveBeenCalledWith(item); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); +}); diff --git a/packages/ui/src/HierarchicalList/HierarchicalList.tsx b/packages/ui/src/HierarchicalList/HierarchicalList.tsx new file mode 100644 index 000000000..3cda08cd9 --- /dev/null +++ b/packages/ui/src/HierarchicalList/HierarchicalList.tsx @@ -0,0 +1,359 @@ +import { Button, DropdownMenu, LayerCard, Text } from "@cloudflare/kumo"; +import { ContextMenu } from "@cloudflare/kumo/primitives/context-menu"; +import { Drawer } from "@cloudflare/kumo/primitives/drawer"; +import { Menu } from "@cloudflare/kumo/primitives/menu"; +import { cn } from "@cloudflare/kumo/utils"; +import { CaretDownIcon, DotsSixVerticalIcon, FolderIcon } from "@phosphor-icons/react"; +import { AnimatePresence, motion } from "motion/react"; +import React, { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { + HierarchicalListPrimitive, + type HierarchicalListPrimitiveRowProps, + type HierarchicalListPrimitiveRowState, +} from "./HierarchicalListPrimitive"; +import type { HierarchicalListDragAndDropOptions } from "./HierarchicalListDragAndDrop"; +import { + useHierarchicalListActionDrawer, + type HierarchicalListActionPresentationOptions, + type HierarchicalListTouchInteractionOptions, +} from "./useHierarchicalListTouchInteractions"; +import type { + HierarchicalListExpansionProps, + HierarchicalListItem, +} from "./HierarchicalList.types"; + +const DRAG_PREVIEW_CLASS_NAME = cn( + "inline-flex h-9 max-w-64 items-center gap-2 overflow-hidden rounded-lg", + "bg-kumo-control px-3 text-sm font-medium text-kumo-default shadow-lg", + "ring-1 ring-kumo-line", +); +const itemPadding = (depth: number) => 12 + depth * 24; +const itemIcon = (item: HierarchicalListItem) => item.icon ?? ( + item.children !== undefined + ?