diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45caafd..a67d4f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,8 @@ on: jobs: checks: runs-on: ubuntu-latest + env: + ASTRO_TELEMETRY_DISABLED: "1" steps: - uses: actions/checkout@v7 # The pnpm version comes from package.json#packageManager, the same one @@ -18,10 +20,10 @@ jobs: - run: pnpm install --frozen-lockfile # The same sequence `pnpm verify` runs locally, one step each so a failure # names its stage: format → lint + typecheck → behaviour tests → build → - # catalog → pack + consumer smoke. + # docs site → pack + consumer smoke. - run: pnpm format:check - run: pnpm lint - run: pnpm test - run: pnpm build - - run: pnpm catalog:check + - run: pnpm docs:check - run: pnpm test:package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c2f14..615b03e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: node-version-file: .nvmrc - run: pnpm install --frozen-lockfile # Nothing publishes that has only been compiled: format, lint, typecheck, - # behaviour tests, build, catalog, and the packed-consumer smoke test all + # behaviour tests, build, the docs site, and the packed-consumer smoke test all # have to pass first. - run: pnpm verify - name: tag matches package.json diff --git a/.gitignore b/.gitignore index d5d6c17..3ff0859 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ examples/catalog/dist/ +examples/catalog/.astro/ +examples/catalog/src/generated/ *.tmp.md # The Claude Design sync: its inputs, its staged converter and the bundle it diff --git a/.prettierignore b/.prettierignore index 9761c97..fdc3dd1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,7 @@ # Build output and generated files. dist/ +examples/catalog/.astro/ +examples/catalog/src/generated/ pnpm-lock.yaml # Markdown is formatted by hand here, and Prettier's markdown printer fights @@ -17,6 +19,9 @@ pnpm-lock.yaml # 100-column guide in `.editorconfig`. *.md +# MDX pages are prose too, hand-formatted for the same two reasons. +*.mdx + # Process records, kept as written at the time. docs/superpowers/ .superpowers/ diff --git a/README.md b/README.md index 3833eef..10ff72e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ src/theme/ styles.css, the single visual contract; shadcn.css, shadcn's l src/index.ts the public surface, exported by name components.json how the shadcn CLI installs a new component here tests/ behaviour tests, the token and shadcn-layer contracts, the packed-consumer test -examples/catalog manual inspection of every component and state, light and dark +examples/catalog the documentation site (Astro): a page, live demos and API tables per component docs/ DESIGN.md, CONTRIBUTING.md, components/, MIGRATION-*.md ``` @@ -71,7 +71,7 @@ docs/ DESIGN.md, CONTRIBUTING.md, components/, MIGRATION-*.md ## Verification -`pnpm verify` runs format, lint and typecheck, the behaviour tests, the build, the catalog, and a +`pnpm verify` runs format, lint and typecheck, the behaviour tests, the build, the docs site, and a packed-consumer test that installs the tarball into a throwaway project and compiles it with a real Tailwind. The release workflow runs the same before it publishes. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index b04727b..8fbe9e8 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -8,8 +8,8 @@ Node 24 (`.nvmrc`) and the pnpm named in `package.json#packageManager`. pnpm install ``` -The repository is a pnpm workspace: the package at the root, and `examples/catalog` as a second -member that depends on it as `workspace:*`. +The repository is a pnpm workspace: the package at the root, and the documentation site (Astro) at +`examples/catalog` as a second member that depends on it as `workspace:*`. ## Scripts @@ -20,9 +20,9 @@ member that depends on it as `workspace:*`. | `pnpm lint` | ESLint (including the colour-literal rule) and `tsc --noEmit` over `src` and `tests`. | | `pnpm format` / `pnpm format:check` | Prettier. Markdown is hand-formatted at 100 columns and excluded. | | `pnpm build` | Cleans `dist/`, compiles `src/` with declarations, and rewrites the `@/` alias to relative paths (`tsc-alias`). | -| `pnpm catalog` | Builds the package and serves the catalog with Vite. | -| `pnpm catalog:check` | Typechecks and builds the catalog, as CI does. | -| `pnpm verify` | Everything above in CI order: format, lint, test, build, catalog, package test. | +| `pnpm docs:dev` | Stops any running dev server, drops Vite's dependency cache, builds the package and serves the docs site on . Named with the colon because plain `docs` is a pnpm built-in. | +| `pnpm docs:check` | Generates the API tables, runs `astro check` and builds the docs site, as CI does. | +| `pnpm verify` | Everything above in CI order: format, lint, test, build, docs site, package test. | ## Adding or changing a component @@ -37,7 +37,7 @@ imports, which the build resolves. Read the diff, then adapt: the checklist is i [DESIGN.md](DESIGN.md), *Adding a component*. In short: colour only through the roles, geometry in the component, `type="button"`, no exit animation on a surface whose trigger can be pressed again on the next frame, export by name from `src/index.ts`, a behaviour test in `tests/components/`, a -specimen in the catalog, a row in `docs/components/README.md`. Never reinstall an existing +page with demos in the docs site, a row in `docs/components/README.md`. Never reinstall an existing component. The CLI pulls in an item's registry dependencies, which for most of the newer components include @@ -57,6 +57,17 @@ happens to emit (`data-[state=open]:`, `data-[orientation=vertical]:`). A `data- the values the layer declares no variant for, and for attributes that are not states; see DESIGN.md, *State attributes*. +## The docs site + +`examples/catalog` is an Astro app that imports the package through the workspace link, so +`pnpm build` runs first. A component page is `src/content/components/.mdx`; its demos are +`src/demos//.tsx`, one story per file, and the file is the code sample the page shows. +The API tables are generated into `src/generated/api/` (gitignored) by `scripts/extract-api.ts` +from the component sources: `astro dev` and `pnpm docs:check` run it, and +`tests/docs/api-extractor.test.ts` holds its shape and fails once a component has no page. +`.astro` files are formatted by Prettier but not linted; the colour-literal rule covers the `.tsx` +demos and islands. + ## Updating the shadcn layer `src/theme/shadcn.css` is a byte-for-byte copy of `shadcn/dist/tailwind.css`, and @@ -76,7 +87,7 @@ that file; a utility we need lives in `styles.css` after the import. A token is a role. Declare it in `:root` and `.dark` in `src/theme/styles.css`, expose it in `@theme inline` as `--color-: var(--)`, add it to `ROLE_NAMES` in `tests/theme/tokens.test.ts` and to both maps in `src/theme/tokens.ts`, and give it a swatch in -`examples/catalog/src/sections/Foundations.tsx`. If it is not a role — if the name describes a +`examples/catalog/src/components/Swatches.astro`. If it is not a role — if the name describes a pigment rather than a purpose — it does not belong here. ## Tests diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2424d60..8cb82c4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -32,7 +32,7 @@ src/theme/shadcn.css shadcn's utility and variant layer, vendored byte for byt src/theme/tokens.ts a runtime mirror of the token values, for callers that cannot read CSS components.json the shadcn CLI's configuration: style, stylesheet, aliases tests/ behaviour tests (jsdom), token and shadcn-layer contracts, consumer test -examples/catalog/ manual inspection, importing the real package +examples/catalog/ the documentation site (Astro), importing the real package ``` Internal imports are written against the `@/` alias `components.json` declares (`@/components/…`, @@ -287,8 +287,10 @@ Then the component is adapted, whichever road it came by: to bottom; `export *` is not used. 8. **Test the behaviour a screen would silently lose**, in `tests/components/`: roles, focus, `aria-*`, the form or pointer outcome. Not the class string. -9. **Show it in `examples/catalog/`**, in every state it has, and give it a row in - `docs/components/README.md`. +9. **Give it a page in the docs site**: `examples/catalog/src/content/components/.mdx` with + a demo per state under `examples/catalog/src/demos//`, and a row in + `docs/components/README.md`. The API table is generated from the source; the extractor test + fails until the page exists. If a caller wants something the component does not offer, the answer is a prop or a variant *in the component*, never a class string spread onto it from the call site. @@ -313,6 +315,6 @@ justification written into this file. | The packed tarball installs, its stylesheet compiles under a real Tailwind with the components' utilities, shadcn's layer and no physical palette, its entry imports and renders (Sidebar included, through the rewritten alias), `dist/` carries no `@/` import, `shadcn` is not a runtime dependency, a Button-only bundle stays small | `tests/package/consumer.test.ts` | | The harness itself | `tests/harness.test.tsx` | -`pnpm verify` runs format, lint, typecheck, the behaviour tests, the build, the catalog, and the +`pnpm verify` runs format, lint, typecheck, the behaviour tests, the build, the docs site, and the packed-consumer test, in that order; the release workflow refuses to publish anything that has not passed it. diff --git a/docs/components/README.md b/docs/components/README.md index f3636d7..efab8c7 100644 --- a/docs/components/README.md +++ b/docs/components/README.md @@ -5,8 +5,8 @@ The package is built on top of shadcn/ui: a component enters from the shadcn reg `components.json` (`pnpm dlx shadcn@latest add `), is adapted to the rules in [DESIGN.md](../DESIGN.md), and is owned here from then on. The file is the reference for anatomy, variants and class strings; this table says what each one -is for and where its behaviour comes from. Every one of them is shown in every state it has in -`examples/catalog/` (`pnpm catalog`). +is for and where its behaviour comes from. Every one of them has a page, with live demos and a +generated API table, in the docs site under `examples/catalog/` (`pnpm docs:dev`). | Component | Anatomy exported | Behaviour | Notes | | --- | --- | --- | --- | diff --git a/docs/superpowers/plans/2026-09-20-docs-site-astro.md b/docs/superpowers/plans/2026-09-20-docs-site-astro.md new file mode 100644 index 0000000..530cce2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-20-docs-site-astro.md @@ -0,0 +1,339 @@ +# ui-core: turn `examples/catalog` into the Astro documentation site + +## Context + +`examples/catalog` is today a Vite 8 + React app for manual inspection: one long page with 24 +hand-written `` blocks (`src/sections/Components.tsx`, 1085 lines), token swatches +(`Foundations.tsx`) and a `States.tsx` section. It imports the real package through the workspace +link and the exports map, and it is wired into `pnpm verify`, CI, the release gate, ESLint and six +doc files. It is not a reference: no per-component page, no usage code, no API tables, no search. + +The user wants it replaced, in the same folder, by a real documentation site for the design +system, built on `astro@latest`, laid out like the reference screenshot (shadcn/radix-style docs): + +- Top nav: wordmark, Home / Docs / Components, "Search documentation…" box, GitHub link, theme + toggle. +- Left sidebar: **Sections** (Introduction, Installation, Theming, States, Components) and + **Components** (all 40, alphabetical), current page highlighted. +- Main column per component: H1 + lede; a "built on Radix / Base UI / cmdk / vaul" callout bar + with Docs + API Reference links; a hero preview card with the live demo and a collapsible + "View Code" panel; **Usage** (import + JSX); **Composition** (ASCII anatomy tree); one section + per variant/state (preview + code); **API Reference** (one Prop | Type | Default table per + exported part); Previous / Next footer. +- Right column: "On This Page" TOC. + +Decisions already taken by the user (do not re-open): + +1. Custom Astro, not Starlight. `@astrojs/react` islands + Tailwind v4. The site dogfoods + ui-core: Sidebar, Sheet, Command, Kbd, Button, Badge, Table, Tooltip… are the docs UI. +2. Scope: everything this session. Shell, section pages, all 40 component pages, catalog code + removed, verify/CI/docs updated. +3. API tables are generated at build time from `src/components/*.tsx` with the TypeScript + compiler API. +4. Folder stays `examples/catalog`. No deploy workflow yet; CI only builds the site. + +## Verified versions (registry, 2026-09-20) + +| Package | Version | Note | +| --- | --- | --- | +| `astro` | 7.3.3 | Node ≥ 22.12 (`.nvmrc` = 24 ✓), Vite 8, Shiki 4, Rust compiler (strict HTML) | +| `@astrojs/react` | 6.0.6 | peers react/react-dom ^19 | +| `@astrojs/mdx` | 8.0.1 | md/mdx rendered by Astro 7's built-in pipeline; Shiki fences via `markdown.shikiConfig` | +| `@astrojs/check` | 0.9.10 | peer `typescript ^5 || ^6` ✓ (repo has 6.0.3) | +| `@tailwindcss/vite` | 4.3.3 | same as the catalog today; official Tailwind v4 path for Astro | +| `prettier-plugin-astro` | 1.0.1 | peer prettier ^3.5.3 ✓ | + +Astro facts that shape the design: + +- Content collections: `src/content.config.ts`, `defineCollection({ loader: glob(...), schema })`, + `z` from `astro/zod`, `render(entry)` → `{ Content, headings }` (headings feed the TOC). +- **Dynamic tags cannot carry `client:*` directives.** Each MDX page must statically import the + demo components it hydrates. Raw source, by contrast, can be resolved dynamically with + `import.meta.glob(..., { query: "?raw", import: "default" })`. +- `` from `astro:components` accepts `themes={{ light, dark }}` + `defaultColor={false}`; + dark needs the documented `.dark .astro-code { color: var(--shiki-dark) }` CSS. +- A React component rendered in `.astro` **without** a `client:*` directive is static HTML with + zero client JS. Badge, Table, Button-as-link, Separator, Breadcrumb are used that way. + +Repo facts verified: + +- One React instance today: root devDep `react ^19.2.8` and catalog dep `react ^19.2.8` resolve + to the same store copy. Keep the ranges identical and add `vite.resolve.dedupe`. +- No component touches `window`/`document` at module scope; `tests/package/consumer.test.ts` + already `renderToStaticMarkup`s `dist/index.js` under Node. SSR of demos at build is safe. +- `.design-sync/build-css.mjs:49-58` resolves `@tailwindcss/node|oxide` through + `examples/catalog/package.json` → `@tailwindcss/vite`. Folder and that devDependency survive. +- `@robomous/ui-core/styles.css` carries `@source "../components"` relative to its real path, so + the package's own class strings compile in the docs build (same mechanism the catalog uses). + +## Target file tree (`examples/catalog`) + +``` +examples/catalog/ + package.json @robomous/ui-core-docs (private) + astro.config.ts react(), mdx(), vite.plugins [tailwindcss()], dedupe react, shiki dual theme + tsconfig.json extends astro/tsconfigs/strict; jsx react-jsx; paths @/* -> ./src/* + scripts/extract-api.ts TS compiler API extractor (Node 24 runs erasable-syntax .ts natively) + public/favicon.svg + src/ + content.config.ts collections: sections, components + content/ + sections/ introduction.mdx installation.mdx theming.mdx states.mdx + components/ alert.mdx … tooltip.mdx (40; id = slug = src/components/.tsx; sonner.mdx titled "Sonner") + demos//.tsx one story per file: `export default function Story()` + one-line JSDoc + generated/api/.json GITIGNORED, produced by scripts/extract-api.ts + layouts/ Base.astro (html, theme bootstrap, global.css, Toaster) Docs.astro (header + sidebar + main + toc + prev/next) + components/ + Header.astro Sidebar.tsx MobileNav.tsx SearchCommand.tsx ThemeToggle.tsx Toc.astro + BuiltOn.astro Preview.astro CodePanel.astro CopyButton.tsx ApiReference.astro PrevNext.astro + Swatches.astro (theming page; ports Foundations.tsx SURFACES/STATUS/STRUCTURE/RADII/TYPE/Focus) + lib/ nav.ts (ordered sections + alphabetical components, prev/next) demo-source.ts (?raw glob + stripDoc) + api.ts (eager glob of generated JSON; clear error if empty) site.ts (repo URL, package name/version) + pages/ + index.astro landing: hero, install snippet, three cards + docs/[...slug].astro section pages + components/index.astro grid of the 40 components + components/[slug].astro component page template + search.json.ts static endpoint for the palette + styles/global.css @import "@robomous/ui-core/styles.css"; @source "../"; shiki dark rules; prose +``` + +Deleted: `index.html`, `vite.config.ts`, `src/App.tsx`, `src/main.tsx`, `src/catalog.css`, +`src/sections/*`. + +`package.json` (docs): + +- dependencies: `@robomous/ui-core workspace:*`, `react ^19.2.8`, `react-dom ^19.2.8`, + `lucide-react ^1.37.0` +- devDependencies: `astro ^7.3.3`, `@astrojs/react ^6.0.6`, `@astrojs/mdx ^8.0.1`, + `@astrojs/check ^0.9.10`, `@tailwindcss/vite ^4.3.3`, `tailwindcss ^4.3.3`, + `typescript ^6.0.3`, `@types/react ^19.2.18`, `@types/react-dom ^19.2.4` +- scripts: `dev: astro dev`, `build: astro build`, `check: astro sync && astro check`, + `preview: astro preview`, `api: node scripts/extract-api.ts`, `predev: node scripts/extract-api.ts` +- dropped: `vite`, `@vitejs/plugin-react` (Astro brings both) + +## Content model + +`src/content.config.ts`: + +```ts +sections: glob("*.mdx", "./src/content/sections"); schema { title, description, order: number } +components: glob("*.mdx", "./src/content/components"); schema { + title: string, // "Sheet" + description: string, // lede, from docs/components/README.md Notes/Behaviour + builtOn: [{ name, docsUrl, apiUrl? }] // "none" behaviour -> [{ name: "native HTML", docsUrl: MDN }] + related: string[] = [] +} +``` + +Component page skeleton (every page follows it; H2s are the TOC): + +```mdx +--- +title: Sheet +description: A Radix Dialog anchored to an edge of the viewport. +builtOn: [{ name: Radix Dialog, docsUrl: https://www.radix-ui.com/primitives/docs/components/dialog, apiUrl: https://www.radix-ui.com/primitives/docs/components/dialog#api-reference }] +--- +import Default from "@/demos/sheet/default"; +import FromTheLeft from "@/demos/sheet/from-the-left"; + + + +## Usage +```tsx +import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle } from "@robomous/ui-core"; +``` +```tsx +… +``` + +## Composition +```text +Sheet +├─ SheetTrigger (asChild → Button) +└─ SheetContent side="right" showCloseButton + ├─ SheetHeader › SheetTitle, SheetDescription + └─ SheetFooter +``` + +## From the left + +``` + +The template `components/[slug].astro` wraps `` with H1 + +lede, ``, then appends `## API Reference` (``) and ``. +`Toc.astro` merges `headings` (depth 2–3) with the fixed "API Reference" entry. If Astro's MDX +does not resolve `Preview` from the `components` map, each page adds one import line +`import Preview from "@/components/Preview.astro"`; decide in Phase A step 5 with the exemplar. + +## Demo registry + +- `src/demos//.tsx`: `export default function ()`, one-line JSDoc, imports + only from `@robomous/ui-core`, `react`, `lucide-react`. Stateful demos keep `useState` inside. +- Sources: the 24 specimens in `examples/catalog/src/sections/Components.tsx` and the 138 + stories in `.design-sync/previews/*.tsx` (already this shape, e.g. + `.design-sync/previews/Button.tsx` → Variants, Sizes, IconAndInline, WithLabelAndIcon, + Disabled). Descriptions/builtOn: the table in `docs/components/README.md:13-52`. +- `Preview.astro` props `demo="sheet/from-the-left"` (path under `src/demos`, no extension). + Renders the card (`rounded-xl bg-card ring-1 ring-foreground/10 p-6 min-h-40`), the `` + island, and ``. `lib/demo-source.ts` globs + `/src/demos/**/*.tsx` as `?raw`, strips the leading JSDoc, throws at build if missing. +- Hydration: `client:load` by default. `client:only="react"` (with a `Skeleton` fallback) for + demos whose SSR output is empty or misleading: Toaster, Drawer (vaul measures the viewport), + and any demo forcing a portal `open`. +- Scales to parallel authoring: a page touches only `content/components/.mdx` and + `demos//*`. Nav, search index, prev/next and API tables are derived; no shared file is + edited per page. + +## Layout pieces + +| Piece | Kind | Notes | +| --- | --- | --- | +| `Base.astro`, `Docs.astro`, `Header.astro` | Astro | header links styled with `buttonVariants({variant:"ghost",size:"sm"})` at build time | +| theme bootstrap | ` - - diff --git a/examples/catalog/package.json b/examples/catalog/package.json index be0130a..a83dd5c 100644 --- a/examples/catalog/package.json +++ b/examples/catalog/package.json @@ -1,26 +1,32 @@ { - "name": "@robomous/ui-core-catalog", + "name": "@robomous/ui-core-docs", "private": true, - "description": "Manual inspection of @robomous/ui-core: foundations, components and states, in light and dark. Imports the real package; owns examples only.", + "description": "The documentation site for @robomous/ui-core: one page per component with live demos, usage, composition and generated API tables. Imports the real package; owns docs and demos only.", "type": "module", "scripts": { - "dev": "vite", - "build": "vite build", - "typecheck": "tsc --noEmit -p tsconfig.json" + "dev": "astro dev", + "predev": "node scripts/extract-api.ts", + "build": "astro build", + "check": "astro sync && astro check", + "preview": "astro preview", + "fresh": "astro dev stop && node -e \"fs.rmSync('node_modules/.vite',{recursive:true,force:true})\"", + "api": "node scripts/extract-api.ts" }, "dependencies": { "@robomous/ui-core": "workspace:*", + "lucide-react": "^1.37.0", "react": "^19.2.8", "react-dom": "^19.2.8" }, "devDependencies": { + "@astrojs/check": "^0.9.10", + "@astrojs/mdx": "^8.0.1", + "@astrojs/react": "^6.0.6", "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.1.0", - "lucide-react": "^1.37.0", + "astro": "^7.3.3", "tailwindcss": "^4.3.3", - "typescript": "^6.0.3", - "vite": "^8.0.0" + "typescript": "^6.0.3" } } diff --git a/examples/catalog/public/favicon.svg b/examples/catalog/public/favicon.svg new file mode 100644 index 0000000..18e12b5 --- /dev/null +++ b/examples/catalog/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/examples/catalog/public/placeholder-frame.svg b/examples/catalog/public/placeholder-frame.svg new file mode 100644 index 0000000..2eba3ce --- /dev/null +++ b/examples/catalog/public/placeholder-frame.svg @@ -0,0 +1 @@ + diff --git a/examples/catalog/scripts/extract-api.ts b/examples/catalog/scripts/extract-api.ts new file mode 100644 index 0000000..47b70c4 --- /dev/null +++ b/examples/catalog/scripts/extract-api.ts @@ -0,0 +1,323 @@ +/** + * The API tables of the docs, read from the components' source. + * + * For every `src/components/.tsx`, each exported PascalCase function is a + * part with a table of the props it declares itself: the keys of a `cva` + * variants object (with `defaultVariants` or the destructuring default), the + * members of an inline `{ … }` type, and any inherited prop the component + * gives a default to. Everything else it accepts is named in `extends`. + * + * Output: `src/generated/api/.json`, gitignored, written before `astro dev` + * and by `docs:check`. `tests/docs/api-extractor.test.ts` holds the shape. + * + * Runs under Node's type stripping (`node scripts/extract-api.ts`), so only + * erasable syntax is used here: no enums, no parameter properties. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import ts from "typescript"; + +export interface ApiProp { + name: string; + type: string; + default: string; + required: boolean; + description: string; +} + +export interface ApiPart { + name: string; + extends: string[]; + props: ApiProp[]; +} + +export interface ComponentApi { + slug: string; + source: string; + parts: ApiPart[]; + helpers: string[]; +} + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, "..", "..", ".."); +const OUT = path.join(HERE, "..", "src", "generated", "api"); + +/** Every component file's API, in file-name order. */ +export function extractAll(repo: string = REPO): ComponentApi[] { + const configPath = path.join(repo, "tsconfig.json"); + const parsed = ts.getParsedCommandLineOfConfigFile( + configPath, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); + }, + }, + ); + if (!parsed) throw new Error(`Could not read ${configPath}`); + const dir = path.join(repo, "src", "components"); + const files = fs + .readdirSync(dir) + .filter((file) => file.endsWith(".tsx")) + .sort() + .map((file) => path.join(dir, file)); + const program = ts.createProgram(files, { ...parsed.options, noEmit: true }); + const checker = program.getTypeChecker(); + return files.map((file) => { + const sourceFile = program.getSourceFile(file); + if (!sourceFile) throw new Error(`Program lost ${file}`); + return extractFile(sourceFile, checker); + }); +} + +/** Writes one JSON per component plus an index, replacing what was there. */ +export function writeAll(apis: ComponentApi[], out: string = OUT): void { + fs.rmSync(out, { recursive: true, force: true }); + fs.mkdirSync(out, { recursive: true }); + for (const api of apis) { + fs.writeFileSync(path.join(out, `${api.slug}.json`), JSON.stringify(api, null, 2) + "\n"); + } + const index = { + generatedAt: new Date().toISOString(), + components: apis.map((api) => api.slug), + exportsCovered: apis.flatMap((api) => [...api.parts.map((part) => part.name), ...api.helpers]), + }; + fs.writeFileSync(path.join(out, "index.json"), JSON.stringify(index, null, 2) + "\n"); +} + +function extractFile(sourceFile: ts.SourceFile, checker: ts.TypeChecker): ComponentApi { + const slug = path.basename(sourceFile.fileName, ".tsx"); + const parts: ApiPart[] = []; + const helpers: string[] = []; + for (const name of exportedNames(sourceFile)) { + const fn = functionDeclaration(sourceFile, name); + if (fn && /^[A-Z]/.test(name)) parts.push(extractPart(name, fn, sourceFile, checker)); + else helpers.push(name); + } + return { slug, source: `src/components/${slug}.tsx`, parts, helpers }; +} + +/** Names in `export { … }` lists and on `export function` / `export const`, in order. */ +function exportedNames(sourceFile: ts.SourceFile): string[] { + const names: string[] = []; + for (const statement of sourceFile.statements) { + if (ts.isExportDeclaration(statement) && statement.exportClause) { + if (ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) names.push(element.name.text); + } + } else if (hasExportModifier(statement)) { + if (ts.isFunctionDeclaration(statement) && statement.name) names.push(statement.name.text); + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) names.push(declaration.name.text); + } + } + } + } + return names; +} + +function hasExportModifier(node: ts.Node): boolean { + return ( + ts.canHaveModifiers(node) && + (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ); +} + +type FunctionLike = ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression; + +/** The function a name is declared as, whether `function X` or `const X = (…) =>`. */ +function functionDeclaration(sourceFile: ts.SourceFile, name: string): FunctionLike | undefined { + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) return statement; + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue; + const init = declaration.initializer; + if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) return init; + } + } + } + return undefined; +} + +function extractPart( + name: string, + fn: FunctionLike, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, +): ApiPart { + const param = fn.parameters[0]; + if (!param) return { name, extends: [], props: [] }; + + const defaults = bindingDefaults(param, sourceFile); + const props: ApiProp[] = []; + const extended: string[] = []; + const seen = new Set(["children"]); + const add = (prop: ApiProp) => { + if (seen.has(prop.name)) return; + seen.add(prop.name); + props.push(prop); + }; + + for (const member of flattenType(param.type)) { + if (ts.isTypeReferenceNode(member) && member.typeName.getText(sourceFile) === "VariantProps") { + for (const prop of variantProps(member, defaults, sourceFile, checker)) add(prop); + } else if (ts.isTypeLiteralNode(member)) { + for (const signature of member.members) { + if (!ts.isPropertySignature(signature) || !signature.type) continue; + const propName = signature.name.getText(sourceFile); + add({ + name: propName, + type: text(signature.type, sourceFile), + default: defaults.get(propName) ?? "", + required: signature.questionToken === undefined && !defaults.has(propName), + description: docComment(signature), + }); + } + } else { + extended.push(text(member, sourceFile)); + } + } + + // A default on a prop the component inherits documents it as well as a + // declaration would: Tabs' `orientation`, Separator's `decorative`. + const paramType = checker.getTypeAtLocation(param); + for (const [propName, value] of defaults) { + if (seen.has(propName) || propName === "className") continue; + const symbol = paramType.getProperty(propName); + if (!symbol) continue; + const propType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(symbol, param)); + add({ + name: propName, + type: checker.typeToString(propType, param, ts.TypeFormatFlags.NoTruncation), + default: value, + required: false, + description: docComment(symbol.valueDeclaration), + }); + } + + if (!seen.has("className") && paramType.getProperty("className")) { + add({ name: "className", type: "string", default: "", required: false, description: "" }); + } + return { name, extends: extended, props }; +} + +/** `{ side = "right", open: openProp, …rest }` → side → `"right"`. */ +function bindingDefaults(param: ts.ParameterDeclaration, sourceFile: ts.SourceFile) { + const defaults = new Map(); + if (!ts.isObjectBindingPattern(param.name)) return defaults; + for (const element of param.name.elements) { + if (element.dotDotDotToken || !element.initializer) continue; + const key = element.propertyName ?? element.name; + defaults.set(key.getText(sourceFile), text(element.initializer, sourceFile)); + } + return defaults; +} + +function flattenType(type: ts.TypeNode | undefined): ts.TypeNode[] { + if (!type) return []; + if (ts.isParenthesizedTypeNode(type)) return flattenType(type.type); + if (ts.isIntersectionTypeNode(type)) return type.types.flatMap((t) => flattenType(t)); + return [type]; +} + +/** The variant keys of the `cva(…)` call a `VariantProps` points at. */ +function variantProps( + reference: ts.TypeReferenceNode, + defaults: Map, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, +): ApiProp[] { + const argument = reference.typeArguments?.[0]; + if (!argument || !ts.isTypeQueryNode(argument)) return []; + let symbol = checker.getSymbolAtLocation(argument.exprName); + if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol); + const declaration = symbol?.valueDeclaration; + if (!declaration || !ts.isVariableDeclaration(declaration)) return []; + const call = declaration.initializer; + if (!call || !ts.isCallExpression(call)) return []; + const config = call.arguments[1]; + if (!config || !ts.isObjectLiteralExpression(config)) return []; + const declaredIn = declaration.getSourceFile(); + + const variants = objectProperty(config, "variants"); + const defaultVariants = objectProperty(config, "defaultVariants"); + if (!variants) return []; + const props: ApiProp[] = []; + for (const property of variants.properties) { + if (!ts.isPropertyAssignment(property) || !ts.isObjectLiteralExpression(property.initializer)) { + continue; + } + const key = propertyKey(property, declaredIn); + const values = property.initializer.properties + .filter(ts.isPropertyAssignment) + .map((value) => JSON.stringify(propertyKey(value, declaredIn))); + const fromCva = defaultVariants?.properties.find( + (p): p is ts.PropertyAssignment => + ts.isPropertyAssignment(p) && propertyKey(p, declaredIn) === key, + ); + props.push({ + name: key, + type: values.join(" | "), + default: fromCva ? text(fromCva.initializer, declaredIn) : (defaults.get(key) ?? ""), + required: false, + description: "", + }); + } + // The same props a call site reads from `VariantProps`, wherever the cva lives. + void sourceFile; + return props; +} + +function objectProperty(object: ts.ObjectLiteralExpression, name: string) { + const property = object.properties.find( + (p) => ts.isPropertyAssignment(p) && propertyKey(p, object.getSourceFile()) === name, + ); + if ( + property && + ts.isPropertyAssignment(property) && + ts.isObjectLiteralExpression(property.initializer) + ) { + return property.initializer; + } + return undefined; +} + +/** `default:` and `"icon-xs":` both name a key; the quotes are not part of it. */ +function propertyKey(property: ts.PropertyAssignment, sourceFile: ts.SourceFile): string { + const name = property.name; + if (ts.isStringLiteral(name) || ts.isIdentifier(name) || ts.isNumericLiteral(name)) + return name.text; + return name.getText(sourceFile); +} + +function docComment(node: ts.Node | undefined): string { + if (!node) return ""; + return ts + .getJSDocCommentsAndTags(node) + .filter(ts.isJSDoc) + .map((doc) => ts.getTextOfJSDocComment(doc.comment) ?? "") + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +function text(node: ts.Node, sourceFile: ts.SourceFile): string { + return node.getText(sourceFile).replace(/\s+/g, " ").trim(); +} + +const invokedDirectly = + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) { + const apis = extractAll(); + writeAll(apis); + const parts = apis.reduce((sum, api) => sum + api.parts.length, 0); + console.log( + `API extracted: ${apis.length} components, ${parts} parts → ${path.relative(process.cwd(), OUT)}`, + ); +} diff --git a/examples/catalog/src/App.tsx b/examples/catalog/src/App.tsx deleted file mode 100644 index f3dc646..0000000 --- a/examples/catalog/src/App.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect, useState } from "react"; -import { Button, Toaster } from "@robomous/ui-core"; - -import { Components } from "./sections/Components"; -import { Foundations } from "./sections/Foundations"; -import { States } from "./sections/States"; - -const SECTIONS = [ - { id: "foundations", title: "Foundations" }, - { id: "components", title: "Components" }, - { id: "states", title: "States" }, -]; - -export function App() { - const [dark, setDark] = useState(() => document.documentElement.classList.contains("dark")); - useEffect(() => { - document.documentElement.classList.toggle("dark", dark); - }, [dark]); - - return ( -
-
- @robomous/ui-core - - -
- -
- - - -
- -
- ); -} - -/** A titled region of the catalog, with an anchor for the header nav. */ -export function Section({ - id, - title, - lede, - children, -}: { - id: string; - title: string; - lede: string; - children: React.ReactNode; -}) { - return ( -
-
-

{title}

-

{lede}

-
- {children} -
- ); -} - -/** A labelled specimen row inside a section. */ -export function Specimen({ - title, - note, - children, -}: { - title: string; - note?: string; - children: React.ReactNode; -}) { - return ( -
-
-

{title}

- {note ?

{note}

: null} -
-
- {children} -
-
- ); -} diff --git a/examples/catalog/src/catalog.css b/examples/catalog/src/catalog.css deleted file mode 100644 index ad53db0..0000000 --- a/examples/catalog/src/catalog.css +++ /dev/null @@ -1,3 +0,0 @@ -/* The design system, then this app's own sources — exactly what a product does. */ -@import "@robomous/ui-core/styles.css"; -@source "./"; diff --git a/examples/catalog/src/components/ApiReference.tsx b/examples/catalog/src/components/ApiReference.tsx new file mode 100644 index 0000000..a10cd13 --- /dev/null +++ b/examples/catalog/src/components/ApiReference.tsx @@ -0,0 +1,76 @@ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@robomous/ui-core"; + +import type { ComponentApi } from "@/lib/api"; + +/** One table per exported part: the props it declares, then what else it accepts. */ +export default function ApiReference({ api }: { api: ComponentApi }) { + return ( +
+ {api.parts.map((part) => ( +
+

+ {part.name} +

+ {part.extends.length > 0 ? ( +

+ Accepts every prop of{" "} + {part.extends.map((name, index) => ( + + {index > 0 ? " and " : ""} + + {name} + + + ))} + {part.props.length > 0 ? ", plus:" : "."} +

+ ) : null} + {part.props.length > 0 ? ( + + + + Prop + Type + Default + + + + {part.props.map((prop) => ( + + + {prop.name} + {prop.required ? null : ?} + + + {prop.type} + {prop.description ? ( +

+ {prop.description} +

+ ) : null} +
+ + {prop.default || "—"} + +
+ ))} +
+
+ ) : null} +
+ ))} + {api.helpers.length > 0 ? ( +

+ Also exported:{" "} + {api.helpers.map((helper, index) => ( + + {index > 0 ? ", " : ""} + {helper} + + ))} + . +

+ ) : null} +
+ ); +} diff --git a/examples/catalog/src/components/BuiltOn.tsx b/examples/catalog/src/components/BuiltOn.tsx new file mode 100644 index 0000000..22d74ca --- /dev/null +++ b/examples/catalog/src/components/BuiltOn.tsx @@ -0,0 +1,36 @@ +import { Badge, Button } from "@robomous/ui-core"; +import { ExternalLinkIcon } from "lucide-react"; + +export interface BuiltOnEntry { + name: string; + docsUrl: string; + apiUrl?: string; +} + +/** Where the component's behaviour comes from, with the upstream docs one click away. */ +export default function BuiltOn({ builtOn }: { builtOn: BuiltOnEntry[] }) { + const names = builtOn.map((entry) => entry.name); + const sentence = + names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`; + const primary = builtOn[0]; + return ( +
+ {builtOn.map((entry) => ( + + {entry.name} + + ))} + This component uses {sentence}. + {primary ? ( + + ) : null} +
+ ); +} diff --git a/examples/catalog/src/components/CodePanel.astro b/examples/catalog/src/components/CodePanel.astro new file mode 100644 index 0000000..07a35dd --- /dev/null +++ b/examples/catalog/src/components/CodePanel.astro @@ -0,0 +1,50 @@ +--- +import { buttonVariants, cn } from "@robomous/ui-core"; +import { Code } from "astro:components"; + +import CopyButton from "@/components/CopyButton"; + +interface Props { + code: string; + lang?: "tsx" | "ts" | "bash" | "css" | "json" | "text"; + open?: boolean; + label?: string; +} + +const { code, lang = "tsx", open = false, label = "View Code" } = Astro.props; +--- + +
+ + {label} + + +
+
+ +
+ +
+
diff --git a/examples/catalog/src/components/CopyButton.tsx b/examples/catalog/src/components/CopyButton.tsx new file mode 100644 index 0000000..5c47a1f --- /dev/null +++ b/examples/catalog/src/components/CopyButton.tsx @@ -0,0 +1,26 @@ +import { Button } from "@robomous/ui-core"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +/** Copies a code sample; the icon confirms for a moment, then resets. */ +export default function CopyButton({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + + return ( + + ); +} diff --git a/examples/catalog/src/components/DocsSidebar.tsx b/examples/catalog/src/components/DocsSidebar.tsx new file mode 100644 index 0000000..234de8f --- /dev/null +++ b/examples/catalog/src/components/DocsSidebar.tsx @@ -0,0 +1,51 @@ +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from "@robomous/ui-core"; + +import type { Nav, NavLink } from "@/lib/nav"; + +/** The docs' left navigation: the package's own Sidebar, pinned open. */ +export default function DocsSidebar({ nav, current }: { nav: Nav; current: string }) { + return ( + + + + + + + + + ); +} + +function Group({ label, links, current }: { label: string; links: NavLink[]; current: string }) { + return ( + + {label} + + + {links.map((link) => { + const active = link.href === current; + return ( + + + + {link.title} + + + + ); + })} + + + + ); +} diff --git a/examples/catalog/src/components/Header.astro b/examples/catalog/src/components/Header.astro new file mode 100644 index 0000000..ab2389c --- /dev/null +++ b/examples/catalog/src/components/Header.astro @@ -0,0 +1,64 @@ +--- +import { buttonVariants, cn } from "@robomous/ui-core"; + +import MobileNav from "@/components/MobileNav"; +import SearchCommand from "@/components/SearchCommand"; +import ThemeToggle from "@/components/ThemeToggle"; +import type { Nav } from "@/lib/nav"; +import { SITE } from "@/lib/site"; + +interface Props { + nav: Nav; + current: string; +} + +const { nav, current } = Astro.props; + +const links = [ + { title: "Home", href: "/", match: (path: string) => path === "/" }, + { title: "Docs", href: "/docs/introduction", match: (path: string) => path.startsWith("/docs") }, + { + title: "Components", + href: "/components", + match: (path: string) => path.startsWith("/components"), + }, +]; +--- + +
+
+ + + {SITE.short} + + +
+ + + + + +
+
+
diff --git a/examples/catalog/src/components/Lede.astro b/examples/catalog/src/components/Lede.astro new file mode 100644 index 0000000..a6016d7 --- /dev/null +++ b/examples/catalog/src/components/Lede.astro @@ -0,0 +1,22 @@ +--- +/** + * A page's description as prose. Frontmatter is plain text, so `code` spans + * are written with backticks; this renders them as and leaves the rest. + */ +interface Props { + text: string; +} + +const { text } = Astro.props; +const parts = text.split("`"); +--- + +

+ {parts.map((part, index) => + index % 2 === 1 ? ( + {part} + ) : ( + part + ), + )} +

diff --git a/examples/catalog/src/components/MobileNav.tsx b/examples/catalog/src/components/MobileNav.tsx new file mode 100644 index 0000000..d90ae1a --- /dev/null +++ b/examples/catalog/src/components/MobileNav.tsx @@ -0,0 +1,33 @@ +import { + Button, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@robomous/ui-core"; +import { MenuIcon } from "lucide-react"; + +import DocsSidebar from "@/components/DocsSidebar"; +import type { Nav } from "@/lib/nav"; + +/** Under the md breakpoint the sidebar lives in a Sheet behind a menu button. */ +export default function MobileNav({ nav, current }: { nav: Nav; current: string }) { + return ( + + + + + + + Navigation + Sections and components of the documentation. + + + + + ); +} diff --git a/examples/catalog/src/components/PrevNext.tsx b/examples/catalog/src/components/PrevNext.tsx new file mode 100644 index 0000000..0e32235 --- /dev/null +++ b/examples/catalog/src/components/PrevNext.tsx @@ -0,0 +1,38 @@ +import { Button } from "@robomous/ui-core"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; + +import type { NavLink } from "@/lib/nav"; + +/** The page before and after this one, in reading order. Rendered statically. */ +export default function PrevNext({ prev, next }: { prev?: NavLink; next?: NavLink }) { + return ( + + ); +} diff --git a/examples/catalog/src/components/Preview.astro b/examples/catalog/src/components/Preview.astro new file mode 100644 index 0000000..c55f738 --- /dev/null +++ b/examples/catalog/src/components/Preview.astro @@ -0,0 +1,29 @@ +--- +import CodePanel from "@/components/CodePanel.astro"; +import { demoSource } from "@/lib/demo-source"; + +interface Props { + /** Path under src/demos/, without extension: "button/variants". */ + demo: string; + align?: "center" | "start" | "stretch"; + class?: string; +} + +const { demo, align = "center", class: className } = Astro.props; +const code = demoSource(demo); +const alignment = { + center: "items-center justify-center", + start: "items-start justify-start", + stretch: "items-stretch justify-stretch", +}[align]; +--- + +
+
+ +
+ +
diff --git a/examples/catalog/src/components/SearchCommand.tsx b/examples/catalog/src/components/SearchCommand.tsx new file mode 100644 index 0000000..4562da4 --- /dev/null +++ b/examples/catalog/src/components/SearchCommand.tsx @@ -0,0 +1,104 @@ +import { + Button, + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Kbd, + KbdGroup, +} from "@robomous/ui-core"; +import { SearchIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface Entry { + title: string; + href: string; + description: string; + group: string; +} + +/** + * Search over the page index (`/search.json`, built from the content + * collections): a Command palette opened from the header or with Cmd/Ctrl+K. + */ +export default function SearchCommand() { + const [open, setOpen] = useState(false); + const [entries, setEntries] = useState(null); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + setOpen((value) => !value); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + useEffect(() => { + if (!open || entries !== null) return; + fetch("/search.json") + .then((response) => response.json() as Promise) + .then(setEntries) + .catch(() => setEntries([])); + }, [open, entries]); + + const groups = new Map(); + for (const entry of entries ?? []) { + groups.set(entry.group, [...(groups.get(entry.group) ?? []), entry]); + } + + return ( + <> + + + + + + {entries === null ? "Loading…" : "No page matches."} + {[...groups].map(([group, items]) => ( + + {items.map((entry) => ( + { + setOpen(false); + window.location.assign(entry.href); + }} + > + {entry.title} + {entry.description} + + ))} + + ))} + + + + + ); +} diff --git a/examples/catalog/src/components/Swatches.astro b/examples/catalog/src/components/Swatches.astro new file mode 100644 index 0000000..c574f80 --- /dev/null +++ b/examples/catalog/src/components/Swatches.astro @@ -0,0 +1,126 @@ +--- +// Token swatches for the theming page. Class strings are literal throughout — +// Tailwind scans source text, so a class built at runtime is a rule the build +// never emitted — the pattern the old catalog's Foundations section used. + +const SURFACES = [ + { name: "background / foreground", fill: "bg-background text-foreground" }, + { name: "card / card-foreground", fill: "bg-card text-card-foreground" }, + { name: "popover / popover-foreground", fill: "bg-popover text-popover-foreground" }, + { name: "muted / muted-foreground", fill: "bg-muted text-muted-foreground" }, + { name: "primary / primary-foreground", fill: "bg-primary text-primary-foreground" }, + { name: "secondary / secondary-foreground", fill: "bg-secondary text-secondary-foreground" }, + { name: "accent / accent-foreground", fill: "bg-accent text-accent-foreground" }, +] as const; + +const STATUS = [ + { name: "success", solid: "bg-success", soft: "bg-success/10 text-success" }, + { name: "warning", solid: "bg-warning", soft: "bg-warning/10 text-warning" }, + { name: "info", solid: "bg-info", soft: "bg-info/10 text-info" }, + { name: "destructive", solid: "bg-destructive", soft: "bg-destructive/10 text-destructive" }, +] as const; + +const STRUCTURE = [ + { name: "border", fill: "bg-border" }, + { name: "input", fill: "bg-input" }, + { name: "ring", fill: "bg-ring" }, + { name: "overlay", fill: "bg-overlay" }, +] as const; + +const RADII = [ + "rounded-sm", + "rounded-md", + "rounded-lg", + "rounded-xl", + "rounded-2xl", + "rounded-3xl", + "rounded-4xl", +] as const; + +const TYPE = ["text-xs", "text-sm", "text-base", "text-lg", "text-xl", "text-2xl"] as const; +--- + +
+
+

Surfaces and emphasis

+
+
+ {SURFACES.map((role) => ( +
+ {role.name} +
+ ))} +
+
+
+ +
+

Status

+
+
+ {STATUS.map((role) => ( +
+
+
{role.name}
+
+ ))} +
+
+
+ +
+

Structure

+
+
+ {STRUCTURE.map((role) => ( +
+
+ {role.name} +
+ ))} +
+ {/* Brand is reached as a variable, never a utility: there is no bg-brand. */} +
+ brand (var(--brand)) +
+
+
+
+ +
+

Radius

+
+
+ {RADII.map((cls) => ( +
+
+ {cls} +
+ ))} +
+
+
+ +
+

Typography

+
+
+ {TYPE.map((cls) => ( +

+ {cls} + The quick brown fox jumps over the lazy dog +

+ ))} +

a1b2c3d4 · 311.9 MB · org/model-tiny

+
+
+
+
diff --git a/examples/catalog/src/components/ThemeToggle.tsx b/examples/catalog/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..176285a --- /dev/null +++ b/examples/catalog/src/components/ThemeToggle.tsx @@ -0,0 +1,37 @@ +import { Button } from "@robomous/ui-core"; +import { MoonIcon, SunIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +/** + * Flips the `dark` class on , the theme's one source, and remembers the + * choice for the inline script in Base.astro to replay before the next paint. + */ +export default function ThemeToggle() { + const [dark, setDark] = useState(false); + useEffect(() => { + setDark(document.documentElement.classList.contains("dark")); + }, []); + + function toggle() { + const next = !dark; + document.documentElement.classList.toggle("dark", next); + try { + localStorage.setItem("theme", next ? "dark" : "light"); + } catch { + // Storage may be unavailable; the class still applied for this page. + } + setDark(next); + } + + return ( + + ); +} diff --git a/examples/catalog/src/components/Toc.astro b/examples/catalog/src/components/Toc.astro new file mode 100644 index 0000000..60a71a7 --- /dev/null +++ b/examples/catalog/src/components/Toc.astro @@ -0,0 +1,61 @@ +--- +import type { MarkdownHeading } from "astro"; + +interface Props { + headings: MarkdownHeading[]; +} + +const { headings } = Astro.props; +const items = headings.filter((heading) => heading.depth === 2 || heading.depth === 3); +--- + +{items.length > 0 && ( + +)} + + diff --git a/examples/catalog/src/content.config.ts b/examples/catalog/src/content.config.ts new file mode 100644 index 0000000..4bb4db2 --- /dev/null +++ b/examples/catalog/src/content.config.ts @@ -0,0 +1,35 @@ +import { glob } from "astro/loaders"; +import { z } from "astro/zod"; +import { defineCollection } from "astro:content"; + +/** The library a component's behaviour comes from, with where to read about it. */ +const builtOn = z.object({ + name: z.string(), + docsUrl: z.url(), + apiUrl: z.url().optional(), +}); + +// Prose pages: introduction, installation, theming, states. `order` is their +// place in the sidebar; the Components index follows them. +const sections = defineCollection({ + loader: glob({ pattern: "*.mdx", base: "./src/content/sections" }), + schema: z.object({ + title: z.string(), + description: z.string(), + order: z.number().int(), + }), +}); + +// One page per component. The id is the file name, which is the component's +// file name in src/components/ and the key of its generated API JSON. +const components = defineCollection({ + loader: glob({ pattern: "*.mdx", base: "./src/content/components" }), + schema: z.object({ + title: z.string(), + description: z.string(), + builtOn: z.array(builtOn).min(1), + related: z.array(z.string()).default([]), + }), +}); + +export const collections = { sections, components }; diff --git a/examples/catalog/src/content/components/alert.mdx b/examples/catalog/src/content/components/alert.mdx new file mode 100644 index 0000000..a15442e --- /dev/null +++ b/examples/catalog/src/content/components/alert.mdx @@ -0,0 +1,59 @@ +--- +title: Alert +description: An in-page notice with two variants, `default` and `destructive`, where the variant recolours the alert's ink and never its border. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Roles/alert_role +--- + +import Variants from "@/demos/alert/variants"; +import WithAction from "@/demos/alert/with-action"; + + + + + +## Usage + +```tsx +import { Alert, AlertTitle, AlertDescription } from "@robomous/ui-core"; +``` + +```tsx + + Ingest paused + The bucket is unreachable. Retrying in a minute. + +``` + +`role="alert"` announces the mount itself: there is no live-region prop to set and nothing else to +wire up. + +## Composition + +```text +Alert +├─ +├─ AlertTitle +├─ AlertDescription +└─ AlertAction +``` + +## Variants + +Two variants. `default` is a paused-but-recoverable notice; `destructive` is a failure that needs +attention. Either way the variant recolours the alert's ink and its description, never the border +around it — status is never a stroke. + + + + + +## Action + +`AlertAction` sits absolute at the top right; the alert detects it (`has-data-[slot=alert-action]`) +and pads its content clear on its own, so the title and description never run under it. + + + + diff --git a/examples/catalog/src/content/components/attachment.mdx b/examples/catalog/src/content/components/attachment.mdx new file mode 100644 index 0000000..5967d0b --- /dev/null +++ b/examples/catalog/src/content/components/attachment.mdx @@ -0,0 +1,109 @@ +--- +title: Attachment +description: An uploaded file as a card. `state` drives every part's styling, from a dashed idle drop target through a shimmering upload to a settled `done`. +builtOn: + - name: Radix Slot + docsUrl: https://www.radix-ui.com/primitives/docs/utilities/slot + apiUrl: https://www.radix-ui.com/primitives/docs/utilities/slot#api-reference +--- + +import States from "@/demos/attachment/states"; +import Sizes from "@/demos/attachment/sizes"; +import Orientation from "@/demos/attachment/orientation"; +import ImageMedia from "@/demos/attachment/image-media"; +import Trigger from "@/demos/attachment/trigger"; +import Group from "@/demos/attachment/group"; + + + + + +## Usage + +```tsx +import { Attachment, AttachmentContent, AttachmentMedia, AttachmentTitle } from "@robomous/ui-core"; +``` + +```tsx + + + + + + frames-03.tar + + +``` + +`state` is the one prop every part reads from: give it the value the upload is actually in, and the +border, the media and the title all follow. + +## Composition + +```text +Attachment +├─ AttachmentMedia +├─ AttachmentContent +│ ├─ AttachmentTitle +│ └─ AttachmentDescription +├─ AttachmentActions +│ └─ AttachmentAction +└─ AttachmentTrigger + +AttachmentGroup +``` + +## States + +Every part styles off `state`: dashed and quiet while idle, a spinner mid-upload, a shimmering +title while processing, and a plain settled `done`. + + + + + +## Sizes + +`default`, `sm` and `xs` step the padding and text down together; `xs` also tightens the corner +radius. + + + + + +## Orientation + +`vertical` stacks the media above the content and floats `AttachmentActions` over its corner, the +shape a thumbnail grid wants; `horizontal` is the default row. + + + + + +## Image media + +`variant="image"` fills the media slot with the attachment's own thumbnail instead of an icon; it +dims until the upload settles into `idle` or `done`. + + + + + +## Trigger + +`AttachmentTrigger` covers the whole card and renders `type="button"`, so the card itself is the +hit target without any part of it becoming a link. `asChild` hands that behaviour to another +element instead, the same pattern `Button` uses. + + + + + +## Group + +`AttachmentGroup` lays a batch of uploads out as a horizontally scrolling, snapping row instead of +wrapping them. + + + + diff --git a/examples/catalog/src/content/components/avatar.mdx b/examples/catalog/src/content/components/avatar.mdx new file mode 100644 index 0000000..25de7c2 --- /dev/null +++ b/examples/catalog/src/content/components/avatar.mdx @@ -0,0 +1,82 @@ +--- +title: Avatar +description: A user's picture reduced to a circle, in three sizes carried as `data-size` so a group's overflow count can match. `AvatarFallback` is not optional — it stands in for a missing or still-loading image. +builtOn: + - name: Radix Avatar + docsUrl: https://www.radix-ui.com/primitives/docs/components/avatar + apiUrl: https://www.radix-ui.com/primitives/docs/components/avatar#api-reference +--- + +import Sizes from "@/demos/avatar/sizes"; +import Badge from "@/demos/avatar/badge"; +import Group from "@/demos/avatar/group"; +import Fallback from "@/demos/avatar/fallback"; + + + + + +## Usage + +```tsx +import { Avatar, AvatarFallback, AvatarImage } from "@robomous/ui-core"; +``` + +```tsx + + + YA + +``` + +`AvatarFallback` is not optional: render it every time, so something shows while the image loads +and if it never does. + +## Composition + +```text +Avatar +├─ AvatarImage +├─ AvatarFallback +└─ AvatarBadge + +AvatarGroup +└─ AvatarGroupCount +``` + +## Sizes + +Three sizes, carried as `data-size` on the root so anything reading it — an `AvatarGroupCount` +sitting next to it — can match. + + + + + +## Badge + +`AvatarBadge` sits over the corner and scales itself down with the avatar's `data-size`, for a +presence or status dot. + + + + + +## Group + +`AvatarGroup` overlaps its children and rings each one in the page's background. +`AvatarGroupCount` picks up the same `data-size`, so the overflow count reads as one more avatar, +not a label stuck on the end. + + + + + +## Fallback + +`AvatarFallback` is not optional: a broken `AvatarImage` unmounts straight to it, so there is never +an empty circle. + + + + diff --git a/examples/catalog/src/content/components/badge.mdx b/examples/catalog/src/content/components/badge.mdx new file mode 100644 index 0000000..d0d46ee --- /dev/null +++ b/examples/catalog/src/content/components/badge.mdx @@ -0,0 +1,55 @@ +--- +title: Badge +description: A small status or count marker with ten variants; the status ones read as a soft surface plus ink, never a stroke. +builtOn: + - name: Radix Slot + docsUrl: https://www.radix-ui.com/primitives/docs/utilities/slot + apiUrl: https://www.radix-ui.com/primitives/docs/utilities/slot#api-reference +--- + +import Variants from "@/demos/badge/variants"; +import InContext from "@/demos/badge/in-context"; + + + + + +## Usage + +```tsx +import { Badge } from "@robomous/ui-core"; +``` + +```tsx +New +completed +failed +``` + +`asChild` hands the classes to a child element, so a badge can wrap an anchor and stay a link. + +## Composition + +```text +Badge +``` + +## Variants + +Ten variants: plain hierarchy (`default`, `secondary`, `outline`, `ghost`, `link`) and five status +roles (`destructive`, `success`, `warning`, `info`, `quiet`) that share one recipe — a soft `/10` +surface in the role plus ink in the same role, with `border-transparent` kept — because colour is +never the only signal and status is never a stroke. `quiet` is `muted`: a state that doesn't ask +for attention. + + + + + +## In context + +A status badge reads fastest beside the row it describes, not inside a sentence. + + + + diff --git a/examples/catalog/src/content/components/breadcrumb.mdx b/examples/catalog/src/content/components/breadcrumb.mdx new file mode 100644 index 0000000..b837f69 --- /dev/null +++ b/examples/catalog/src/content/components/breadcrumb.mdx @@ -0,0 +1,84 @@ +--- +title: Breadcrumb +description: A trail of links ending in the page you're on. The last crumb is a `BreadcrumbPage`, not a link, and the separators between them are decorative. +builtOn: + - name: Radix Slot + docsUrl: https://www.radix-ui.com/primitives/docs/utilities/slot + apiUrl: https://www.radix-ui.com/primitives/docs/utilities/slot#api-reference +--- + +import Trail from "@/demos/breadcrumb/trail"; +import Ellipsis from "@/demos/breadcrumb/ellipsis"; +import CustomSeparator from "@/demos/breadcrumb/custom-separator"; + + + + + +## Usage + +```tsx +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@robomous/ui-core"; +``` + +```tsx + + + + Datasets + + + + Batch 12 + + + +``` + +The crumb you're standing on is a `BreadcrumbPage`, never a `BreadcrumbLink`: there is nothing to +navigate to, so `aria-current="page"` is all it needs to say. + +## Composition + +```text +Breadcrumb +└─ BreadcrumbList + ├─ BreadcrumbItem + │ ├─ BreadcrumbLink + │ ├─ BreadcrumbPage + │ └─ BreadcrumbEllipsis + └─ BreadcrumbSeparator +``` + +## Trail + +The crumb you're standing on is a `BreadcrumbPage`, not a link: `aria-current="page"` tells +assistive tech, and there is nothing left to press. + + + + + +## Ellipsis + +`BreadcrumbEllipsis` stands in for the crumbs a long trail has no room to show. + + + + + +## Custom separator + +`BreadcrumbSeparator` accepts children: swap the chevron for anything else and it stays +`aria-hidden` either way. + + + + diff --git a/examples/catalog/src/content/components/button-group.mdx b/examples/catalog/src/content/components/button-group.mdx new file mode 100644 index 0000000..8f9dae6 --- /dev/null +++ b/examples/catalog/src/content/components/button-group.mdx @@ -0,0 +1,70 @@ +--- +title: ButtonGroup +description: Buttons, text, inputs and separators welded into one control. Every segment carries a `data-slot`, which is what rounds the first and the last of them. +builtOn: + - name: Radix Slot + docsUrl: https://www.radix-ui.com/primitives/docs/utilities/slot + apiUrl: https://www.radix-ui.com/primitives/docs/utilities/slot#api-reference +--- + +import TextInput from "@/demos/button-group/text-input"; +import Split from "@/demos/button-group/split"; +import Vertical from "@/demos/button-group/vertical"; + + + + + +## Usage + +```tsx +import { Button, ButtonGroup, ButtonGroupSeparator } from "@robomous/ui-core"; +``` + +```tsx + + + + + +``` + +Only a child carrying a `data-slot` gets rounded, so a plain `
` dropped in the middle leaves +the corners to its neighbours instead of breaking the shape. + +## Composition + +```text +ButtonGroup +├─ ButtonGroupText +├─ ButtonGroupSeparator +└─ Button / Input / ... +``` + +## Text and input + +Every segment carries a `data-slot`, which is what the group reads to round only the first and the +last child. + + + + + +## Split + +`ButtonGroupSeparator` is a `Separator` underneath: it inherits its orientation from the group +instead of setting one of its own. + + + + + +## Vertical + +`orientation="vertical"` stacks the segments and rounds the top and bottom instead of the ends. + + + + diff --git a/examples/catalog/src/content/components/button.mdx b/examples/catalog/src/content/components/button.mdx new file mode 100644 index 0000000..bc91537 --- /dev/null +++ b/examples/catalog/src/content/components/button.mdx @@ -0,0 +1,101 @@ +--- +title: Button +description: The action element. Six variants with one intent each, sizes down to an inline link, and `type="button"` unless asked otherwise. +builtOn: + - name: Radix Slot + docsUrl: https://www.radix-ui.com/primitives/docs/utilities/slot + apiUrl: https://www.radix-ui.com/primitives/docs/utilities/slot#api-reference +--- + +import AsChild from "@/demos/button/as-child"; +import Disabled from "@/demos/button/disabled"; +import Icon from "@/demos/button/icon"; +import Inline from "@/demos/button/inline"; +import Sizes from "@/demos/button/sizes"; +import Variants from "@/demos/button/variants"; +import WithIcon from "@/demos/button/with-icon"; + + + + + +## Usage + +```tsx +import { Button } from "@robomous/ui-core"; +``` + +```tsx + + + +``` + +A plain `Button` never submits a form: it renders `type="button"` unless you pass `type="submit"`. + +## Composition + +```text +Button +└─ +``` + +## Variants + +Six variants, one intent each. `default` is the page's single primary action; `destructive` is +the only one wearing a status colour, because it ends something. There is no `success` button. + + + + + +## Sizes + +Four heights on one baseline. The small sizes step down the radius scale with them. + + + + + +## Icon + +Square sizes for a bare icon. The label moves to `aria-label`. + + + + + +## With an icon + +An icon reads as part of the label. `data-icon="inline-start"` or `"inline-end"` tells the button +which side to pad. + + + + + +## Inline + +The `inline` size sits inside a sentence, with the line's own height. + + + + + +## As a link + +`asChild` hands the classes to the child element, so a link looks like a button and stays a link. + + + + + +## Disabled + +Disabled keeps the arrow cursor: nothing will respond to a press. + + + + diff --git a/examples/catalog/src/content/components/card.mdx b/examples/catalog/src/content/components/card.mdx new file mode 100644 index 0000000..0d9849c --- /dev/null +++ b/examples/catalog/src/content/components/card.mdx @@ -0,0 +1,94 @@ +--- +title: Card +description: A bounded surface for a header, a body and a footer band, all sharing one spacing scale that a single size prop tightens at once. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/HTML/Element/div +--- + +import Anatomy from "@/demos/card/anatomy"; +import Compact from "@/demos/card/compact"; +import StatTile from "@/demos/card/stat-tile"; +import WithAction from "@/demos/card/with-action"; +import WithTable from "@/demos/card/with-table"; + + + + + +## Usage + +```tsx +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@robomous/ui-core"; +``` + +```tsx + + + Warehouse cameras + Twelve feeds, sampled every four seconds. + + +

Frames land in the ingest bucket in order.

+
+
+``` + +`--card-spacing` is one custom property the whole card reads from, so `size="sm"` tightens the +header, content and footer padding together rather than each region needing its own override. + +## Composition + +```text +Card +├─ CardHeader +│ ├─ CardTitle +│ ├─ CardDescription +│ └─ CardAction +├─ CardContent +└─ CardFooter +``` + +## Anatomy + +Header, content and a footer band. The footer's muted surface and top rule mark it as a distinct +region for actions, not another paragraph of content. + + + + + +## Action + +`CardAction` opens a second grid column in `CardHeader`, aligned to the title's row no matter how +tall the description makes the header grow. + + + + + +## Compact + +`size="sm"` tightens `--card-spacing`, and `CardTitle` steps down to `text-sm` along with it. + + + + + +## Stat tile + +A card with only a header is the grid unit a dashboard is laid out from: `CardDescription` above, +`CardTitle` carrying the figure. + + + + + +## With a table + +`CardContent` is where a `Table` usually lives. The card's own padding stops at the content region, +so it never doubles up with the table's cell padding. + + + + diff --git a/examples/catalog/src/content/components/combobox.mdx b/examples/catalog/src/content/components/combobox.mdx new file mode 100644 index 0000000..a677b0b --- /dev/null +++ b/examples/catalog/src/content/components/combobox.mdx @@ -0,0 +1,93 @@ +--- +title: Combobox +description: A filterable list behind a text input. State lives in bare `data-open` / `data-highlighted` attributes; the visuals are ours. +builtOn: + - name: Base UI Combobox + docsUrl: https://base-ui.com/react/components/combobox + apiUrl: https://base-ui.com/react/components/combobox#api-reference +--- + +import Clearable from "@/demos/combobox/clearable"; +import Disabled from "@/demos/combobox/disabled"; +import MultiSelect from "@/demos/combobox/multi-select"; +import SingleSelect from "@/demos/combobox/single-select"; + + + + + +## Usage + +```tsx +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@robomous/ui-core"; +``` + +```tsx + + + + No matching classes + + {(item) => ( + + {item} + + )} + + + +``` + +`ComboboxList` takes a function child, called once per item after filtering — there is no separate +render prop. `ComboboxEmpty` shows itself once that list has nothing left to call it with. + +## Composition + +```text +Combobox +├─ ComboboxInput +├─ ComboboxChips +│ ├─ ComboboxChip +│ └─ ComboboxChipsInput +└─ ComboboxContent + └─ ComboboxList + ├─ ComboboxGroup + │ └─ ComboboxLabel + ├─ ComboboxItem + ├─ ComboboxSeparator + └─ ComboboxEmpty +``` + +## Clearable + +`showClear` on `ComboboxInput` swaps the trigger chevron for a clear button once a value is +picked, so clearing the field doesn't need a trip to the keyboard. + + + + + +## Multi-select + +Setting `multiple` collects values into an array. There is no input left to anchor the popup to, +so a multi-select swaps `ComboboxInput` for `ComboboxChips`, and `useComboboxAnchor` gives +`ComboboxContent` an element to position against instead. + + + + + +## Disabled + +Locked while an ingest job holds the batch. + + + + diff --git a/examples/catalog/src/content/components/command.mdx b/examples/catalog/src/content/components/command.mdx new file mode 100644 index 0000000..c7687aa --- /dev/null +++ b/examples/catalog/src/content/components/command.mdx @@ -0,0 +1,82 @@ +--- +title: Command +description: A listbox driven from a search field the reader never leaves, inline or inside a dialog. `CommandDialog`'s title and description are `sr-only` inside the dialog — that names it, without a visible heading. +builtOn: + - name: cmdk + docsUrl: https://github.com/pacocoursey/cmdk + - name: Radix Dialog + docsUrl: https://www.radix-ui.com/primitives/docs/components/dialog + apiUrl: https://www.radix-ui.com/primitives/docs/components/dialog#api-reference +--- + +import Dialog from "@/demos/command/dialog"; +import Inline from "@/demos/command/inline"; + + + + + +## Usage + +```tsx +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@robomous/ui-core"; +``` + +```tsx + + + + Nothing matches. + + Upload a batch + + + +``` + +`CommandItem`'s `value` drives the filter match; give it the words a reader might type, not +necessarily the label shown. + +## Composition + +```text +Command +├─ CommandInput +└─ CommandList + ├─ CommandEmpty + ├─ CommandGroup + │ ├─ CommandItem + │ │ └─ CommandShortcut + │ └─ CommandItem + └─ CommandSeparator + +CommandDialog +└─ Command + └─ … (as above) +``` + +## Inline + +The everyday shape: a field at the top, a scrolling list beneath it, nothing hidden behind a +trigger. + + + + + +## Command palette + +Opened from a button or from Cmd/Ctrl+K. `CommandDialog`'s `title` and `description` are `sr-only` +inside the dialog — the reader never sees them, but they're what names the dialog for anyone using +a screen reader, in place of a visible heading. + + + + diff --git a/examples/catalog/src/content/components/context-menu.mdx b/examples/catalog/src/content/components/context-menu.mdx new file mode 100644 index 0000000..a97a0ad --- /dev/null +++ b/examples/catalog/src/content/components/context-menu.mdx @@ -0,0 +1,90 @@ +--- +title: ContextMenu +description: A menu that opens on right-click instead of a press, over the same item anatomy as DropdownMenu. Neither surface plays an exit animation, for the same reason — the reader can right-click again before the closing menu has left. +builtOn: + - name: Radix ContextMenu + docsUrl: https://www.radix-ui.com/primitives/docs/components/context-menu + apiUrl: https://www.radix-ui.com/primitives/docs/components/context-menu#api-reference +--- + +import Basic from "@/demos/context-menu/basic"; +import CheckboxItems from "@/demos/context-menu/checkbox-items"; +import Submenu from "@/demos/context-menu/submenu"; + + + + + +## Usage + +```tsx +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@robomous/ui-core"; +``` + +```tsx + + + Right-click here + + + Copy link + Delete + + +``` + +`ContextMenuTrigger` is the area itself, not a button: it wraps whatever content the reader +right-clicks and renders `select-none` so the click doesn't also select text. + +## Composition + +```text +ContextMenu +├─ ContextMenuTrigger +└─ ContextMenuPortal + └─ ContextMenuContent + ├─ ContextMenuLabel + ├─ ContextMenuGroup + │ └─ ContextMenuItem + │ └─ ContextMenuShortcut + ├─ ContextMenuCheckboxItem + ├─ ContextMenuRadioGroup + │ └─ ContextMenuRadioItem + ├─ ContextMenuSeparator + └─ ContextMenuSub + ├─ ContextMenuSubTrigger + └─ ContextMenuSubContent + └─ ContextMenuItem +``` + +## Basic + +A label, a group holding an item with a shortcut, a separator, and a destructive item — the +anatomy a right-click menu reaches for first. + + + + + +## Checkbox items + +Checked, unchecked, and indeterminate sit on the same axis as `DropdownMenuCheckboxItem`. The +indeterminate state wears a dash, not the tick that would claim every one of them. + + + + + +## Submenu + +A submenu can hold its own radio group, so a destination is chosen without leaving the row that +opened it. It shares its parent's no-exit-animation rule: the next right-click has to land. + + + + diff --git a/examples/catalog/src/content/components/dialog.mdx b/examples/catalog/src/content/components/dialog.mdx new file mode 100644 index 0000000..08da7a0 --- /dev/null +++ b/examples/catalog/src/content/components/dialog.mdx @@ -0,0 +1,103 @@ +--- +title: Dialog +description: A modal surface that blocks the page behind it, labelled by its title and described by its description through Radix's own ids. It keeps its exit animation, because its trigger cannot be pressed again until it leaves. +builtOn: + - name: Radix Dialog + docsUrl: https://www.radix-ui.com/primitives/docs/components/dialog + apiUrl: https://www.radix-ui.com/primitives/docs/components/dialog#api-reference +--- + +import Confirm from "@/demos/dialog/confirm"; +import Default from "@/demos/dialog/default"; +import Form from "@/demos/dialog/form"; +import NoCloseButton from "@/demos/dialog/no-close-button"; + + + + + +## Usage + +```tsx +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@robomous/ui-core"; +``` + +```tsx + + + + + + + New dataset + Frames are grouped into batches as they arrive. + + + + + + + +``` + +`DialogContent` portals itself and paints `DialogOverlay` behind it; `DialogHeader` and +`DialogFooter` are layout, not required wiring — the title and description are what Radix reads. + +## Composition + +```text +Dialog +├─ DialogTrigger +└─ DialogContent + ├─ DialogHeader + │ ├─ DialogTitle + │ └─ DialogDescription + ├─ DialogClose + └─ DialogFooter +``` + +## Header and footer + +`DialogFooter` is a `-mx-4 -mb-4` band that breaks out of the content padding and rounds the +bottom corners. Below `sm:` it stacks full-width and reverses order, so the primary action sits +first in the DOM and on top visually; at `sm:` and above it becomes a right-aligned row. + + + + + +## Form + +A dialog that collects something composes `Field` anatomy directly inside `DialogContent`, the +same as anywhere else it appears. + + +
+ + +## Destructive confirmation + +`showCloseButton` on `DialogFooter` adds a plain Close action ahead of the call site's own +buttons — here, beside a `destructive` one that ends something. + + + + + +## No close button + +`showCloseButton={false}` on `DialogContent` removes the corner close affordance entirely, for +when the call site owns dismissal elsewhere, such as its own footer actions. + + + + diff --git a/examples/catalog/src/content/components/drawer.mdx b/examples/catalog/src/content/components/drawer.mdx new file mode 100644 index 0000000..03eae7d --- /dev/null +++ b/examples/catalog/src/content/components/drawer.mdx @@ -0,0 +1,79 @@ +--- +title: Drawer +description: A panel that slides in and can be dragged shut, built on vaul rather than Radix. `direction` picks the edge it opens from, and only the bottom drawer gets a drag handle, because only it can be pulled by gesture. +builtOn: + - name: vaul + docsUrl: https://vaul.emilkowal.ski/ + apiUrl: https://vaul.emilkowal.ski/api +--- + +import Bottom from "@/demos/drawer/bottom"; +import Directions from "@/demos/drawer/directions"; + + + + + +## Usage + +```tsx +import { + Button, + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@robomous/ui-core"; +``` + +```tsx + + + + + + + Filters + Narrow the batches shown. + + + +``` + +`direction` defaults to `bottom`, the only direction that renders a drag handle; the others open +like a Sheet that can also be swiped shut. Every Drawer demo on this page hydrates with +`client:only="react"`, because vaul reads the viewport to position itself and has nothing +meaningful to render on the server. + +## Composition + +```text +Drawer +├─ DrawerTrigger +└─ DrawerContent + ├─ DrawerHeader + │ ├─ DrawerTitle + │ └─ DrawerDescription + └─ DrawerFooter + └─ DrawerClose +``` + +## Bottom + +The default direction. A drag handle sits above the content so the panel can be pulled shut by +gesture, the one behaviour neither Radix nor Base UI gives the rest of the package. + + + + + +## Directions + +`direction` opens the drawer from any edge. Only `bottom` shows the drag handle; the others are a +Dialog-like panel that keeps its exit animation, as a Sheet does. + + + + diff --git a/examples/catalog/src/content/components/dropdown-menu.mdx b/examples/catalog/src/content/components/dropdown-menu.mdx new file mode 100644 index 0000000..0629e9e --- /dev/null +++ b/examples/catalog/src/content/components/dropdown-menu.mdx @@ -0,0 +1,120 @@ +--- +title: DropdownMenu +description: A menu opened from a trigger, sized to its items rather than the trigger — `min-w-32` is only a floor. Neither the menu nor a submenu plays an exit animation, so the trigger can open the next one on the very next frame. +builtOn: + - name: Radix DropdownMenu + docsUrl: https://www.radix-ui.com/primitives/docs/components/dropdown-menu + apiUrl: https://www.radix-ui.com/primitives/docs/components/dropdown-menu#api-reference +--- + +import Basic from "@/demos/dropdown-menu/basic"; +import CheckboxItems from "@/demos/dropdown-menu/checkbox-items"; +import Destructive from "@/demos/dropdown-menu/destructive"; +import Inset from "@/demos/dropdown-menu/inset"; +import RadioGroup from "@/demos/dropdown-menu/radio-group"; +import Submenu from "@/demos/dropdown-menu/submenu"; + + + + + +## Usage + +```tsx +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@robomous/ui-core"; +``` + +```tsx + + + + + + Rename + Delete + + +``` + +The menu stays closed on the server render; it opens only once its trigger is pressed, and Radix +owns focus, typeahead and outside-dismiss from there. + +## Composition + +```text +DropdownMenu +├─ DropdownMenuTrigger +└─ DropdownMenuPortal + └─ DropdownMenuContent + ├─ DropdownMenuLabel + ├─ DropdownMenuGroup + │ └─ DropdownMenuItem + │ └─ DropdownMenuShortcut + ├─ DropdownMenuCheckboxItem + ├─ DropdownMenuRadioGroup + │ └─ DropdownMenuRadioItem + ├─ DropdownMenuSeparator + └─ DropdownMenuSub + ├─ DropdownMenuSubTrigger + └─ DropdownMenuSubContent + └─ DropdownMenuItem +``` + +## Basic + +A label over a group, a separator, and a shortcut hint next to a plain item — the anatomy most +menus reach for first. + + + + + +## Checkbox items + +Checked, unchecked, and indeterminate sit on the same axis. The indeterminate state wears a dash, +not the tick that would claim every one of them. + + + + + +## Radio group + +One tab stop; the arrow keys move the selection inside the group, exactly as they do for a +standalone `RadioGroup`. + + + + + +## Submenu + +A submenu opens on hover or the right arrow key. It follows the same rule as its parent: no exit +animation, so the row that opens the next menu is never swallowed by the one still closing. + + + + + +## Destructive item + +`variant="destructive"` recolours an item's text and icon before focus even lands on it — the only +item variant that wears a status colour, because it ends something. + + + + + +## Inset items + +`inset` lines a label's or an item's leading edge up with the icons in the rows around it, so a +menu that mixes icon and plain rows still reads as one column. + + + + diff --git a/examples/catalog/src/content/components/empty.mdx b/examples/catalog/src/content/components/empty.mdx new file mode 100644 index 0000000..4a008cc --- /dev/null +++ b/examples/catalog/src/content/components/empty.mdx @@ -0,0 +1,77 @@ +--- +title: Empty +description: The panel that says what is missing and what to do about it, in place of a blank list. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/HTML/Element/div +--- + +import DefaultMedia from "@/demos/empty/default-media"; +import Icon from "@/demos/empty/icon"; +import NoResults from "@/demos/empty/no-results"; + + + + + +## Usage + +```tsx +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@robomous/ui-core"; +``` + +```tsx + + + + + + No batches yet + Upload a batch to start labelling. + + + + + +``` + +`Empty` sets no border of its own, so a dashed one is opt-in through `className` for the cases +where the panel needs to read as an empty container rather than a plain message. + +## Composition + +```text +Empty +├─ EmptyHeader +│ ├─ EmptyMedia +│ ├─ EmptyTitle +│ └─ EmptyDescription +└─ EmptyContent +``` + +## Icon media + +`EmptyMedia` `variant="icon"` sets the icon inside a muted square. `EmptyContent` holds the single +action that resolves the panel — never a second, competing button. + + + + + +## Default media + +`variant="default"` leaves the icon unstyled, for a larger mark that carries the panel on its own +rather than sitting inside a chip. + + + + + +## Without an action + +Not every empty panel has something to do: a filtered list can just say why it's empty and how to +change the filter, with no `EmptyContent` at all. + + + + diff --git a/examples/catalog/src/content/components/field.mdx b/examples/catalog/src/content/components/field.mdx new file mode 100644 index 0000000..0e75a4f --- /dev/null +++ b/examples/catalog/src/content/components/field.mdx @@ -0,0 +1,98 @@ +--- +title: Field +description: Gives a control its structure — label, description, error and layout — while the accessible wiring stays with the call site that knows the ids. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/HTML/Element/div +--- + +import Default from "@/demos/field/default"; +import Invalid from "@/demos/field/invalid"; +import Horizontal from "@/demos/field/horizontal"; +import Responsive from "@/demos/field/responsive"; +import FieldSetComposition from "@/demos/field/field-set"; + + + + + +## Usage + +```tsx +import { Field, FieldLabel, FieldDescription, FieldError, Input } from "@robomous/ui-core"; +``` + +```tsx + + Name + + Shown on your profile. + {hasError && Required} + +``` + +`Field` is anatomy, not wiring: nothing generates `aria-describedby` or `aria-invalid` for you, so +the call site that owns the ids writes them explicitly (see DESIGN.md's *Accessibility* section). +`FieldError` renders `role="alert"`; `Field` renders `role="group"` and styles `data-invalid`. + +## Composition + +```text +FieldSet +├─ FieldLegend +└─ FieldGroup + └─ Field + ├─ FieldLabel + ├─ FieldContent + ├─ FieldTitle + ├─ FieldDescription + ├─ FieldError + └─ FieldSeparator +``` + +## A complete field + +Label, control and description, with `htmlFor`/`id`/`aria-describedby` written out at the call +site — `Field` supplies only the layout and the grouping role. + + + + + +## Invalid + +`data-invalid` on `Field`, `aria-invalid` on the control, and `FieldError` in place of the +description: three attributes the call site sets together, none of them inferred from the others. + + + + + +## Horizontal + +`orientation="horizontal"` places the label beside the control instead of above it, from +`fieldVariants` in `field.tsx`. + + + + + +## Responsive + +`orientation="responsive"` stacks the label above the control while its `FieldGroup` is narrow, +then sets it beside the control once the group crosses a container breakpoint — a container query +against the group's own width, not the viewport's. + + + + + +## FieldSet, FieldLegend and FieldGroup + +`FieldSet` and `FieldLegend` give a group of fields a real `
`/`` pair; `FieldGroup` +only spaces the fields inside it and provides the container the responsive orientation measures +against. + + + + diff --git a/examples/catalog/src/content/components/hover-card.mdx b/examples/catalog/src/content/components/hover-card.mdx new file mode 100644 index 0000000..ecb3d86 --- /dev/null +++ b/examples/catalog/src/content/components/hover-card.mdx @@ -0,0 +1,59 @@ +--- +title: HoverCard +description: A preview that appears when the pointer rests on its trigger. It is pointer-only, so it must never be the sole route to whatever it shows, and it keeps its exit animation because nothing is waiting on the frame after it leaves. +builtOn: + - name: Radix HoverCard + docsUrl: https://www.radix-ui.com/primitives/docs/components/hover-card + apiUrl: https://www.radix-ui.com/primitives/docs/components/hover-card#api-reference +--- + +import Profile from "@/demos/hover-card/profile"; +import Status from "@/demos/hover-card/status"; + + + + + +## Usage + +```tsx +import { Button, HoverCard, HoverCardContent, HoverCardTrigger } from "@robomous/ui-core"; +``` + +```tsx + + + + + Vision tooling. Joined 2026. + +``` + +The trigger must stay reachable and useful on its own — a link that still navigates, a button +that still does something — because touch and keyboard users may never see the card. + +## Composition + +```text +HoverCard +├─ HoverCardTrigger +└─ HoverCardContent +``` + +## Profile + +A pointer-only preview, additive to a trigger that already does something on its own — here, a +real, focusable `Button`. + + + + + +## Status preview + +Any focusable control can open a hover card. Content is free-form: this one previews a camera's +live status rather than a profile. + + + + diff --git a/examples/catalog/src/content/components/input-group.mdx b/examples/catalog/src/content/components/input-group.mdx new file mode 100644 index 0000000..d224c7a --- /dev/null +++ b/examples/catalog/src/content/components/input-group.mdx @@ -0,0 +1,83 @@ +--- +title: InputGroup +description: Wraps a control with addons on any of its four edges — icons, text, or buttons — and refocuses the control when an addon that isn't a button is clicked. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/HTML/Element/div +--- + +import WithAddons from "@/demos/input-group/with-addons"; +import UnitsText from "@/demos/input-group/units-text"; +import ButtonSizes from "@/demos/input-group/button-sizes"; +import WithTextarea from "@/demos/input-group/with-textarea"; + + + + + +## Usage + +```tsx +import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupButton } from "@robomous/ui-core"; +``` + +```tsx + + + + + + + Go + + +``` + +Clicking an addon that isn't a button refocuses `InputGroupInput`/`InputGroupTextarea`, so an icon +addon behaves like part of the field rather than a dead label. + +## Composition + +```text +InputGroup +├─ InputGroupAddon +│ ├─ InputGroupButton +│ └─ InputGroupText +├─ InputGroupInput +└─ InputGroupTextarea +``` + +## Addons + +An icon addon at `inline-start`, the control, and a button addon at `inline-end`. + + + + + +## Units as text + +`InputGroupText` renders plain, non-interactive copy inside an addon, such as a units suffix that +should never look pressable. + + + + + +## Button sizes + +`InputGroupButton` defaults to `xs`, the only size that fits inside the group's own height without +enlarging it; `sm`, `icon-xs` and `icon-sm` cover the rest. + + + + + +## With a textarea + +`block-start` and `block-end` addons sit above and below an `InputGroupTextarea`, which grows the +group instead of the group growing around a fixed input height. + + + + diff --git a/examples/catalog/src/content/components/input.mdx b/examples/catalog/src/content/components/input.mdx new file mode 100644 index 0000000..276e1db --- /dev/null +++ b/examples/catalog/src/content/components/input.mdx @@ -0,0 +1,52 @@ +--- +title: Input +description: A native text input. `aria-invalid` reads as the invalid state, never a class of its own. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/HTML/Element/input +--- + +import Default from "@/demos/input/default"; +import Disabled from "@/demos/input/disabled"; +import Invalid from "@/demos/input/invalid"; + + + + + +## Usage + +```tsx +import { Input } from "@robomous/ui-core"; +``` + +```tsx + +``` + +A bare `Input` needs its own `aria-label`; pair it with `Field`/`FieldLabel` instead when there is +a visible label and, often, a description to attach through `aria-describedby`. + +## Composition + +```text +Input +``` + +## Disabled + +Disabled keeps the arrow cursor and drops the opacity: nothing will respond to focus or a +keystroke. + + + + + +## Invalid + +`aria-invalid` styles the invalid state directly, so a screen reader and the stylesheet agree on +the same attribute. + + + + diff --git a/examples/catalog/src/content/components/item.mdx b/examples/catalog/src/content/components/item.mdx new file mode 100644 index 0000000..859b058 --- /dev/null +++ b/examples/catalog/src/content/components/item.mdx @@ -0,0 +1,101 @@ +--- +title: Item +description: A row with its own title, description and actions, for a list where each entry is more than one line of text. +builtOn: + - name: Radix Slot + docsUrl: https://www.radix-ui.com/primitives/docs/utilities/slot + apiUrl: https://www.radix-ui.com/primitives/docs/utilities/slot#api-reference +--- + +import AsChild from "@/demos/item/as-child"; +import Group from "@/demos/item/group"; +import Media from "@/demos/item/media"; +import Sizes from "@/demos/item/sizes"; +import Variants from "@/demos/item/variants"; + + + + + +## Usage + +```tsx +import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@robomous/ui-core"; +``` + +```tsx + + + + + + batch-0041.zip + 311.9 MB, uploaded today + + +``` + +`ItemContent` holds `ItemTitle` and `ItemDescription`; anything trailing goes in `ItemActions`, +which sits outside `ItemContent` so it never competes with the title for the row's width. + +## Composition + +```text +ItemGroup +├─ Item +│ ├─ ItemMedia +│ ├─ ItemContent +│ │ ├─ ItemTitle +│ │ └─ ItemDescription +│ ├─ ItemActions +│ ├─ ItemHeader +│ └─ ItemFooter +└─ ItemSeparator +``` + +## Variants + +`default` is borderless, sitting flush with whatever it's stacked in. `outline` draws its own +border, for a row that reads as a distinct card. `muted` fills with the muted surface instead, +for a row that's present but demoted, like a folder among files. + + + + + +## Sizes + +`size` steps `default` down to `sm` and `xs`, tightening the row's own padding and, on `ItemGroup`, +the gap between stacked items. + + + + + +## Media + +`ItemMedia` `variant="default"` leaves its content unstyled, for something that already carries its +own size, like a status dot. `variant="icon"` sizes a `lucide-react` icon consistently. `variant="image"` +crops whatever `` it wraps to a fixed square, shrinking with `size`. + + + + + +## Group with a separator + +`ItemGroup` is `role="list"`, so assistive tech announces the count. `ItemSeparator` marks the +boundary between rows instead of each `Item` drawing its own border. + + + + + +## As a link + +`asChild` hands the row's classes to an anchor, so the whole item is a link and stays reachable as +one tab stop rather than a title link buried inside a static row. + + + + diff --git a/examples/catalog/src/content/components/kbd.mdx b/examples/catalog/src/content/components/kbd.mdx new file mode 100644 index 0000000..c68b63c --- /dev/null +++ b/examples/catalog/src/content/components/kbd.mdx @@ -0,0 +1,73 @@ +--- +title: Kbd +description: A real `` for a single keystroke, with `KbdGroup` holding several keys together as one chord. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/HTML/Element/kbd +--- + +import Chord from "@/demos/kbd/chord"; +import Single from "@/demos/kbd/single"; +import InButton from "@/demos/kbd/in-button"; +import InTooltip from "@/demos/kbd/in-tooltip"; + + + + + +## Usage + +```tsx +import { Kbd, KbdGroup } from "@robomous/ui-core"; +``` + +```tsx + + ⌘ + K + +``` + +A lone key is just a `Kbd`; reach for `KbdGroup` only once there is more than one to read together. + +## Composition + +```text +Kbd +KbdGroup +└─ Kbd +``` + +## Chord + +`KbdGroup` holds a chord: the keys read together as one shortcut, not a list of separate ones. + + + + + +## Single key + +A lone `Kbd` sits inline with body text, sized to the line rather than as a boxed control. + + + + + +## Inside a button + +`data-icon="inline-end"` pads a `KbdGroup` the same way it pads an icon, so a shortcut sits at the +label's edge. + + + + + +## Inside a tooltip + +`Kbd`'s background and text recolour automatically inside a `TooltipContent`, so it reads on the +tooltip's own fill instead of the page's. + + + + diff --git a/examples/catalog/src/content/components/label.mdx b/examples/catalog/src/content/components/label.mdx new file mode 100644 index 0000000..468ae3b --- /dev/null +++ b/examples/catalog/src/content/components/label.mdx @@ -0,0 +1,55 @@ +--- +title: Label +description: Names the control it pairs with through `htmlFor`/`id`, and dims through `peer-disabled` when the control it names is disabled. +builtOn: + - name: Radix Label + docsUrl: https://www.radix-ui.com/primitives/docs/components/label + apiUrl: https://www.radix-ui.com/primitives/docs/components/label#api-reference +--- + +import Default from "@/demos/label/default"; +import DisabledPeer from "@/demos/label/disabled-peer"; + + + + + +## Usage + +```tsx +import { Label, Input } from "@robomous/ui-core"; +``` + +```tsx +
+ + +
+``` + +`htmlFor` must match the control's `id`, so a click on the label focuses it. `FieldLabel` writes +this pairing for you inside a `Field`. + +## Composition + +```text +Label +``` + +## Paired with a control + +A label and the input it names, wired by hand with `htmlFor` and `id`. + + + + + +## Disabled peer + +`peer-disabled` styles a label reactively when the sibling it follows in the DOM is disabled — the +control has to come first in markup for the CSS sibling selector to reach it, so a visually +top-of-stack label needs `flex-col-reverse` underneath it. + + + + diff --git a/examples/catalog/src/content/components/pagination.mdx b/examples/catalog/src/content/components/pagination.mdx new file mode 100644 index 0000000..fa56686 --- /dev/null +++ b/examples/catalog/src/content/components/pagination.mdx @@ -0,0 +1,79 @@ +--- +title: Pagination +description: A page-by-page control built from real anchors, not buttons, so every step keeps working without JavaScript. `isActive` marks the current page for the eye and for assistive tech together. +builtOn: + - name: Radix Slot + docsUrl: https://www.radix-ui.com/primitives/docs/utilities/slot + apiUrl: https://www.radix-ui.com/primitives/docs/utilities/slot#api-reference +--- + +import Default from "@/demos/pagination/default"; +import AtTheStart from "@/demos/pagination/at-the-start"; + + + + + +## Usage + +```tsx +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@robomous/ui-core"; +``` + +```tsx + + + + + + + + 2 + + + + + + + +``` + +`PaginationLink` renders a `Button` under the hood, but `asChild` hands its classes to the `` you +give it, so the page still navigates like a link. + +## Composition + +```text +Pagination +└─ PaginationContent + ├─ PaginationItem + │ ├─ PaginationLink + │ ├─ PaginationPrevious + │ └─ PaginationNext + └─ PaginationEllipsis +``` + +## Default + +Every step, including the numbers, is a real anchor: middle-click and open-in-new-tab both work, +which a ` + + + + Rename + Shown wherever this batch is listed. + + + + + +``` + +`PopoverHeader` is optional layout for `PopoverTitle` and `PopoverDescription`; the rest of +`PopoverContent` is free-form, same as filling out a small form. + +## Composition + +```text +Popover +├─ PopoverTrigger +├─ PopoverAnchor +└─ PopoverContent + └─ PopoverHeader + ├─ PopoverTitle + └─ PopoverDescription +``` + +## Rename + +`PopoverContent` carries no exit animation, unlike a Dialog's or a Sheet's, because its trigger +is a plain button the reader is free to press again on the very next frame (see DESIGN.md, +*Motion*). + + + + + +## Anchor + +`PopoverAnchor` positions the content against a different element than the one that opens it — +here, the camera's name rather than the info button beside it. + + + + diff --git a/examples/catalog/src/content/components/progress.mdx b/examples/catalog/src/content/components/progress.mdx new file mode 100644 index 0000000..a7e4339 --- /dev/null +++ b/examples/catalog/src/content/components/progress.mdx @@ -0,0 +1,52 @@ +--- +title: Progress +description: A determinate progress bar that forwards `value` straight to `aria-valuenow`; completion is an amount, not a polarity, so there is no variant. +builtOn: + - name: Radix Progress + docsUrl: https://www.radix-ui.com/primitives/docs/components/progress + apiUrl: https://www.radix-ui.com/primitives/docs/components/progress#api-reference +--- + +import Value from "@/demos/progress/value"; +import Stages from "@/demos/progress/stages"; + + + + + +## Usage + +```tsx +import { Progress } from "@robomous/ui-core"; +``` + +```tsx + +``` + +`aria-label` (or `aria-labelledby`) is the caller's own responsibility: `Progress` has no visible +label and announces only the number. + +## Composition + +```text +Progress +``` + +## Value + +The indicator's width and `aria-valuenow` both come straight from `value`. There is no variant, +because completion is an amount, never a status. + + + + + +## Stages + +Several bars stacked report the steps of one pipeline independently, each with its own `value` and +label. + + + + diff --git a/examples/catalog/src/content/components/radio-group.mdx b/examples/catalog/src/content/components/radio-group.mdx new file mode 100644 index 0000000..74a8740 --- /dev/null +++ b/examples/catalog/src/content/components/radio-group.mdx @@ -0,0 +1,63 @@ +--- +title: RadioGroup +description: One tab stop for the whole group. The arrow keys move the selection, not just focus, and `aria-invalid` styles the invalid state. +builtOn: + - name: Radix RadioGroup + docsUrl: https://www.radix-ui.com/primitives/docs/components/radio-group + apiUrl: https://www.radix-ui.com/primitives/docs/components/radio-group#api-reference +--- + +import Default from "@/demos/radio-group/default"; +import Disabled from "@/demos/radio-group/disabled"; +import Invalid from "@/demos/radio-group/invalid"; + + + + + +## Usage + +```tsx +import { Label, RadioGroup, RadioGroupItem } from "@robomous/ui-core"; +``` + +```tsx + +
+ + +
+
+ + +
+
+``` + +`RadioGroupItem` renders the input; it carries no text of its own, so pair each one with a `Label` +through `htmlFor` rather than relying on adjacency. + +## Composition + +```text +RadioGroup +└─ RadioGroupItem +``` + +## Disabled + +A single disabled option inside an otherwise enabled group. Its `Label` dims with it, because the +item's own class carries `peer` for exactly that pairing. + + + + + +## Invalid + +`aria-invalid` on an item styles its ring as destructive without hiding which option is currently +selected — the checked state still reads through. + + + + diff --git a/examples/catalog/src/content/components/scroll-area.mdx b/examples/catalog/src/content/components/scroll-area.mdx new file mode 100644 index 0000000..709b89d --- /dev/null +++ b/examples/catalog/src/content/components/scroll-area.mdx @@ -0,0 +1,56 @@ +--- +title: ScrollArea +description: A scroll region with its own thin, themeable scrollbar. The viewport inside it is the actual scroll container, not the root element you size. +builtOn: + - name: Radix ScrollArea + docsUrl: https://www.radix-ui.com/primitives/docs/components/scroll-area + apiUrl: https://www.radix-ui.com/primitives/docs/components/scroll-area#api-reference +--- + +import VerticalList from "@/demos/scroll-area/vertical-list"; +import Horizontal from "@/demos/scroll-area/horizontal"; + + + + + +## Usage + +```tsx +import { ScrollArea } from "@robomous/ui-core"; +``` + +```tsx + +
{/* content taller than 10rem */}
+
+``` + +Size the `ScrollArea` itself (`h-*`, `w-*`); the viewport inside it fills that box and scrolls, so +the content can be as tall as it needs to be. + +## Composition + +```text +ScrollArea + real scrolling viewport and a corner +└─ ScrollBar +``` + +## Vertical list + +The viewport, not the `ScrollArea` root, is the scroll container: sizing goes on the root, content +grows freely inside it. + + + + + +## Horizontal + +A second `ScrollBar` with `orientation="horizontal"` tracks the viewport's x-axis instead of its +default y-axis. + + + + diff --git a/examples/catalog/src/content/components/select.mdx b/examples/catalog/src/content/components/select.mdx new file mode 100644 index 0000000..622a2c4 --- /dev/null +++ b/examples/catalog/src/content/components/select.mdx @@ -0,0 +1,90 @@ +--- +title: Select +description: A listbox behind a button, with two trigger heights and a `multiline` mode for a value too long to clamp to one line. +builtOn: + - name: Radix Select + docsUrl: https://www.radix-ui.com/primitives/docs/components/select + apiUrl: https://www.radix-ui.com/primitives/docs/components/select#api-reference +--- + +import Default from "@/demos/select/default"; +import Disabled from "@/demos/select/disabled"; +import Groups from "@/demos/select/groups"; +import Multiline from "@/demos/select/multiline"; +import Sizes from "@/demos/select/sizes"; + + + + + +## Usage + +```tsx +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@robomous/ui-core"; +``` + +```tsx + +``` + +`SelectTrigger` carries no visible label of its own, so give it an `aria-label` or pair it with a +`Label` through `htmlFor`. + +## Composition + +```text +Select +├─ SelectTrigger +│ └─ SelectValue +└─ SelectContent + ├─ SelectScrollUpButton + ├─ SelectGroup + │ ├─ SelectLabel + │ └─ SelectItem + ├─ SelectSeparator + └─ SelectScrollDownButton +``` + +## Sizes + +Two trigger heights: `default` for a form, `sm` for a dense toolbar. Both keep the same radius +scale as the rest of the field controls. + + + + + +## Multiline + +The trigger is `whitespace-nowrap` by default, so a long value clips. `multiline` lifts that and +lets the closed trigger wrap and grow to its value instead — `line-clamp-none` on its own would +leave the value on one line, overflowing the box, so the prop does both jobs together. + + + + + +## Groups + +`SelectGroup` and `SelectLabel` split a long list into named sections, with `SelectSeparator` +between them. + + + + + +## Disabled + +Disabled keeps the arrow cursor: nothing will respond to a press. + + + + diff --git a/examples/catalog/src/content/components/separator.mdx b/examples/catalog/src/content/components/separator.mdx new file mode 100644 index 0000000..5060578 --- /dev/null +++ b/examples/catalog/src/content/components/separator.mdx @@ -0,0 +1,53 @@ +--- +title: Separator +description: A rule between two blocks of content or two inline items, decorative by default so a screen reader skips straight over it. +builtOn: + - name: Radix Separator + docsUrl: https://www.radix-ui.com/primitives/docs/components/separator + apiUrl: https://www.radix-ui.com/primitives/docs/components/separator#api-reference +--- + +import Horizontal from "@/demos/separator/horizontal"; +import Vertical from "@/demos/separator/vertical"; + + + + + +## Usage + +```tsx +import { Separator } from "@robomous/ui-core"; +``` + +```tsx +
batch-0041
+ +
batch-0042
+``` + +`decorative` defaults to `true`, because most separators repeat a grouping the layout already +shows; set it to `false` only when the rule is the only thing marking a real boundary for assistive +tech. + +## Composition + +```text +Separator +``` + +## Horizontal + +A horizontal separator dividing two stacked blocks of content. + + + + + +## Vertical + +`orientation="vertical"` divides inline items instead, such as the entries in a toolbar. + + + + diff --git a/examples/catalog/src/content/components/sheet.mdx b/examples/catalog/src/content/components/sheet.mdx new file mode 100644 index 0000000..568619d --- /dev/null +++ b/examples/catalog/src/content/components/sheet.mdx @@ -0,0 +1,87 @@ +--- +title: Sheet +description: A panel that slides in from an edge of the viewport, sharing Dialog's overlay, focus trap and exit animation. `side` picks the edge it opens from — right by default — and `showCloseButton` toggles its corner close affordance. +builtOn: + - name: Radix Dialog + docsUrl: https://www.radix-ui.com/primitives/docs/components/dialog + apiUrl: https://www.radix-ui.com/primitives/docs/components/dialog#api-reference +--- + +import Filters from "@/demos/sheet/filters"; +import NoCloseButton from "@/demos/sheet/no-close-button"; +import Sides from "@/demos/sheet/sides"; + + + + + +## Usage + +```tsx +import { + Button, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@robomous/ui-core"; +``` + +```tsx + + + + + + + Filters + Narrow the list to what you are looking for. + + + +``` + +`side` defaults to `right`; pass `top`, `bottom` or `left` to anchor the panel to another edge. + +## Composition + +```text +Sheet +├─ SheetTrigger +└─ SheetContent + ├─ SheetHeader + │ ├─ SheetTitle + │ └─ SheetDescription + ├─ SheetClose + └─ SheetFooter +``` + +## Sides + +`side` picks which edge the panel slides from. `top` and `bottom` span the full width; `left` +and `right` run the full height at `w-3/4` up to `sm:max-w-sm`, each with a border on its inner +edge. + + + + + +## Filters + +`SheetHeader` and `SheetFooter` frame the panel; the body between them, like this filter field, +is free-form. + + + + + +## No close button + +`showCloseButton={false}` removes the corner close affordance, for when the call site owns +dismissal through its own footer actions instead. + + + + diff --git a/examples/catalog/src/content/components/sidebar.mdx b/examples/catalog/src/content/components/sidebar.mdx new file mode 100644 index 0000000..dd21b7d --- /dev/null +++ b/examples/catalog/src/content/components/sidebar.mdx @@ -0,0 +1,131 @@ +--- +title: Sidebar +description: The application shell's navigable panel. `side`, `variant` and `collapsible` shape it, `⌘/Ctrl+B` and a cookie remember whether it is open, and it becomes a Sheet below 768px. +builtOn: + - name: Radix Slot + docsUrl: https://www.radix-ui.com/primitives/docs/utilities/slot + apiUrl: https://www.radix-ui.com/primitives/docs/utilities/slot#api-reference + - name: Radix Dialog + docsUrl: https://www.radix-ui.com/primitives/docs/components/dialog + apiUrl: https://www.radix-ui.com/primitives/docs/components/dialog#api-reference +--- + +import Anatomy from "@/demos/sidebar/anatomy"; +import Sizes from "@/demos/sidebar/sizes"; +import Outline from "@/demos/sidebar/outline"; +import Loading from "@/demos/sidebar/loading"; +import Collapsing from "@/demos/sidebar/collapsing"; +import Floating from "@/demos/sidebar/floating"; + + + + + +## Usage + +```tsx +import { Sidebar, SidebarContent, SidebarInset, SidebarProvider, SidebarTrigger } from "@robomous/ui-core"; +``` + +```tsx + + + {/* groups and menus */} + + + + {/* page content */} + + +``` + +`useSidebar` throws outside a `SidebarProvider`, so every composition starts there; the provider is +also what the `⌘/Ctrl+B` shortcut and the `sidebar_state` cookie attach to. + +## Composition + +```text +SidebarProvider +├─ Sidebar +│ ├─ SidebarHeader / SidebarFooter +│ ├─ SidebarInput +│ ├─ SidebarContent +│ │ └─ SidebarGroup +│ │ ├─ SidebarGroupLabel / SidebarGroupAction +│ │ └─ SidebarGroupContent +│ │ └─ SidebarMenu +│ │ └─ SidebarMenuItem +│ │ ├─ SidebarMenuButton +│ │ ├─ SidebarMenuAction / SidebarMenuBadge +│ │ ├─ SidebarMenuSkeleton +│ │ └─ SidebarMenuSub +│ │ └─ SidebarMenuSubItem → SidebarMenuSubButton +│ ├─ SidebarSeparator +│ └─ SidebarRail +├─ SidebarTrigger +└─ SidebarInset +``` + +`collapsible="offcanvas"` and `collapsible="icon"` render the panel `position: fixed`, which is +right for a real app shell but not for a card sitting in a document's flow. The Anatomy, Sizes, +Outline and Loading demos below sidestep that with `collapsible="none"` and a fixed-height wrapper; +Collapsing and Floating, where the fixed behaviour is the point, instead wrap the demo in a box with +`contain-layout`, which gives the panel a containing block other than the browser viewport so it +stays inside the card. + +## Anatomy + +A header input, a labelled group with its own group action, an active item wearing a badge, a +second item with a hover-revealed `SidebarMenuAction` and a `SidebarMenuSub`, and a footer row. +Every part reads its state off the same `data-*` attributes the `Sidebar` itself sets. + + + + + +## Sizes + +Three heights for `SidebarMenuButton` on the same baseline: `sm`, `default`, `lg`. + + + + + +## Outline + +`variant="outline"` rings a menu button with `--sidebar-border` instead of filling it on hover, +for an item that should read as a control rather than a destination. + + + + + +## Loading + +`SidebarMenuSkeleton` stands in for a row before its label is known; `showIcon` reserves the space +the icon will take once it lands. + + + + + +## Collapsing + +`collapsible="icon"` shrinks the panel to `--sidebar-width-icon` and keeps only the icons; +`collapsible="offcanvas"`, the default, hides it outright instead. `⌘/Ctrl+B`, the `SidebarTrigger` +button and `SidebarRail` all call the same `toggleSidebar`, and the choice persists in the +`sidebar_state` cookie so a reload keeps it. `SidebarInset` is the `
` that only picks up a +border and margin once `variant="inset"`. + + + + + +## Floating + +`variant="floating"` lifts the panel off the page's edge into its own ringed, shadowed box instead +of sharing a border with it; `side="right"` mirrors which edge it docks to. + + + + diff --git a/examples/catalog/src/content/components/skeleton.mdx b/examples/catalog/src/content/components/skeleton.mdx new file mode 100644 index 0000000..81ca280 --- /dev/null +++ b/examples/catalog/src/content/components/skeleton.mdx @@ -0,0 +1,51 @@ +--- +title: Skeleton +description: A loading placeholder — an animate-pulse block shaped by the className you give it, standing in for content that has not arrived yet. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/HTML/Element/div +--- + +import Card from "@/demos/skeleton/card"; +import Text from "@/demos/skeleton/text"; + + + + + +## Usage + +```tsx +import { Skeleton } from "@robomous/ui-core"; +``` + +```tsx + + +``` + +`Skeleton` carries no shape of its own: every size and radius comes from the `className` you pass, +matched to the content it stands in for. + +## Composition + +```text +Skeleton +``` + +## Card + +A frame preview above two caption lines, sized to match the card it will become once its content +arrives. + + + + + +## Text + +A thumbnail beside two lines, standing in for a row of text and its avatar. + + + + diff --git a/examples/catalog/src/content/components/sonner.mdx b/examples/catalog/src/content/components/sonner.mdx new file mode 100644 index 0000000..4657570 --- /dev/null +++ b/examples/catalog/src/content/components/sonner.mdx @@ -0,0 +1,77 @@ +--- +title: Sonner +description: A toast for the outcome of something the reader just did. `Toaster` follows the `dark` class on ``, and its icons come from lucide. +builtOn: + - name: sonner + docsUrl: https://sonner.emilkowal.ski/ + apiUrl: https://sonner.emilkowal.ski/toaster +--- + +import Intents from "@/demos/sonner/intents"; +import WithDescription from "@/demos/sonner/with-description"; +import WithAction from "@/demos/sonner/with-action"; +import ExportPromise from "@/demos/sonner/promise"; + + + + + +## Usage + +```tsx +import { Toaster, toast } from "@robomous/ui-core"; +``` + +```tsx +// once, near the app root +; + +// anywhere below it +toast("Saved"); +``` + +This catalog already mounts a single `Toaster` in its layout; an app does the same, once. Every +call to `toast` from anywhere in the tree reaches that one mount point. + +## Composition + +```text +Toaster +toast() +``` + +## Intents + +Four intents beyond a plain `toast()`: `success`, `warning`, `info`, `error`. Each supplies its own +icon, and there is no `variant` prop to reach for instead — the function called is the state. + + + + + +## With a description + +`description` adds a second line under the title, for detail that does not belong in the title +itself. + + + + + +## With an action + +A button on the toast itself, reserved for the one thing worth doing right after — usually undoing +what just happened. + + + + + +## Promise + +`toast.promise` swaps a loading toast for a success or error one, decided by the promise it wraps, +so the reader is never left checking whether anything happened. + + + + diff --git a/examples/catalog/src/content/components/spinner.mdx b/examples/catalog/src/content/components/spinner.mdx new file mode 100644 index 0000000..161dbe4 --- /dev/null +++ b/examples/catalog/src/content/components/spinner.mdx @@ -0,0 +1,50 @@ +--- +title: Spinner +description: A spinning loading glyph that announces itself as Loading; there is no isPending prop on Button, so a Spinner is composed into it directly. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Roles/status_role +--- + +import Default from "@/demos/spinner/default"; +import InButton from "@/demos/spinner/in-button"; + + + + + +## Usage + +```tsx +import { Spinner } from "@robomous/ui-core"; +``` + +```tsx + +``` + +`Spinner` is a lucide glyph with `role="status"` and `aria-label="Loading"` already set: nothing +else needs announcing. + +## Composition + +```text +Spinner +``` + +## Alone + +A bare `Spinner`, sized like an icon and spinning on its own. + + + + + +## In a Button + +There is no `isPending` prop on `Button`: compose a `Spinner` into it with `data-icon="inline-start"` +and disable the button yourself, so a press cannot fire twice. + + + + diff --git a/examples/catalog/src/content/components/table.mdx b/examples/catalog/src/content/components/table.mdx new file mode 100644 index 0000000..f60e5eb --- /dev/null +++ b/examples/catalog/src/content/components/table.mdx @@ -0,0 +1,94 @@ +--- +title: Table +description: A native table, header rows separated from the body by a rule, wrapped in its own horizontal scroller so a wide dataset never squeezes the page around it. +builtOn: + - name: native HTML + docsUrl: https://developer.mozilla.org/docs/Web/HTML/Element/table +--- + +import Caption from "@/demos/table/caption"; +import Dataset from "@/demos/table/dataset"; +import EmptyState from "@/demos/table/empty-state"; +import Footer from "@/demos/table/footer"; + + + + + +## Usage + +```tsx +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@robomous/ui-core"; +``` + +```tsx + + + + Model + mAP@50 + + + + + yolo-v9-tiny + 0.912 + + +
+``` + +Right-align a numeric column's `TableHead` with `text-right`, and give its `TableCell`s the same +class plus `tabular-nums`, so the figures line up under the heading and each other. + +## Composition + +```text +Table +├─ TableCaption +├─ TableHeader +│ └─ TableRow +│ └─ TableHead +├─ TableBody +│ └─ TableRow +│ └─ TableCell +└─ TableFooter + └─ TableRow + └─ TableCell +``` + +## Wide data + +`Table` wraps itself in an `overflow-x-auto` container, so a dataset with more columns than the +viewport scrolls horizontally instead of crushing every column to fit. + + + + + +## Footer + +`TableFooter` closes the table with a muted, bordered band, for a total that summarises the rows +above rather than continuing them. + + +