diff --git a/cli/commands/generate/adapter-generator.test.ts b/cli/commands/generate/adapter-generator.test.ts new file mode 100644 index 0000000000..5705f98b3b --- /dev/null +++ b/cli/commands/generate/adapter-generator.test.ts @@ -0,0 +1,69 @@ +import "#veryfront/schemas/_test-setup.ts"; +/** + * Tests for `veryfront generate adapter ` — vendoring a veryfront/ui + * engine adapter template into a consumer project. + */ +import { assert, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { join } from "#std/path.ts"; +import { generateUiAdapter } from "./adapter-generator.ts"; +import { getUiAdapterTemplate, listUiAdapters } from "../../templates/loader.ts"; + +describe("commands/generate/adapter-generator", () => { + it("the manifest ships the four engine templates", () => { + const engines = listUiAdapters(); + for (const engine of ["base-ui", "radix", "react-aria", "ariakit"]) { + assert(engines.includes(engine), `manifest missing ui-adapter:${engine}`); + } + }); + + it("base-ui template maps the overlay archetypes + exports its adapter", () => { + const files = getUiAdapterTemplate("base-ui"); + assert(files && files.length === 1, "base-ui template should be one file"); + const src = files![0]!.content; + for (const slot of ["popover:", "dialog:", "menu:", "tooltip:"]) { + assertStringIncludes(src, slot); + } + assertStringIncludes(src, "baseUiAdapter"); + }); + + it("vendors ui-adapters/.tsx into the project", async () => { + const dir = await Deno.makeTempDir(); + try { + await generateUiAdapter(dir, "radix"); + const dest = join(dir, "ui-adapters", "radix.tsx"); + const written = await Deno.readTextFile(dest); + assertStringIncludes(written, "radixAdapter"); + assertStringIncludes(written, "REFERENCE TEMPLATE"); + } finally { + await Deno.remove(dir, { recursive: true }); + } + }); + + it("does not clobber an already-vendored adapter", async () => { + const dir = await Deno.makeTempDir(); + try { + await Deno.mkdir(join(dir, "ui-adapters")); + const dest = join(dir, "ui-adapters", "base-ui.tsx"); + await Deno.writeTextFile(dest, "// my edited adapter\n"); + await generateUiAdapter(dir, "base-ui"); + const after = await Deno.readTextFile(dest); + assert(after === "// my edited adapter\n", "must not overwrite a vendored file"); + } finally { + await Deno.remove(dir, { recursive: true }); + } + }); + + it("rejects an unknown engine with the available list", async () => { + const dir = await Deno.makeTempDir(); + try { + await assertRejects( + () => generateUiAdapter(dir, "not-an-engine"), + Error, + "Unknown ui adapter engine", + ); + } finally { + await Deno.remove(dir, { recursive: true }); + } + }); +}); diff --git a/cli/commands/generate/adapter-generator.ts b/cli/commands/generate/adapter-generator.ts new file mode 100644 index 0000000000..addc7896d1 --- /dev/null +++ b/cli/commands/generate/adapter-generator.ts @@ -0,0 +1,86 @@ +/** + * `veryfront generate adapter ` — vendor a `veryfront/ui` engine adapter. + * + * Copies the `.tsx` reference template (Base UI / Radix / React Aria / + * Ariakit) verbatim into the consumer's `./ui-adapters/`. From then on the + * consumer OWNS the file and the engine package is THEIR dependency — `veryfront/ui` + * core stays engine-free (enforced by a CI guard), so the swap is opt-in and the + * engine version is on the consumer's schedule. Wire it up once via + * `Adapter}>` (see the file's docstring). + * + * @module cli/commands/generate/adapter-generator + */ +import { join } from "#std/path.ts"; +import { bold, brand, dim } from "#cli/ui"; +import { cliLogger } from "#cli/utils"; +import { createFileSystem } from "veryfront/platform"; +import { createError, toError } from "veryfront/errors"; +import { ensureDir } from "../../utils/fs.ts"; +import { getUiAdapterTemplate, listUiAdapters } from "../../templates/loader.ts"; + +/** npm package + wiring hint per engine, shown after scaffolding. */ +const ENGINE_PACKAGES: Record = { + "base-ui": { pkg: "@base-ui/react", adapter: "baseUiAdapter" }, + radix: { + pkg: + "@radix-ui/react-popover @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tooltip", + adapter: "radixAdapter", + }, + "react-aria": { pkg: "react-aria-components", adapter: "reactAriaAdapter" }, + ariakit: { pkg: "@ariakit/react", adapter: "ariakitAdapter" }, + vaul: { pkg: "vaul", adapter: "vaulAdapter" }, +}; + +/** Scaffold `ui-adapters/.tsx` into the project (idempotent — never clobbers). */ +export async function generateUiAdapter( + projectDir: string, + engine: string, +): Promise { + const files = getUiAdapterTemplate(engine); + if (!files || files.length === 0) { + const available = listUiAdapters(); + throw toError( + createError({ + type: "config", + message: available.length + ? `Unknown ui adapter engine "${engine}". Available: ${available.join(", ")}` + : `No ui adapter engines are available in this build.`, + }), + ); + } + + const fs = createFileSystem(); + const targetDir = join(projectDir, "ui-adapters"); + await ensureDir(targetDir); + + const written: string[] = []; + for (const file of files) { + const dest = join(targetDir, file.path); + if (await fs.exists(dest)) { + cliLogger.warn( + `ui-adapters/${file.path} already exists — left as-is (delete it to regenerate).`, + ); + continue; + } + await fs.writeTextFile(dest, file.content); + written.push(`ui-adapters/${file.path}`); + } + + const meta = ENGINE_PACKAGES[engine]; + for (const path of written) cliLogger.info(`Created ${brand(path)}`); + cliLogger.info(""); + cliLogger.info(bold("Next steps:")); + if (meta) { + cliLogger.info(` 1. Install the engine: ${brand(`npm i ${meta.pkg}`)}`); + cliLogger.info( + ` 2. Wrap your app: ${ + brand(``) + }`, + ); + } + cliLogger.info( + dim( + " You own this file now. veryfront/ui core stays engine-free; slots you don't map fall back to builtin.", + ), + ); +} diff --git a/cli/commands/generate/command-help.ts b/cli/commands/generate/command-help.ts index 3047f93383..5f7e45767f 100644 --- a/cli/commands/generate/command-help.ts +++ b/cli/commands/generate/command-help.ts @@ -19,9 +19,12 @@ export const generateHelp: CommandHelp = { "veryfront generate skill code-review", "veryfront generate integration # Interactive wizard", "veryfront generate integration twilio # With name preset", + "veryfront generate adapter base-ui # Vendor a veryfront/ui engine adapter", + "veryfront generate adapter radix", ], notes: [ - "Types: page, api, layout, component, tool, agent, prompt, workflow, task, resource, skill, integration", + "Types: page, api, layout, component, tool, agent, prompt, workflow, task, resource, skill, integration, adapter", "Integration type launches interactive wizard if name not provided", + "Adapter type vendors a veryfront/ui engine adapter into ./ui-adapters/ (engines: base-ui, radix, react-aria, ariakit, vaul)", ], }; diff --git a/cli/commands/generate/command.ts b/cli/commands/generate/command.ts index 5b7b2ffc1a..1fe488ffa4 100644 --- a/cli/commands/generate/command.ts +++ b/cli/commands/generate/command.ts @@ -2,6 +2,7 @@ import { getConfig } from "veryfront/config"; import { cliLogger } from "#cli/utils"; import { createError, toError } from "veryfront/errors"; import { generateIntegration } from "./integration-generator.ts"; +import { generateUiAdapter } from "./adapter-generator.ts"; import { isScaffoldType, scaffoldProjectFile } from "../../scaffold/engine.ts"; async function getPreferredRouter( @@ -32,6 +33,11 @@ export async function generateCommand( return; } + if (type === "adapter") { + await generateUiAdapter(projectDir, name); + return; + } + if (!isScaffoldType(type)) { throw toError( createError({ diff --git a/cli/commands/generate/handler.ts b/cli/commands/generate/handler.ts index 86b2be9ec2..b4f6e51363 100644 --- a/cli/commands/generate/handler.ts +++ b/cli/commands/generate/handler.ts @@ -10,7 +10,7 @@ import type { ParsedArgs } from "#cli/shared/types"; import { cwd } from "veryfront/platform"; import { SCAFFOLD_TYPES } from "../../scaffold/engine.ts"; -const VALID_TYPES = [...SCAFFOLD_TYPES, "integration"] as const; +const VALID_TYPES = [...SCAFFOLD_TYPES, "integration", "adapter"] as const; const getGenerateArgsSchema = defineSchema((v) => v.object({ diff --git a/cli/templates/loader.ts b/cli/templates/loader.ts index 590fb5ed5b..073a6b3f6e 100644 --- a/cli/templates/loader.ts +++ b/cli/templates/loader.ts @@ -60,7 +60,10 @@ export function getIntegrationTemplate( export function listTemplates(): string[] { return Object.keys(typedManifest.templates).filter( - (name) => !name.startsWith("integration:"), + (name) => + !name.startsWith("integration:") && + !name.startsWith("ui-adapter:") && + !name.startsWith("ai-rules:"), ); } @@ -69,3 +72,18 @@ export function listIntegrations(): string[] { .filter((name) => name.startsWith("integration:")) .map((name) => name.replace("integration:", "")); } + +/** The `.tsx` reference adapter for `veryfront generate adapter `. */ +export function getUiAdapterTemplate(engine: string): TemplateFile[] | null { + const entry = typedManifest.templates[`ui-adapter:${engine}`]; + if (!entry) return null; + + return getSortedFiles(entry); +} + +/** Engine names with a shippable `ui-adapters/.tsx` reference template. */ +export function listUiAdapters(): string[] { + return Object.keys(typedManifest.templates) + .filter((name) => name.startsWith("ui-adapter:")) + .map((name) => name.replace("ui-adapter:", "")); +} diff --git a/cli/templates/manifest.json b/cli/templates/manifest.json index 0c253c588b..edb8b77018 100644 --- a/cli/templates/manifest.json +++ b/cli/templates/manifest.json @@ -92,6 +92,9 @@ "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"strict\": true,\n \"jsx\": \"react-jsx\",\n \"skipLibCheck\": true,\n \"esModuleInterop\": true,\n \"paths\": {\n \"@/*\": [\"./*\"]\n }\n },\n \"include\": [\"**/*.ts\", \"**/*.tsx\"],\n \"exclude\": [\"node_modules\"]\n}\n" } }, + "saas": { + "files": {} + }, "saas-starter": { "files": { "agents/assistant.ts": "import { agent } from \"veryfront/agent\";\n\nexport default agent({\n id: \"assistant\",\n name: \"SaaS Assistant\",\n description: \"Answer product and customer questions.\",\n system: \"You are a helpful AI assistant. Be concise and direct.\",\n tools: true,\n memory: { type: \"conversation\", maxMessages: 50 },\n maxSteps: 10,\n suggestions: [\n {\n type: \"prompt\",\n title: \"Summarize account\",\n prompt: \"Summarize the latest account activity.\",\n },\n {\n type: \"prompt\",\n title: \"Find customers\",\n prompt: \"Find customers who need attention.\",\n },\n ],\n});\n", @@ -647,6 +650,31 @@ "files": { "windsurf.md": "# Veryfront project guide\n\nFollow `AGENTS.md` when it exists. If it does not exist, use this guide.\n\nThis is a Veryfront project. Veryfront is a framework for building and running AI apps and agents in TypeScript and React.\n\n## Project conventions\n\nUse these folders as runtime boundaries. Create folders only when the feature needs them.\n\n- `app/`: pages, layouts, route handlers, and user-facing API routes.\n- `agents/`: model reasoning and tool use.\n- `tools/`: deterministic callable capabilities.\n- `workflows/`: multi-step coordination.\n- `skills/`: reusable agent instructions in `skills//SKILL.md`.\n- `veryfront.config.ts`: project metadata and router configuration.\n\n## Developer loop\n\n1. Start local development with `veryfront dev`.\n2. Generate new files with `veryfront generate `.\n3. Inspect current CLI commands with `veryfront schema --json`.\n4. Verify discovered routes with `veryfront routes`.\n5. Run focused tests and builds before shipping.\n6. Use https://veryfront.com/docs when local files and CLI schema do not answer a Veryfront API or convention question.\n\n## Coding agent loop\n\nPrefer Veryfront scaffold tools over hand-written boilerplate. Keep app routes, agents, tools, workflows, and skills in their expected folders.\n\n## Inference\n\nAgent routes need model access. Use `veryfront login` for the Veryfront Cloud gateway, set `VERYFRONT_API_TOKEN`, or set provider keys such as `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`.\n" } + }, + "ui-adapter:ariakit": { + "files": { + "ariakit.tsx": "/**\n * Ariakit adapter for veryfront/ui — REFERENCE TEMPLATE.\n *\n * `npx veryfront generate adapter ariakit` copies this file into YOUR repo\n * (e.g. `./ui-adapters/ariakit.tsx`). You own it from then on. `@ariakit/react`\n * is YOUR dependency, bumped on YOUR schedule; `veryfront/ui` core depends on no\n * engine (enforced by a CI guard). Wire it up once:\n *\n * ```tsx\n * import { UIAdapterProvider } from \"veryfront/ui\";\n * import { ariakitAdapter } from \"./ui-adapters/ariakit.tsx\";\n *\n * {app};\n * ```\n *\n * The provider merges a PARTIAL map over the builtin, so you can adopt Ariakit\n * for just some parts and leave the rest zero-dependency. Slot coverage: 9/11 —\n * `popover` / `dialog` / `menu` / `tooltip` / `select` / `combobox` /\n * `disclosure` / `toolbar` / `tabs` map to Ariakit; `toast` and `toggleGroup`\n * STAY builtin because Ariakit ships NO toast primitive (no toast store /\n * region / queue) and no dedicated toggle-group primitive. Don't hand-roll them\n * here —\n * the zero-dependency builtins already satisfy `ToastParts` / `ToggleGroupParts`,\n * so leaving those unset lets the provider fall back to them.\n *\n * Ariakit is STORE-based: each primitive is `useXStore(...)` → an imperative\n * store shared via ``, with role components (`Popover`,\n * `PopoverDisclosure`, …) reading it from context. We BRIDGE that store model\n * onto veryfront's render-slot contract:\n * 1. `Root` builds the store from `DisclosureProps` — Ariakit's store option is\n * `setOpen: (open: boolean) => void`, which already matches our single-arg\n * `onOpenChange` (no `eventDetails` to drop), plus `open` / `defaultOpen`.\n * 2. Positioning splits from the surface: Ariakit folds `side` + `align` into\n * one `placement` and takes the offset as `gutter`; our classes +\n * `data-vf-state` land on the role component (`Popover` / `Menu` / …).\n * 3. Ariakit surfaces portal by default — we point `portalElement` at the\n * `[data-vf-ui]` token scope (via `useTokenScope`) so every `var(--…)`\n * resolves. `asChild` maps to Ariakit's polymorphic `render={children}`.\n *\n * Two of the added slots need extra reconciliation notes:\n * - **select**: `useSelectStore` owns value + open faithfully, and we bridge\n * both into a `SelectState` context so the skin's Trigger/Value/Item read\n * `useSelect()`. Ariakit's `SelectPopover` normally anchors to Ariakit's own\n * `` button.\n const anchorRef = React.useRef(null);\n\n const state = React.useMemo(() => ({\n value: currentValue,\n setValue: (next: string) => store.setValue(next),\n open: isOpen,\n setOpen: (next: boolean) => store.setOpen(next),\n labels,\n anchorRef,\n }), [currentValue, isOpen, labels, store]);\n\n return (\n \n \n {children}\n \n \n );\n },\n Content: ({ className, children, ...rest }) => {\n const state = React.useContext(SelectStateContext) as\n | (SelectState & { anchorRef: React.RefObject })\n | null;\n return (\n \n {(container) => (\n ` disclosure. `sameWidth` matches the\n // builtin's `matchTriggerWidth`; verify prop names vs your version.\n getAnchorRect={() => state?.anchorRef.current?.getBoundingClientRect() ?? null}\n sameWidth\n gutter={4}\n role=\"listbox\"\n className={className}\n data-vf-state=\"open\"\n {...rest}\n >\n {children}\n \n )}\n \n );\n },\n // Bridge Ariakit's store value/open into the contract's SelectState; throws\n // outside \");\n return state;\n },\n};\n\n// ---------------------------------------------------------------------------\n// Combobox (registry + filter HAND-ROLLED; Ariakit store owns the surface)\n// ---------------------------------------------------------------------------\n// See the module docstring: Ariakit's combobox drives filtering + the active-\n// descendant over its OWN `ComboboxItem` registry, which does not invert onto\n// the contract's skin-provided `registerOption`/`matches`/`activeId` registry.\n// So we hand-roll that state machine (mirroring `builtin/combobox.tsx`) and use\n// the Ariakit combobox store ONLY for the real engine surface: the input anchor,\n// open state, and the portalled, positioned `ComboboxPopover`. `store.value`\n// (Ariakit's input text) IS the contract's `query`; the committed `value` is\n// separate (Ariakit conflates them), so we track that by hand.\ninterface ComboboxOption {\n id: string;\n value: string;\n text: string;\n}\n\nconst ComboboxStateContext = React.createContext<\n (ComboboxState & { store: Ariakit.ComboboxStore }) | null\n>(null);\n\nfunction useAriakitCombobox(): ComboboxState & { store: Ariakit.ComboboxStore } {\n const ctx = React.useContext(ComboboxStateContext);\n if (!ctx) throw new Error(\"Combobox parts must be used within \");\n return ctx;\n}\n\nconst AriakitComboboxRoot: ComboboxParts[\"Root\"] = ({\n children,\n value,\n defaultValue,\n onValueChange,\n open,\n defaultOpen,\n onOpenChange,\n defaultInputValue,\n onInputValueChange,\n}) => {\n const listboxId = React.useId();\n const optionsRef = React.useRef([]);\n const [activeId, setActiveId] = React.useState(undefined);\n\n // Ariakit combobox store: its `value` is the INPUT TEXT (our `query`); it also\n // owns open + provides the popover surface + input anchor. Its `setValue`\n // single-arg matches our `onInputValueChange` (text changed).\n const store = Ariakit.useComboboxStore({\n defaultValue: defaultInputValue,\n setValue: (next) => onInputValueChange?.(next as string),\n open,\n defaultOpen,\n setOpen: onOpenChange,\n });\n const query = store.useState(\"value\") as string;\n const isOpen = store.useState(\"open\");\n\n // Committed selection value is separate from the input text in our contract,\n // but Ariakit conflates them — so track the committed value by hand.\n const isValueControlled = value !== undefined;\n const [internalValue, setInternalValue] = React.useState(defaultValue);\n const currentValue = isValueControlled ? value : internalValue;\n\n const matches = React.useCallback(\n (text: string) => !query || text.toLowerCase().includes(query.toLowerCase()),\n [query],\n );\n\n const setOpen = React.useCallback((next: boolean) => {\n store.setOpen(next);\n if (!next) setActiveId(undefined);\n }, [store]);\n\n const setQuery = React.useCallback((next: string) => {\n store.setValue(next); // fires the store's setValue → onInputValueChange\n setActiveId(undefined);\n store.setOpen(true);\n }, [store]);\n\n const select = React.useCallback((nextValue: string, text: string) => {\n if (!isValueControlled) setInternalValue(nextValue);\n onValueChange?.(nextValue);\n store.setValue(text);\n setActiveId(undefined);\n store.setOpen(false);\n }, [isValueControlled, onValueChange, store]);\n\n const registerOption = React.useCallback((id: string, value: string, text: string) => {\n const existing = optionsRef.current.find((o) => o.id === id);\n if (existing) {\n existing.value = value;\n existing.text = text;\n } else {\n optionsRef.current.push({ id, value, text });\n }\n }, []);\n const unregisterOption = React.useCallback((id: string) => {\n optionsRef.current = optionsRef.current.filter((o) => o.id !== id);\n }, []);\n\n // Keyboard nav walks the *filtered* option set (active-descendant), exactly\n // like the builtin — Ariakit's own composite nav is inert here (no\n // `ComboboxItem`s registered), so `preventDefault` hands control to us.\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n const visible = optionsRef.current.filter((o) => matches(o.text));\n const currentIndex = visible.findIndex((o) => o.id === activeId);\n const move = (nextIndex: number) => {\n const clamped = Math.max(0, Math.min(visible.length - 1, nextIndex));\n setActiveId(visible[clamped]?.id);\n };\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n if (!isOpen) setOpen(true);\n else move(currentIndex + 1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n if (!isOpen) setOpen(true);\n else move(currentIndex <= 0 ? 0 : currentIndex - 1);\n break;\n case \"Home\":\n if (isOpen && visible.length) {\n event.preventDefault();\n move(0);\n }\n break;\n case \"End\":\n if (isOpen && visible.length) {\n event.preventDefault();\n move(visible.length - 1);\n }\n break;\n case \"Enter\": {\n const active = visible.find((o) => o.id === activeId);\n if (isOpen && active) {\n event.preventDefault();\n select(active.value, active.text);\n }\n break;\n }\n case \"Escape\":\n if (isOpen) {\n event.preventDefault();\n setOpen(false);\n }\n break;\n }\n },\n [matches, activeId, isOpen, setOpen, select],\n );\n\n const ctx = React.useMemo(() => ({\n query,\n setQuery,\n open: isOpen,\n setOpen,\n value: currentValue,\n select,\n activeId,\n matches,\n listboxId,\n registerOption,\n unregisterOption,\n onInputKeyDown,\n store,\n }), [\n query,\n setQuery,\n isOpen,\n setOpen,\n currentValue,\n select,\n activeId,\n matches,\n listboxId,\n registerOption,\n unregisterOption,\n onInputKeyDown,\n store,\n ]);\n\n return (\n \n {children}\n \n );\n};\n\nexport const ariakitCombobox: ComboboxParts = {\n Root: AriakitComboboxRoot,\n // Render Ariakit's `Combobox` for the input so the store gets its anchor\n // element (positions the popover) — but our hand-rolled state owns value,\n // active-descendant, and keydown. `role`/`aria-controls`/`aria-activedescendant`\n // are set explicitly; verify Ariakit doesn't re-own `aria-activedescendant`\n // in your version (it stays undefined here since no `ComboboxItem`s register).\n Input: ({ className, onChange, onKeyDown, ref, ...props }) => {\n const ctx = useAriakitCombobox();\n return (\n {\n onChange?.(event);\n // Ariakit's store already captured the text; re-run `setQuery` with it\n // to reset the active-descendant + keep the list open (idempotent on\n // the store's value, so no double text write of consequence).\n ctx.setQuery(event.target.value);\n }}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (!event.defaultPrevented) ctx.onInputKeyDown(event);\n }}\n {...props}\n />\n );\n },\n Content: ({ className, children, ...rest }) => {\n const ctx = useAriakitCombobox();\n return (\n \n {(container) => (\n \n {children}\n \n )}\n \n );\n },\n useCombobox: useAriakitCombobox as () => ComboboxState,\n};\n\n// ---------------------------------------------------------------------------\n// Disclosure (collapsible archetype — Ariakit's disclosure store owns open state)\n// ---------------------------------------------------------------------------\n// The Collapsible archetype is the overlay disclosure MINUS the portal: a trigger\n// toggles an INLINE region present only while open (no `ScopedPortal` here).\n// Ariakit's `useDisclosureStore` owns the open state; `Disclosure` is the toggle\n// button and `DisclosureContent` the region. We share the store (+ Root's\n// `disabled`) via context so Trigger/Content self-wire — mirroring how the menu\n// bridges its store — and so Content can mount only while open, matching the\n// contract (and `builtin/disclosure.tsx`).\nconst DisclosureStoreContext = React.createContext<\n { store: Ariakit.DisclosureStore; disabled?: boolean } | null\n>(null);\n\nconst AriakitDisclosureRoot: DisclosureParts[\"Root\"] = (\n { open, defaultOpen, onOpenChange, disabled, children, ref, ...rest },\n) => {\n // Ariakit's `setOpen` store option IS our single-arg `onOpenChange` (no\n // `eventDetails` to drop), plus `open` / `defaultOpen`.\n const store = Ariakit.useDisclosureStore({\n open,\n defaultOpen,\n setOpen: onOpenChange,\n });\n const isOpen = store.useState(\"open\");\n const ctx = React.useMemo(() => ({ store, disabled }), [store, disabled]);\n return (\n
\n \n {children}\n \n
\n );\n};\n\nexport const ariakitDisclosure: DisclosureParts = {\n Root: AriakitDisclosureRoot,\n // Ariakit's `Disclosure` is the toggle button — it reads the store from\n // `DisclosureProvider` and sets `aria-expanded` itself. Root's `disabled`\n // rides down through context. `asChild` maps to polymorphic `render={children}`.\n Trigger: ({ asChild, children, ...rest }) => {\n const ctx = React.useContext(DisclosureStoreContext);\n const disabled = rest.disabled ?? ctx?.disabled;\n return asChild\n ? (\n \n )\n : (\n \n {children}\n \n );\n },\n // Inline (NOT portalled) region. Ariakit's `DisclosureContent` stays\n // mounted+hidden by default, so gate on store open-state to mount only while\n // open — matching the contract and the builtin.\n Content: ({ className, children, ...rest }) => {\n const ctx = React.useContext(DisclosureStoreContext);\n const isOpen = ctx?.store.useState(\"open\") ?? false;\n if (!isOpen) return null;\n return (\n \n {children}\n \n );\n },\n};\n\n// ---------------------------------------------------------------------------\n// Toolbar (roving-tabindex composite — Ariakit's toolbar store owns the roving)\n// ---------------------------------------------------------------------------\n// Ariakit's `useToolbarStore` owns the roving-tabindex composite (one shared tab\n// stop, arrow-key nav, Home/End) and `Toolbar` renders the `role=\"toolbar\"`\n// wrapper; each `ToolbarItem` registers itself as a roving stop with the store\n// it reads from context. This maps cleanly onto the contract: Root builds the\n// store (mapping `orientation`) + renders `Toolbar`; Item is a `ToolbarItem`\n// whose polymorphic `render` handles `asChild`. Inline, NOT portalled — no\n// `ScopedPortal`. `className` + the rest of the skin's props ride through\n// `...rest`; separators stay pure skin (not routed through `Item`).\nexport const ariakitToolbar: ToolbarParts = {\n Root: ({ orientation = \"horizontal\", children, ref, ...rest }) => {\n // `orientation` is a toolbar store option; verify vs your @ariakit/react\n // version (in some releases it's instead a prop on ).\n const store = Ariakit.useToolbarStore({ orientation });\n return (\n \n {children}\n \n );\n },\n // `ToolbarItem` registers as a roving stop with the toolbar store from\n // context (the engine's roving governs it). `asChild` maps to Ariakit's\n // polymorphic `render={children}` (used by ToolbarLink → ); otherwise it\n // renders a button. No visual classes — those arrive via `...rest`.\n Item: ({ asChild, children, ref, ...rest }) =>\n asChild\n ? \n : {children},\n};\n\n// ---------------------------------------------------------------------------\n// Tabs (single-select tablist — Ariakit's tab store owns the selected value)\n// ---------------------------------------------------------------------------\n// Panel-less: the consumer renders content keyed by the active value, so we map\n// only the tablist + tabs (NO `TabPanel`). Ariakit's `useTabStore` owns the\n// selected tab id; `TabList` renders the `role=\"tablist\"` wrapper and each `Tab`\n// registers with the store it reads from context. We bridge the store's\n// `setSelectedId` onto our single-arg `onValueChange`, and expose the store via\n// context so each Tab can set `data-state=\"active\"|\"inactive\"` EXPLICITLY from\n// `store.useState(\"selectedId\") === value` — Ariakit's `Tab` emits `role=\"tab\"`\n// + `aria-selected` + `data-active` itself, but NOT the `data-state` our skin\n// styles off of (`data-[state=active]:…`), so we always set it here.\nconst TabStoreContext = React.createContext(null);\n\nconst AriakitTabsRoot: TabsParts[\"Root\"] = ({ value, onValueChange, children, ref, ...rest }) => {\n // Ariakit's `setSelectedId` receives the id (nullable when nothing is\n // selected); normalize to \"\" to match our non-null `onValueChange` contract.\n const store = Ariakit.useTabStore({\n selectedId: value,\n setSelectedId: (id) => onValueChange(id ?? \"\"),\n });\n return (\n \n \n {children}\n \n \n );\n};\n\nexport const ariakitTabs: TabsParts = {\n Root: AriakitTabsRoot,\n // `Tab id={value}` self-wires selection through the store from context (selects\n // on click, sets `role=\"tab\"` + `aria-selected`). We add `data-state` EXPLICITLY\n // from the store's selected id so the skin's `data-[state=active]:…` classes land.\n // `asChild` maps to Ariakit's polymorphic `render={children}`. No visual classes —\n // those arrive via `...rest`.\n Tab: ({ value, asChild, children, ref, ...rest }) => {\n const store = React.useContext(TabStoreContext);\n const selectedId = store?.useState(\"selectedId\");\n const isActive = selectedId === value;\n const dataState = isActive ? \"active\" : \"inactive\";\n return asChild\n ? (\n \n )\n : (\n \n {children}\n \n );\n },\n};\n\n/**\n * Partial adapter map — adopt Ariakit for popover + dialog + menu + tooltip +\n * select + combobox + disclosure + toolbar + tabs (9/11). `toast` and\n * `toggleGroup` are intentionally ABSENT: Ariakit ships no toast primitive and\n * no dedicated toggle-group primitive, so both fall back to the zero-dependency\n * builtin. Extend as you vendor more parts.\n */\nexport const ariakitAdapter: Partial & { name: string } = {\n name: \"ariakit\",\n popover: ariakitPopover,\n dialog: ariakitDialog,\n menu: ariakitMenu,\n tooltip: ariakitTooltip,\n select: ariakitSelect,\n combobox: ariakitCombobox,\n disclosure: ariakitDisclosure,\n toolbar: ariakitToolbar,\n tabs: ariakitTabs,\n // toast: intentionally omitted — Ariakit has no toast primitive; falls back\n // to builtin toast.\n // toggleGroup: intentionally omitted — Ariakit has no dedicated toggle-group\n // primitive; falls back to builtin toggleGroup.\n};\n" + } + }, + "ui-adapter:base-ui": { + "files": { + "base-ui.tsx": "/**\n * Base UI adapter for `veryfront/ui` — REFERENCE TEMPLATE.\n *\n * `npx veryfront generate adapter base-ui` copies this file into YOUR repo\n * (e.g. `./ui-adapters/base-ui.tsx`). You own it from then on. `@base-ui/react`\n * is YOUR dependency, bumped on YOUR schedule; `veryfront/ui` core depends on no\n * engine (enforced by a CI guard). Wire it up once:\n *\n * ```tsx\n * import { UIAdapterProvider } from \"veryfront/ui\";\n * import { baseUiAdapter } from \"./ui-adapters/base-ui.tsx\";\n *\n * {app};\n * ```\n *\n * The provider merges a PARTIAL map over the builtin, so you can adopt Base UI\n * for just some parts and leave the rest zero-dependency. This template maps ALL\n * 11/11 parts: `popover` / `dialog` / `menu` / `tooltip` / `select` / `combobox` /\n * `toast` / `disclosure` / `toggleGroup` / `toolbar` / `tabs`. Ten wrap real Base UI primitives; `combobox` is a contract-faithful,\n * React-only hand-roll — Base UI's `Autocomplete` is data-driven (owns its own\n * `items` + filtering) and does NOT invert onto our `register`/`matches`/\n * `activeId` option registry, so that one slot owns query + filter + active-\n * descendant itself (mirroring the builtin). Drop any key to fall back to builtin.\n *\n * Three normalizations the contract forces (the fault lines from RFC 0001 §13.2):\n * 1. Drop Base UI's 2nd `onOpenChange(open, eventDetails)` arg — our contract\n * is single-arg `(open) => void`.\n * 2. Positioning anatomy splits: `Positioner` takes `align`/`sideOffset`; our\n * classes + `data-vf-state` land on `Popup` (Radix's one `Content` ≈ Base\n * UI's `Positioner` + `Popup`).\n * 3. Portal `container` keeps the surface inside the `[data-vf-ui]` token scope\n * (via `useTokenScope`) — otherwise every `var(--…)` resolves to nothing.\n *\n * @module ui-adapters/base-ui\n */\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Popover as BasePopover } from \"@base-ui/react/popover\";\nimport { Dialog as BaseDialog } from \"@base-ui/react/dialog\";\nimport { Menu as BaseMenu } from \"@base-ui/react/menu\";\nimport { Tooltip as BaseTooltip } from \"@base-ui/react/tooltip\";\nimport { Select as BaseSelect } from \"@base-ui/react/select\";\nimport { Toast as BaseToast } from \"@base-ui/react/toast\";\nimport { Collapsible as BaseCollapsible } from \"@base-ui/react/collapsible\";\nimport { Toggle as BaseToggle, ToggleGroup as BaseToggleGroup } from \"@base-ui/react/toggle-group\";\nimport { Toolbar as BaseToolbar } from \"@base-ui/react/toolbar\";\nimport { Tabs as BaseTabs } from \"@base-ui/react/tabs\";\nimport { useTokenScope } from \"veryfront/ui\";\nimport type {\n ComboboxParts,\n ComboboxState,\n DialogParts,\n DisclosureParts,\n MenuParts,\n ModalState,\n PopoverParts,\n SelectParts,\n SelectState,\n TabsParts,\n ToastFn,\n ToastOptions,\n ToastParts,\n ToastState,\n ToggleGroupParts,\n ToolbarParts,\n TooltipParts,\n UIAdapter,\n} from \"veryfront/ui\";\n\n/** Render a portalled surface inside the veryfront token scope. */\nfunction ScopedPortal(\n { children }: { children: (container: HTMLElement) => React.ReactNode },\n): React.ReactElement {\n const { ref, getContainer } = useTokenScope();\n const [container, setContainer] = React.useState(null);\n React.useLayoutEffect(() => setContainer(getContainer()), [getContainer]);\n return (\n <>\n \n )\n : null}\n
\n {title\n ? (\n \n {title}\n \n )\n : null}\n {description\n ? (\n \n {description}\n \n )\n : null}\n {action || cancel\n ? (\n
\n {cancel\n ? (\n // `Toast.Close` dismisses; run the caller's onClick first.\n \n cancel.onClick?.()}\n className=\"rounded-md px-2 py-1 text-sm font-medium text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--edge-medium)]\"\n >\n {cancel.label}\n \n \n )\n : null}\n {action\n ? (\n // `Toast.Action` needs `altText` for the screen-reader summary.\n \n action.onClick()}\n className=\"rounded-md bg-[var(--primary)] px-2 py-1 text-sm font-medium text-[var(--secondary)] transition-colors hover:bg-[var(--secondary)] hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--edge-medium)]\"\n >\n {action.label}\n \n \n )\n : null}\n
\n )\n : null}\n
\n \n \n \n \n \n \n \n );\n}\n\n/**\n * Partial adapter map — adopt Radix for ten slots (four floating overlays +\n * select + toast + the inline disclosure + the toggle group + the toolbar + the\n * tabs). Combobox is deliberately ABSENT: Radix has no combobox primitive, so\n * that key falls through to the zero-dependency builtin via the partial-map\n * merge. Extend as you vendor more parts.\n */\nexport const radixAdapter: Partial & { name: string } = {\n name: \"radix\",\n popover: radixPopover,\n dialog: radixDialog,\n menu: radixMenu,\n tooltip: radixTooltip,\n select: radixSelect,\n toast: radixToast,\n disclosure: radixDisclosure,\n toggleGroup: radixToggleGroup,\n toolbar: radixToolbar,\n tabs: radixTabs,\n // combobox: intentionally omitted — Radix ships no combobox primitive; the\n // builtin combobox stays in force through the partial-map merge.\n};\n" + } + }, + "ui-adapter:react-aria": { + "files": { + "react-aria.tsx": "/**\n * React Aria adapter for `veryfront/ui` — REFERENCE TEMPLATE.\n *\n * `npx veryfront generate adapter react-aria` copies this file into YOUR repo\n * (e.g. `./ui-adapters/react-aria.tsx`). You own it from then on.\n * `react-aria-components` is YOUR dependency, bumped on YOUR schedule;\n * `veryfront/ui` core depends on no engine (enforced by a CI guard). Wire it up\n * once:\n *\n * ```tsx\n * import { UIAdapterProvider } from \"veryfront/ui\";\n * import { reactAriaAdapter } from \"./ui-adapters/react-aria.tsx\";\n *\n * {app};\n * ```\n *\n * The provider merges a PARTIAL map over the builtin, so you can adopt React\n * Aria for just some parts and leave the rest zero-dependency. This template is\n * **full coverage — 11/11**: `popover` / `dialog` / `menu` / `tooltip` /\n * `disclosure` / `toggleGroup` / `toolbar` / `select` / `combobox` / `tabs` /\n * `toast`, all mapped onto `react-aria-components`. No slot falls back to the\n * builtin. `tabs` maps onto RAC's `Tabs` / `TabList` / `Tab` panel-less (the skin\n * renders content by value), bridging `selectedKey` + setting `data-state`\n * explicitly.\n * `toggleGroup` maps onto RAC's `ToggleButtonGroup` / `ToggleButton` (bridging the\n * contract's `type`/string `value` onto RAC's `selectionMode`/`Set` selection).\n * `toolbar` maps onto RAC's inline `Toolbar`, which roves its own focusable\n * children (`role=\"toolbar\"`, arrow-key nav, one tab stop). `disclosure`\n * is\n * RAC's inline `Disclosure`/`DisclosurePanel` (the overlay disclosure minus the\n * portal). `combobox` (and, for the same reason, `select`) is a\n * *contract-faithful hand-rolled* mapping — see reconciliation (5) below and the\n * per-slot docstrings for why RAC's collection primitives can't drive it.\n *\n * ## Component layer, not hooks (contract reconciliation)\n * We build against **`react-aria-components`** (the high-level component layer:\n * `DialogTrigger`, `Popover`, `Menu`, `Tooltip`, …) — NOT the low-level\n * `react-aria` / `react-stately` hooks. The veryfront contract is **role-tagged\n * render slots** (`Root`/`Trigger`/`Content`) plus a normalized `{open,setOpen}`\n * disclosure, deliberately NOT the prop-getters that React Aria's hooks return.\n * The component layer already packages those hooks behind composition\n * primitives, so it maps cleanly onto our slots; the hook layer would force us to\n * reinvent that packaging. RAC's component model (its `*Trigger` components own\n * open/hover state and portal their surfaces) is therefore *bridged* onto the\n * render-slot contract below.\n *\n * Reconciliations the contract forces (cf. Base UI's three, RFC 0001 §13.2):\n * 1. `onOpenChange` needs NO normalization — RAC's is already single-arg\n * `(isOpen: boolean) => void`, exactly the contract shape (Base UI, by\n * contrast, has a 2nd `eventDetails` arg to drop).\n * 2. Positioning-vs-surface split: RAC's `Popover` / `Modal` / `Tooltip` are\n * the positioner + portal; our classes + `data-vf-state=\"open\"` land on the\n * surface element (`Popover` itself / `Dialog` / `Menu` / `Tooltip`).\n * `align`→`placement` (`\"bottom start\"` / `\"bottom end\"`), `side`→\n * `placement`, `sideOffset`→`offset`.\n * 3. Portal container: RAC portals overlays to `document.body` by default, so\n * every surface takes `UNSTABLE_portalContainer` (or wrap the tree in\n * `UNSTABLE_PortalProvider`) to stay inside the `[data-vf-ui]` token scope\n * via `useTokenScope` — otherwise every `var(--…)` resolves to nothing.\n * 4. `asChild` composes through RAC's `Pressable` (triggers) / `Focusable`\n * (tooltip target) rather than a Radix-style Slot merge: React Aria owns the\n * press/hover wiring on its own trigger, so the consumer's element is\n * rendered *inside* that wrapper. Minor semantic difference — the child\n * still receives the interaction, but through RAC's press abstraction.\n * 5. Collection-vs-registry (select + combobox): RAC's `Select` / `ListBox` /\n * `ComboBox` are COLLECTION components — they own their trigger (`Button` /\n * `Input`) and their items (`ListBoxItem`), filter internally, and surface\n * selection through `onSelectionChange` over that collection. The veryfront\n * contract inverts this: the skin renders its OWN `role=\"option\"` items that\n * register into an adapter-owned registry (`registerOption` / `matches` /\n * `activeId`) and drive state through `useSelect()` / `useCombobox()`. RAC's\n * collection model does NOT invert onto that, so — per the engine-adapter\n * spec's combobox note — both slots own their state here (mirroring the\n * builtin) and use the one RAC mechanic that *does* invert: the standalone\n * `Popover` (`triggerRef` + controlled `isOpen`) for positioning / portal /\n * dismiss. A bare `Popover` (no `Dialog` child) doesn't steal focus, so the\n * combobox's `aria-activedescendant` nav stays in the input. Still a valid,\n * swappable engine slot.\n * 6. Toast is imperative in RAC too — a `ToastQueue` you `.add()`/`.close()`\n * plus a `ToastRegion` — so it maps directly onto the `{ toast, dismiss }`\n * contract with no hand-rolled queue. RAC's toast API is newer/unstable\n * (`UNSTABLE_`-prefixed): verify the exports vs your installed version.\n *\n * @module ui-adapters/react-aria\n */\nimport * as React from \"react\";\n// `Pressable` / `Focusable` were `UNSTABLE_`-prefixed in older releases and the\n// `UNSTABLE_portalContainer` prop name is still evolving — verify vs your\n// installed react-aria-components version.\nimport {\n Button,\n Dialog,\n DialogTrigger,\n // `Disclosure` / `DisclosurePanel` are a newer RAC addition — verify these\n // names (and the `isExpanded`/`onExpandedChange` prop shape) vs your installed\n // react-aria-components version.\n Disclosure,\n DisclosurePanel,\n Focusable,\n Menu,\n MenuTrigger,\n Modal,\n Popover,\n Pressable,\n // `Tabs` / `TabList` / `Tab` are RAC's tablist primitives — verify these names\n // (and `selectedKey`/`onSelectionChange`) vs your react-aria-components version.\n Tab,\n TabList,\n Tabs,\n Text,\n // `ToggleButtonGroup` / `ToggleButton` are a newer RAC addition — verify these\n // names (and `selectionMode`/`selectedKeys`/`onSelectionChange`) vs your\n // installed react-aria-components version.\n ToggleButton,\n ToggleButtonGroup,\n // `Toolbar` owns roving-tabindex focus (`role=\"toolbar\"`, arrow-key nav, one\n // shared tab stop) over its focusable children — verify the name (and the\n // `orientation` prop) vs your react-aria-components version.\n Toolbar,\n Tooltip,\n TooltipTrigger,\n // RAC's toast primitives are newer/unstable and ship `UNSTABLE_`-prefixed —\n // verify these names (and the queue/region API shape) vs your installed\n // react-aria-components version.\n UNSTABLE_Toast as RACToast,\n UNSTABLE_ToastContent as ToastContent,\n UNSTABLE_ToastQueue as ToastQueue,\n UNSTABLE_ToastRegion as ToastRegion,\n} from \"react-aria-components\";\n// `Key` / `Selection` are RAC's collection key + selection types\n// (`Selection = Set | \"all\"`) — verify vs your react-aria-components version.\nimport type { Key, Selection } from \"react-aria-components\";\nimport { useTokenScope } from \"veryfront/ui\";\nimport type {\n ComboboxParts,\n ComboboxState,\n DialogParts,\n DisclosureParts,\n MenuParts,\n ModalState,\n PopoverParts,\n SelectParts,\n SelectState,\n TabsParts,\n ToastFn,\n ToastOptions,\n ToastParts,\n ToastState,\n ToggleGroupParts,\n ToolbarParts,\n TooltipParts,\n UIAdapter,\n} from \"veryfront/ui\";\n\n/** Render a portalled surface inside the veryfront token scope. */\nfunction ScopedPortal(\n { children }: { children: (container: HTMLElement) => React.ReactNode },\n): React.ReactElement {\n const { ref, getContainer } = useTokenScope();\n const [container, setContainer] = React.useState(null);\n React.useLayoutEffect(() => setContainer(getContainer()), [getContainer]);\n return (\n <>\n
` takes. Otherwise a RAC\n// `Button` is the roving stop.\nexport const reactAriaToolbar: ToolbarParts = {\n // verify the `orientation` prop vs your react-aria-components version.\n Root: ({ orientation = \"horizontal\", children, ...rest }) => (\n \n {children}\n \n ),\n Item: ({ asChild, children, ...rest }) =>\n asChild\n ? {React.cloneElement(children as React.ReactElement, rest)}\n : ,\n};\n\n// ---------------------------------------------------------------------------\n// Select (dual state — value + open; the skin drives selection via setValue)\n// ---------------------------------------------------------------------------\n// See reconciliation (5): RAC's `Select`/`ListBox` are collection components that\n// own their trigger (`Button`) + items (`ListBoxItem`) and surface selection via\n// `onSelectionChange`. The veryfront skin renders its OWN `role=\"combobox\"`\n// trigger and `role=\"option\"` items and drives state through `useSelect()`\n// (`setOpen`/`setValue`), so RAC's Select collection does NOT invert onto this\n// contract. We therefore own the dual value+open state here (mirroring the\n// builtin) and use the RAC mechanic that *does* invert — the standalone\n// `Popover` (`triggerRef` + `isOpen`/`onOpenChange`) — for positioning, portal,\n// and outside-click/Escape dismiss. Selection flows through the skin's `Item` →\n// `setValue` (the contract's selection path); there is no RAC collection for an\n// `onSelectionChange` to fire from.\ninterface SelectStateInternal extends SelectState {\n /** The Root's wrapper element — the Popover's positioning anchor. */\n anchorRef: React.RefObject;\n}\n\nconst SelectContext = React.createContext(null);\n\nfunction useSelectState(): SelectStateInternal {\n const ctx = React.useContext(SelectContext);\n if (!ctx) throw new Error(\"Select parts must be used within ` button, but our skin renders the Trigger itself, so we anchor the + * popover to `Root`'s wrapper span via `getAnchorRect` (mirrors the builtin's + * `anchorRef` span) instead of an Ariakit disclosure. + * - **combobox**: Ariakit's combobox is store-based and drives filtering + the + * active-descendant over its OWN `ComboboxItem` registry. The contract instead + * makes the ADAPTER own a skin-provided option registry (`registerOption` / + * substring `matches` / `activeId` / `onInputKeyDown`). That model does NOT + * cleanly invert onto Ariakit's item registry, so — per the engine-adapter + * note — the registry + substring filter + active-descendant keyboard machine + * is HAND-ROLLED here (React-only, mirroring `builtin/combobox.tsx`), while + * the Ariakit combobox store still provides the real engine surface: the + * `role="combobox"` input anchor, open state, and the portalled, positioned, + * dismiss-managed `ComboboxPopover`. Still a valid, swappable engine slot. + * + * @module ui-adapters/ariakit + */ +import * as React from "react"; +import * as Ariakit from "@ariakit/react"; +import { useTokenScope } from "veryfront/ui"; +import type { + ComboboxParts, + ComboboxState, + DialogParts, + DisclosureParts, + MenuParts, + ModalState, + PopoverParts, + SelectParts, + SelectState, + TabsParts, + ToolbarParts, + TooltipParts, + UIAdapter, +} from "veryfront/ui"; + +/** Resolve the veryfront token-scope element to portal a floating surface into. */ +function useScopedContainer(): { + ref: React.Ref; + container: HTMLElement | null; +} { + const { ref, getContainer } = useTokenScope(); + const [container, setContainer] = React.useState(null); + React.useLayoutEffect(() => setContainer(getContainer()), [getContainer]); + return { ref, container }; +} + +/** Render a portalled surface inside the veryfront token scope. */ +function ScopedPortal( + { children }: { children: (container: HTMLElement) => React.ReactNode }, +): React.ReactElement { + const { ref, container } = useScopedContainer(); + return ( + <> + `), so the engine roves the consumer's node. +export const baseUiToolbar: ToolbarParts = { + Root: ({ orientation = "horizontal", children, ...rest }) => ( + + {children} + + ), + Item: ({ asChild, children, ...rest }) => + asChild + ? + : {children}, +}; + +// --------------------------------------------------------------------------- +// Tabs (single-select tablist — PANEL-LESS: the consumer renders content by value) +// --------------------------------------------------------------------------- +// Base UI's Tabs owns the selected value (`Tabs.Root value onValueChange` + +// `Tabs.List` + `Tabs.Tab value`) and renders the `role="tablist"`/`role="tab"` +// nodes with `aria-selected` + selection-on-click. Our skin is PANEL-LESS (the +// consumer renders content keyed by the active value), so we map only Root+List + +// Tab — NO `Tabs.Panel`. Two normalizations the contract forces: +// (1) drop Base UI's 2nd `onValueChange(value, eventDetails)` arg → single-arg. +// (2) Base UI's Tab emits `data-selected`, but the skin styles off +// `data-state="active"|"inactive"`. So we mirror the selected value into a +// context and each Tab sets `data-state` explicitly from whether its own +// `value` matches. `role="tab"` + `aria-selected` stay native to Base UI's Tab. +// The Root's `ref`/`className` (+ any `...rest`) land on `Tabs.List` — the +// `role="tablist"` node — matching the builtin. +const TabsValueContext = React.createContext(null); + +export const baseUiTabs: TabsParts = { + Root: ({ value, onValueChange, children, ...rest }) => ( + + onValueChange(next)} + > + {children} + + + ), + Tab: ({ value, asChild, className, children, ...rest }) => { + const selected = React.useContext(TabsValueContext); + const isActive = selected === value; + // (2) mirror selection into `data-state`; the skin styles off it. + return asChild + ? ( + + ) + : ( + + {children} + + ); + }, +}; + +/** + * Partial adapter map — Base UI covers ALL 11/11 parts: popover + dialog + menu + + * tooltip + select + combobox + toast + disclosure + toggleGroup + toolbar + tabs. + * `combobox` is a contract-faithful, React-only hand-roll (Base UI's data-driven + * `Autocomplete` doesn't invert onto our `register`/`matches`/`activeId` registry + * — see the Combobox section); the rest wrap real Base UI primitives. Drop any + * key to fall back to the builtin. + */ +export const baseUiAdapter: Partial & { name: string } = { + name: "base-ui", + popover: baseUiPopover, + dialog: baseUiDialog, + menu: baseUiMenu, + tooltip: baseUiTooltip, + select: baseUiSelect, + combobox: baseUiCombobox, + toast: baseUiToast, + disclosure: baseUiDisclosure, + toggleGroup: baseUiToggleGroup, + toolbar: baseUiToolbar, + tabs: baseUiTabs, +}; diff --git a/cli/templates/ui-adapters/radix.tsx b/cli/templates/ui-adapters/radix.tsx new file mode 100644 index 0000000000..f6e9c58d07 --- /dev/null +++ b/cli/templates/ui-adapters/radix.tsx @@ -0,0 +1,717 @@ +/** + * Radix UI adapter for veryfront/ui — REFERENCE TEMPLATE. + * + * `npx veryfront generate adapter radix` copies this file into YOUR repo + * (e.g. `./ui-adapters/radix.tsx`). You own it from then on. The + * `@radix-ui/react-*` packages are YOUR dependencies, bumped on YOUR schedule; + * `veryfront/ui` core depends on no engine (enforced by a CI guard). Wire it up + * once: + * + * ```tsx + * import { UIAdapterProvider } from "veryfront/ui"; + * import { radixAdapter } from "./ui-adapters/radix.tsx"; + * + * {app}; + * ``` + * + * The provider merges a PARTIAL map over the builtin, so this adapter adopts + * Radix for ten slots — the four floating overlays (popover / dialog / menu / + * tooltip) plus select, toast, the inline disclosure, the toggle group, the + * toolbar, and the tabs. Coverage: 10/11 (popover / dialog / menu / tooltip / + * select / toast / disclosure / toggleGroup / toolbar / tabs; combobox stays + * builtin — Radix has NO combobox primitive, so there is nothing to map it + * onto). Extend the map as you vendor more parts. + * + * How Radix maps onto the contract (the fault lines from RFC 0001 §13.2): + * 1. `onOpenChange` is ALREADY single-arg `(open: boolean) => void` in Radix — + * no normalization needed (Base UI's 2nd `eventDetails` arg does not exist + * here). We still keep the contract's `DisclosureProps` shape. + * 2. No positioner/surface split: Radix's ONE `Content` element takes the + * positioning props (`align` / `side` / `sideOffset`) directly AND carries + * our `className` + `data-vf-state="open"`. + * 3. Portal `container` keeps the surface inside the `[data-vf-ui]` token scope + * (via `useTokenScope`) — otherwise every `var(--…)` resolves to nothing. + * 4. `asChild` is native Radix: pass `asChild` and Radix merges its behaviour + * onto the single child element. + * 5. Select: `SelectParts` exposes ONLY `Root` / `Content` / `useSelect` — no + * Trigger slot — because the SKIN renders the visible `role="combobox"` + * trigger and the `role="option"` items itself and drives them through + * `useSelect()`. So we use `Select.Root` as the CONTROLLED value+open state + * owner, bridge that state into a context (like Dialog/Menu do with + * `ModalState`), and let `Content` render Radix's `Portal` / `Content` / + * `Viewport` as the floating listbox. Selection flows skin → `setValue` + * (not through Radix's own `Select.Item`, which the skin supersedes). + * 6. Toast: Radix Toast is RENDER-BASED (no imperative `toast()` — you mount + * one `` per notification), but the contract's `useToast()` + * returns an imperative `{ toast, dismiss }`. So the `Provider` holds a tiny + * queue (like the builtin), mounts `` + ``, + * renders one `` per queued item, and exposes `{ toast, dismiss }` + * via context. Auto-dismiss rides Radix's per-root `duration` → + * `onOpenChange(false)` → `dismiss(id)`. The Viewport renders IN PLACE (Radix + * does not portal it to `document.body`), so it stays inside the token scope + * without a `ScopedPortal` — unlike the overlays above. + * + * Combobox is intentionally LEFT ON THE BUILTIN: Radix ships no combobox + * primitive to invert onto `ComboboxParts` (query + substring filter + option + * registry + `aria-activedescendant`), so there is nothing to adapt — the + * zero-dependency builtin combobox remains in force via the partial-map merge. + * + * @module ui-adapters/radix + */ +import * as React from "react"; +// verify these entry points vs your installed @radix-ui/react-* versions. +import * as Popover from "@radix-ui/react-popover"; +import * as Dialog from "@radix-ui/react-dialog"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import * as Tooltip from "@radix-ui/react-tooltip"; +import * as Select from "@radix-ui/react-select"; +import * as Toast from "@radix-ui/react-toast"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import * as ToggleGroup from "@radix-ui/react-toggle-group"; +import * as Toolbar from "@radix-ui/react-toolbar"; +import * as Tabs from "@radix-ui/react-tabs"; +import { cx, useTokenScope } from "veryfront/ui"; +import type { + DialogParts, + DisclosureParts, + MenuParts, + ModalState, + PopoverParts, + SelectParts, + SelectState, + TabsParts, + ToastFn, + ToastOptions, + ToastParts, + ToastState, + ToggleGroupParts, + ToolbarParts, + TooltipParts, + TooltipSide, + UIAdapter, +} from "veryfront/ui"; + +/** Render a portalled surface inside the veryfront token scope. */ +function ScopedPortal( + { children }: { children: (container: HTMLElement) => React.ReactNode }, +): React.ReactElement { + const { ref, getContainer } = useTokenScope(); + const [container, setContainer] = React.useState(null); + React.useLayoutEffect(() => setContainer(getContainer()), [getContainer]); + return ( + <> + ` child is merged and + // roved just like a button. The Item carries no visual classes of its own — + // `className` (and the rest of the button attrs) arrive via `...rest` from the + // skin. + Item: ({ asChild, children, ...rest }) => ( + + {children} + + ), +}; + +// --------------------------------------------------------------------------- +// Tabs (single-select tablist — panel-less: consumer renders content by value) +// --------------------------------------------------------------------------- +export const radixTabs: TabsParts = { + // Radix's `Tabs.Root` owns the selected value and wires its Triggers through + // its OWN internal context, and `onValueChange` is already the single-arg + // shape the contract wants — so Root just maps `value` / `onValueChange` onto + // it and wraps a `Tabs.List` (the `role="tablist"` node) that receives the + // wrapper `div` attrs + `className` via `...rest`. Panel-less: we render NO + // `Tabs.Content`; the consumer renders content keyed by the active value. + Root: ({ value, onValueChange, children, ...rest }) => ( + + {children} + + ), + // Radix's `Tabs.Trigger` NATIVELY emits `role="tab"` + `aria-selected` + + // `data-state="active"|"inactive"` and selects its value on click, so it maps + // directly — the skin's `data-[state=active]:…` classes read that state hook. + // (4) `asChild` is native Radix. The Tab carries no visual classes of its own + // — `className` (and the rest of the button attrs) arrive via `...rest`. + Tab: ({ value, asChild, children, ...rest }) => ( + + {children} + + ), +}; + +// --------------------------------------------------------------------------- +// Select (dual-state listbox: value + open + a skin-supplied labels map) +// --------------------------------------------------------------------------- +const SelectStateContext = React.createContext(null); + +export const radixSelect: SelectParts = { + // Radix `Select.Root` owns the controlled value + open state; we mirror it into + // a context so the skin's Trigger/Value/Item read `useSelect()`. The skin owns + // item rendering (plain `role="option"` divs) and collected the `labels` map, + // so this Root only needs the value/open machine + Radix as the state carrier. + Root: ( + { + children, + value, + defaultValue, + onValueChange, + open, + defaultOpen, + onOpenChange, + labels, + }, + ) => { + const [internalValue, setInternalValue] = React.useState(defaultValue); + const [internalOpen, setInternalOpen] = React.useState( + defaultOpen ?? false, + ); + const isValueControlled = value !== undefined; + const isOpenControlled = open !== undefined; + const currentValue = isValueControlled ? value : internalValue; + const isOpen = isOpenControlled ? open : internalOpen; + + const setValue = React.useCallback((next: string) => { + if (!isValueControlled) setInternalValue(next); + onValueChange?.(next); + }, [isValueControlled, onValueChange]); + + const setOpen = React.useCallback((next: boolean) => { + if (!isOpenControlled) setInternalOpen(next); + onOpenChange?.(next); + }, [isOpenControlled, onOpenChange]); + + const state = React.useMemo( + () => ({ value: currentValue, setValue, open: isOpen, setOpen, labels }), + [currentValue, setValue, isOpen, setOpen, labels], + ); + + return ( + + {/* Radix reflects OUR state; selection is driven by the skin via setValue. */} + + {children} + + + ); + }, + Content: ({ className, children, ...rest }) => ( + + {(container) => ( + // (3) portal into the token scope; one popper Content carries classes + state. + + + {children} + + + )} + + ), + useSelect: () => { + const ctx = React.useContext(SelectStateContext); + if (!ctx) throw new Error("Select parts must be used within "); + return ctx; +} + +export const reactAriaSelect: SelectParts = { + Root: ( + { children, value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, labels }, + ) => { + const [internalValue, setInternalValue] = React.useState(defaultValue); + const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false); + const anchorRef = React.useRef(null); + + const isValueControlled = value !== undefined; + const isOpenControlled = open !== undefined; + const currentValue = isValueControlled ? value : internalValue; + const isOpen = isOpenControlled ? open : internalOpen; + + const setValue = React.useCallback((next: string) => { + if (!isValueControlled) setInternalValue(next); + onValueChange?.(next); + }, [isValueControlled, onValueChange]); + + const setOpen = React.useCallback((next: boolean) => { + if (!isOpenControlled) setInternalOpen(next); + onOpenChange?.(next); + }, [isOpenControlled, onOpenChange]); + + const ctx = React.useMemo(() => ({ + value: currentValue, + setValue, + open: isOpen, + setOpen, + labels, + anchorRef, + }), [currentValue, setValue, isOpen, setOpen, labels]); + + return ( + + {/* The anchor the standalone Popover positions against + matches width to. */} + {children} + + ); + }, + Content: ({ className, children, ref, ...rest }) => { + const ctx = useSelectState(); + return ( + + {(container) => ( + // Standalone Popover (triggerRef + controlled isOpen) supplies the real + // RAC positioning/portal/dismiss; the skin classes + normalized state + + // listbox role land on the surface div. + +
+ {children} +
+
+ )} +
+ ); + }, + // Exposed to skin parts; returns the richer internal state (skin ignores anchorRef). + useSelect: useSelectState as () => SelectState, +}; + +// --------------------------------------------------------------------------- +// Combobox (contract-faithful hand-rolled state machine + RAC Popover surface) +// --------------------------------------------------------------------------- +// See reconciliation (5): RAC's `ComboBox` OWNS its collection + filtering (it +// renders `ListBoxItem`s and filters them internally) and does NOT invert onto +// this contract's skin-driven registry, where the skin's `Item`s call +// `registerOption`/`matches`/`activeId` and the ADAPTER owns `query`, the +// substring filter, and the `aria-activedescendant` walk over the *filtered* +// set. Per the engine-adapter spec's combobox note, forcing RAC's ComboBox onto +// that model would be a broken mapping, so this slot uses a contract-faithful +// HAND-ROLLED state machine (mirrors the builtin combobox) — still a valid, +// swappable engine slot. The one part that DOES invert — the floating surface — +// uses RAC's real standalone `Popover` (a bare Popover with no `Dialog` child +// does not steal focus, so `aria-activedescendant` nav stays in the input). +interface ComboboxStateInternal extends ComboboxState { + anchorRef: React.RefObject; +} + +const ComboboxContext = React.createContext(null); + +function useComboboxState(): ComboboxStateInternal { + const ctx = React.useContext(ComboboxContext); + if (!ctx) throw new Error("Combobox parts must be used within "); + return ctx; +} + +interface ComboboxOption { + id: string; + value: string; + text: string; +} + +export const reactAriaCombobox: ComboboxParts = { + Root: ( + { + children, + value, + defaultValue, + onValueChange, + open, + defaultOpen, + onOpenChange, + defaultInputValue, + onInputValueChange, + }, + ) => { + const listboxId = React.useId(); + const anchorRef = React.useRef(null); + const optionsRef = React.useRef([]); + + const [query, setQueryState] = React.useState(defaultInputValue ?? ""); + const [internalValue, setInternalValue] = React.useState(defaultValue); + const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false); + const [activeId, setActiveId] = React.useState(undefined); + + const isValueControlled = value !== undefined; + const isOpenControlled = open !== undefined; + const currentValue = isValueControlled ? value : internalValue; + const isOpen = isOpenControlled ? open : internalOpen; + + const matches = React.useCallback( + (text: string) => !query || text.toLowerCase().includes(query.toLowerCase()), + [query], + ); + + const setOpen = React.useCallback((next: boolean) => { + if (!isOpenControlled) setInternalOpen(next); + onOpenChange?.(next); + if (!next) setActiveId(undefined); + }, [isOpenControlled, onOpenChange]); + + const setQuery = React.useCallback((next: string) => { + setQueryState(next); + onInputValueChange?.(next); + setActiveId(undefined); + if (!isOpenControlled) setInternalOpen(true); + onOpenChange?.(true); + }, [isOpenControlled, onOpenChange, onInputValueChange]); + + const select = React.useCallback((nextValue: string, text: string) => { + if (!isValueControlled) setInternalValue(nextValue); + onValueChange?.(nextValue); + setQueryState(text); + onInputValueChange?.(text); + setActiveId(undefined); + if (!isOpenControlled) setInternalOpen(false); + onOpenChange?.(false); + }, [isValueControlled, onValueChange, isOpenControlled, onOpenChange, onInputValueChange]); + + const registerOption = React.useCallback((id: string, optValue: string, text: string) => { + const existing = optionsRef.current.find((o) => o.id === id); + if (existing) { + existing.value = optValue; + existing.text = text; + } else { + optionsRef.current.push({ id, value: optValue, text }); + } + }, []); + const unregisterOption = React.useCallback((id: string) => { + optionsRef.current = optionsRef.current.filter((o) => o.id !== id); + }, []); + + const onInputKeyDown = React.useCallback((event: React.KeyboardEvent) => { + const visible = optionsRef.current.filter((o) => matches(o.text)); + const currentIndex = visible.findIndex((o) => o.id === activeId); + const move = (nextIndex: number) => { + const clamped = Math.max(0, Math.min(visible.length - 1, nextIndex)); + setActiveId(visible[clamped]?.id); + }; + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + if (!isOpen) setOpen(true); + else move(currentIndex + 1); + break; + case "ArrowUp": + event.preventDefault(); + if (!isOpen) setOpen(true); + else move(currentIndex <= 0 ? 0 : currentIndex - 1); + break; + case "Home": + if (isOpen && visible.length) { + event.preventDefault(); + move(0); + } + break; + case "End": + if (isOpen && visible.length) { + event.preventDefault(); + move(visible.length - 1); + } + break; + case "Enter": { + const active = visible.find((o) => o.id === activeId); + if (isOpen && active) { + event.preventDefault(); + select(active.value, active.text); + } + break; + } + case "Escape": + if (isOpen) { + event.preventDefault(); + setOpen(false); + } + break; + } + }, [matches, activeId, isOpen, setOpen, select]); + + const ctx = React.useMemo(() => ({ + query, + setQuery, + open: isOpen, + setOpen, + value: currentValue, + select, + activeId, + matches, + listboxId, + registerOption, + unregisterOption, + onInputKeyDown, + anchorRef, + }), [ + query, + setQuery, + isOpen, + setOpen, + currentValue, + select, + activeId, + matches, + listboxId, + registerOption, + unregisterOption, + onInputKeyDown, + ]); + + return {children}; + }, + Input: ({ className, onChange, onKeyDown, onFocus, ref, ...props }) => { + const ctx = useComboboxState(); + const setRef = React.useCallback((node: HTMLInputElement | null) => { + ctx.anchorRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref != null) (ref as React.MutableRefObject).current = node; + }, [ctx.anchorRef, ref]); + + return ( + { + onChange?.(event); + ctx.setQuery(event.target.value); + }} + onKeyDown={(event) => { + onKeyDown?.(event); + if (!event.defaultPrevented) ctx.onInputKeyDown(event); + }} + onFocus={(event) => { + onFocus?.(event); + if (!ctx.open) ctx.setOpen(true); + }} + {...props} + /> + ); + }, + Content: ({ className, children, ref, ...rest }) => { + const ctx = useComboboxState(); + return ( + + {(container) => ( + // Bare standalone Popover: real RAC positioning/portal/dismiss without a + // Dialog child, so focus is NOT pulled out of the input — the + // aria-activedescendant pattern keeps working. + +
+ {children} +
+
+ )} +
+ ); + }, + useCombobox: useComboboxState as () => ComboboxState, +}; + +// --------------------------------------------------------------------------- +// Tabs (single-select tablist — RAC Tabs/TabList/Tab, PANEL-LESS) +// --------------------------------------------------------------------------- +// RAC's `Tabs` owns the selected key + roving focus; `TabList` renders the +// `role="tablist"` and each `Tab` a `role="tab"`. Our skin is PANEL-LESS (the +// consumer renders content by value), so we map Root→`Tabs`+`TabList` and +// Tab→`Tab` and DROP RAC's `TabPanel`s entirely. Two mappings the contract +// forces: +// 1. `value`→`selectedKey`; `onValueChange`←`onSelectionChange` (RAC emits a +// `Key`, so `String()`-narrow it back to the contract's string — already +// single-arg, cf. reconciliation (1)). An inline primitive, so there is NO +// ScopedPortal here. +// 2. RAC's `Tab` emits `data-selected` (its own selected-state attribute), NOT +// the `data-state="active"|"inactive"` the skin styles off. So we publish +// the selected `value` through a context the `Tab` reads to set `data-state` +// explicitly. RAC's Tab already emits `role="tab"` + `aria-selected` and +// selects on press; we add the `data-state` styling hook. +const TabsSelectionContext = React.createContext(undefined); + +export const reactAriaTabs: TabsParts = { + Root: ({ value, onValueChange, children, ref, ...rest }) => ( + + {/* verify `selectedKey`/`onSelectionChange` vs your react-aria-components version. */} + + onValueChange(String(key))} + > + + {children} + + + + ), + // `id={value}` keys the tab as the `selectedKey` target. `asChild` has no RAC + // analogue here — the `Tab` must itself be the keyed list member — so it is + // dropped (not spread onto the DOM). The skin's `className` + rest props flow + // straight onto the RAC `Tab`. + Tab: ({ value, asChild: _asChild, children, ref, ...rest }) => { + const selected = React.useContext(TabsSelectionContext); + const isActive = selected === value; + return ( + // RAC emits `role="tab"` + `aria-selected` + selects on press; we add + // `data-state` (the skin's styling hook) from our authoritative selection. + + {children} + + ); + }, +}; + +// --------------------------------------------------------------------------- +// Toast (imperative — RAC's ToastQueue + ToastRegion behind {toast, dismiss}) +// --------------------------------------------------------------------------- +// See reconciliation (6): RAC's toast IS imperative — a `ToastQueue` you +// `.add()`/`.close()` plus a `ToastRegion` that renders the live region + the +// visible toasts. We hold one queue in the Provider, mount its ToastRegion, and +// expose `{ toast, dismiss }` via context so `useToast()` returns the imperative +// API: `toast(options)` / `toast.custom(render)` map onto `queue.add`, +// `dismiss(id)` onto `queue.close`. No hand-rolled queue needed (unlike a +// render-only engine). The RAC toast API is `UNSTABLE_`-prefixed — verify names. +type ReactAriaToastContent = + | ({ kind: "structured" } & ToastOptions) + | { kind: "custom"; render: (id: string) => React.ReactNode }; + +const ToastContext = React.createContext(null); + +function ReactAriaToastProvider( + { children, duration = 5000 }: { children: React.ReactNode; duration?: number }, +): React.ReactElement { + // One stable queue for the Provider's lifetime. + const [queue] = React.useState( + () => new ToastQueue({ maxVisibleToasts: 5 }), + ); + + const api = React.useMemo(() => { + const enqueue = (content: ReactAriaToastContent, ms: number | undefined) => { + const timeout = ms ?? duration; + // RAC auto-dismisses on a finite timeout (and pauses on hover/focus); + // Infinity → omit `timeout` to persist. verify vs your RAC version (RAC + // warns on timeouts under 5000ms). + return queue.add(content, timeout === Infinity ? undefined : { timeout }); + }; + const toast = ((options: ToastOptions) => + enqueue({ kind: "structured", ...options }, options.duration)) as ToastFn; + toast.custom = (render: (id: string) => React.ReactNode) => + enqueue({ kind: "custom", render }, undefined); + return { + toast, + dismiss: (id: string) => + queue.close(id), + }; + }, [queue, duration]); + + return ( + + {children} + { + /* ToastRegion stays in the app subtree (inside the token scope) so + `var(--…)` resolves — it is fixed-positioned, not an anchored overlay, + so it needs no ScopedPortal. */ + } + + {({ toast }) => { + const content = toast.content; + if (content.kind === "custom") { + return ( + + {content.render(toast.key)} + + ); + } + const { icon, title, description, action, cancel, variant } = content; + return ( + + {icon + ? ( + + ) + : null} + + {title + ? ( + + {title} + + ) + : null} + {description + ? ( + + {description} + + ) + : null} + {action || cancel + ? ( +
+ {cancel + ? ( + // `slot="close"` lets RAC dismiss the toast; `onPress` + // adds the consumer's side effect. + + ) + : null} + {action + ? ( + + ) + : null} +
+ ) + : null} +
+ +
+ ); + }} +
+
+ ); +} +ReactAriaToastProvider.displayName = "ReactAriaToastProvider"; + +export const reactAriaToast: ToastParts = { + Provider: ReactAriaToastProvider, + // Throws outside a , matching the contract. + useToast: () => { + const ctx = React.useContext(ToastContext); + if (!ctx) throw new Error("useToast must be used within a "); + return ctx; + }, +}; + +/** + * FULL adapter map — React Aria for all 11 primitives: popover + dialog + menu + + * tooltip + disclosure + toggleGroup + toolbar + select + combobox + tabs + + * toast. No slot falls back to the builtin. `disclosure` maps onto RAC's inline + * `Disclosure`/`DisclosurePanel` (no portal); `toggleGroup` bridges the contract's + * `type`/string `value` onto RAC's `ToggleButtonGroup` `selectionMode`/`Set` + * selection; `toolbar` maps onto RAC's inline `Toolbar` (it roves its own + * focusable children); `select` + `combobox` bridge their state locally (RAC's + * collection primitives don't invert onto the skin-driven registry — + * reconciliation (5)); `tabs` maps onto RAC's `Tabs`/`TabList`/`Tab` panel-less, + * bridging `selectedKey` + setting `data-state` explicitly (RAC's Tab emits + * `data-selected`, not the skin's `data-state`); `toast` maps straight onto RAC's + * imperative `ToastQueue` (reconciliation (6)). + */ +export const reactAriaAdapter: Partial & { name: string } = { + name: "react-aria", + popover: reactAriaPopover, + dialog: reactAriaDialog, + menu: reactAriaMenu, + tooltip: reactAriaTooltip, + disclosure: reactAriaDisclosure, + toggleGroup: reactAriaToggleGroup, + toolbar: reactAriaToolbar, + select: reactAriaSelect, + combobox: reactAriaCombobox, + tabs: reactAriaTabs, + toast: reactAriaToast, +}; diff --git a/cli/templates/ui-adapters/vaul.tsx b/cli/templates/ui-adapters/vaul.tsx new file mode 100644 index 0000000000..3698f87f52 --- /dev/null +++ b/cli/templates/ui-adapters/vaul.tsx @@ -0,0 +1,85 @@ +/** + * Vaul adapter for `veryfront/ui` — REFERENCE TEMPLATE (drawer SPECIALIST). + * + * Vaul (https://vaul.emilkowal.ski) is the best-in-class drag-to-dismiss drawer. + * Unlike the full engines (Base UI / Radix / …), it is a SPECIALIST: it powers + * exactly one slot — `drawer` — with real drag physics, snap points, and velocity + * dismissal that a generic dialog can't express. Everything else stays on whatever + * adapter is already active (builtin by default), because a `PartialUIAdapter` + * merges over the parent. + * + * `npx veryfront generate adapter vaul` copies this file into YOUR repo + * (`./ui-adapters/vaul.tsx`); `vaul` is YOUR dependency. Wire it up once: + * + * ```tsx + * import { UIAdapterProvider } from "veryfront/ui"; + * import { vaulAdapter } from "./ui-adapters/vaul.tsx"; + * + * // Drawers now drag-to-dismiss; the call-site + skin are unchanged. + * {app}; + * ``` + * + * Stack it under a full engine to get Vaul drawers + (say) Base UI overlays: + * `…`. + * + * @module ui-adapters/vaul + */ +import * as React from "react"; +import { Drawer as Vaul } from "vaul"; +import { useTokenScope } from "veryfront/ui"; +import type { DrawerParts, UIAdapter } from "veryfront/ui"; + +/** Resolve the veryfront token-scope element so the portalled sheet keeps `var(--…)`. */ +function useScopedContainer(): { ref: React.Ref; container: HTMLElement | null } { + const { ref, getContainer } = useTokenScope(); + const [container, setContainer] = React.useState(null); + React.useLayoutEffect(() => setContainer(getContainer()), [getContainer]); + return { ref, container }; +} + +export const vaulDrawer: DrawerParts = { + Root: ({ open, defaultOpen, onOpenChange, direction = "bottom", children }) => ( + + {children} + + ), + Trigger: ({ asChild, children, ...rest }) => + asChild + ? {children} + : {children}, + Content: ({ className, children, lead, ...rest }) => { + const { ref, container } = useScopedContainer(); + return ( + <> +