@usetheo/ui 0.10.0-next.0 → 0.11.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +119 -3
- package/README.md +22 -21
- package/dist/chunk-BX7A5GUV.js +78 -0
- package/dist/chunk-BX7A5GUV.js.map +1 -0
- package/dist/{chunk-H3ANHVEL.js → chunk-DKQAHZG2.js} +4 -4
- package/dist/{chunk-H3ANHVEL.js.map → chunk-DKQAHZG2.js.map} +1 -1
- package/dist/{chunk-DAKIL5PC.js → chunk-IPEYGWA7.js} +3 -3
- package/dist/{chunk-DAKIL5PC.js.map → chunk-IPEYGWA7.js.map} +1 -1
- package/dist/chunk-IWSLOBYG.js +199 -0
- package/dist/chunk-IWSLOBYG.js.map +1 -0
- package/dist/chunk-MI5CXMZU.js +171 -0
- package/dist/chunk-MI5CXMZU.js.map +1 -0
- package/dist/chunk-QJGGTIUN.js +110 -0
- package/dist/chunk-QJGGTIUN.js.map +1 -0
- package/dist/chunk-R2PAGRDP.js +152 -0
- package/dist/chunk-R2PAGRDP.js.map +1 -0
- package/dist/{chunk-QU6RLHYH.js → chunk-TNBJ36XJ.js} +3 -3
- package/dist/{chunk-QU6RLHYH.js.map → chunk-TNBJ36XJ.js.map} +1 -1
- package/dist/components.css +1 -1
- package/dist/composites/agent-stream/index.js +3 -3
- package/dist/composites/data-table/index.js +10 -0
- package/dist/composites/data-table/index.js.map +1 -0
- package/dist/composites/page-shell/index.js +7 -0
- package/dist/composites/page-shell/index.js.map +1 -0
- package/dist/composites/rule-editor/index.js +2 -2
- package/dist/composites/skill-editor/index.js +2 -2
- package/dist/index.d.ts +281 -12
- package/dist/index.js +47 -42
- package/dist/index.js.map +1 -1
- package/dist/primitives/action-bar/index.js +4 -0
- package/dist/primitives/action-bar/index.js.map +1 -0
- package/dist/primitives/dropdown-menu/index.js +4 -0
- package/dist/primitives/dropdown-menu/index.js.map +1 -0
- package/dist/primitives/pin-input/index.js +4 -0
- package/dist/primitives/pin-input/index.js.map +1 -0
- package/llms.txt +4 -3
- package/package.json +63 -43
- package/registry/index.json +30 -0
- package/registry/r/action-bar.json +22 -0
- package/registry/r/data-table.json +27 -0
- package/registry/r/dropdown-menu.json +23 -0
- package/registry/r/page-shell.json +25 -0
- package/registry/r/pin-input.json +20 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
package/llms.txt
CHANGED
|
@@ -7,7 +7,7 @@ This file follows the [llms.txt convention](https://llmstxt.org/) and gives an L
|
|
|
7
7
|
## Project metadata
|
|
8
8
|
|
|
9
9
|
- **npm package:** `@usetheo/ui`
|
|
10
|
-
- **Current version:** `0.
|
|
10
|
+
- **Current version:** `0.10.0-next.0` (npm dist-tag `next`; latest release line)
|
|
11
11
|
- **License:** Apache-2.0
|
|
12
12
|
- **Repository:** `https://github.com/usetheodev/theo-ui`
|
|
13
13
|
- **Docs site:** `https://docs.usetheo.dev/theoui`
|
|
@@ -51,7 +51,7 @@ import { theoUIVitePlugin } from "@usetheo/ui/vite-plugin";
|
|
|
51
51
|
import "@usetheo/ui/preset"; // Tailwind v4 preset (zero-config)
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
There are **
|
|
54
|
+
There are **134 subpath exports** in `package.json#exports` — one per component plus engine entry points. **As of 0.10.0-next.0** (Brief #4), per-component subpath imports (`@usetheo/ui/button`) point at real per-component dist files (`dist/primitives/button/index.js`) instead of the barrel — switching to subpath form measurably shrinks the consumer's `@usetheo/ui` chunk by dropping unused components. Before 0.10, subpath imports were cosmetic (all aliased the barrel). The barrel form keeps working, additive migration shape (same as `@mui/material`).
|
|
55
55
|
|
|
56
56
|
## Peer dependencies (required vs optional)
|
|
57
57
|
|
|
@@ -103,6 +103,7 @@ Located in `src/components/composites/{slug}/`.
|
|
|
103
103
|
|
|
104
104
|
## Recent deliveries (PaaS shape)
|
|
105
105
|
|
|
106
|
+
- **0.10.0-next.0 (Brief #4 — subpath tree-shaking, build pipeline fix):** ~100 cosmetic subpath exports (every entry pointed at the same 417 KB barrel) replaced with real per-component dist files via tsup auto-glob + `splitting: true`. `dist/index.js` shrank from 417 KB → 49 KB (−88%) because all component code now lives in 119 shared chunks. Per-component dist files (~150-200 bytes each) are tiny re-export wrappers. Per-component `.d.ts` not emitted (would OOM tsup worker pool) — types resolve via barrel `dist/index.d.ts`. `scripts/regen-subpath-exports.ts` runs after every build to keep `package.json#exports` honest (refuses to write if any straggler points at the barrel). Zero breaking change; consumer migration is opt-in file-by-file. ADR at `.claude/knowledge-base/decisions/subpath-exports-per-component.md`.
|
|
106
107
|
- **0.9.0-next.0 (Brief #3 — deferred follow-ups, 2 components):** `Alert` (persistent inline notice; 4 intents — info/success/warning/destructive — with mapped lucide icons and role=alert for destructive vs role=status for others; optional title/description/action/onDismiss), `Pagination` (accessible nav landmark with first/prev/numbers/next/last + visual ellipses + aria-current=page; keyboard nav ArrowLeft/Right/Home/End; configurable siblingCount + jump buttons + size; returns null when totalPages <= 1; exports a pure `computePageRange` helper for unit-testing the range logic in isolation). Zero new peer-deps. Brief #3 consumer: TheoCloud `<VerificationBanner>` → 3-line `<Alert>`.
|
|
107
108
|
- **0.8.0-next.0 (Brief #2 — cross-cutting, 8 components):** `Table` (sub-components + sortable headers), `StatusDot` (5 kinds + auto-pulse), `CopyButton` (clipboard + `aria-live` + SSR-safe), `Timestamp` (`Intl.RelativeTimeFormat`, native `title` tooltip, Unix ms only), `StatTile` (dual button/div mode), `DangerZone` (`.Action` sub-component), `ConfirmDialog` (typed-phrase guard + async loading + Enter-to-confirm), `CodeBlock` (terminal prefix + caption + raw-code copy). Consumer: TheoCloud dashboard. Zero new peer-deps; bundle +5.4% (rebaselined).
|
|
108
109
|
- **0.7.0-next.0 (Brief #1 — PaaS-shape, 4 components):** `UsageMeter` (multi-metric, over-quota warning + clamping), `Progress` (4 intents + indeterminate + motion-reduce aware), `PlanBadge` (5 canonical tiers), `AccountMenu` (avatar + name + plan + secondary, dual button/div).
|
|
@@ -267,4 +268,4 @@ Run dev server: `pnpm dev` (Ladle on `http://localhost:61000`). Build static: `p
|
|
|
267
268
|
|
|
268
269
|
The code, the README, and the `package.json#exports` map are **authoritative**. If `llms.txt` disagrees with them, the code wins — update this file via PR with a one-line rationale. Locked names (Section "Locked names") and voice/narrative rules require a strategic review at the monorepo level before being weakened or repealed.
|
|
269
270
|
|
|
270
|
-
— Generated 2026-05-23 (last updated for 0.
|
|
271
|
+
— Generated 2026-05-23 (last updated 2026-05-25 for 0.10.0-next.0 / Brief #4 subpath tree-shaking) from filesystem inventory + `CLAUDE.md` + `README.md`. Regenerate by inspecting `src/components/{primitives,composites}/*/` directories and updating the catalog sections above.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usetheo/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0-next.0",
|
|
4
4
|
"description": "Theo UI — framework-agnostic React component library with the Violet Forge design system. Focused on AI-agent interfaces, cloud dashboards, and developer-tooling surfaces.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -12,21 +12,14 @@
|
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"import": "./dist/index.js"
|
|
14
14
|
},
|
|
15
|
-
"./styles.css": "./dist/styles.css",
|
|
16
|
-
"./styles-v3-legacy.css": "./dist/styles-v3-legacy.css",
|
|
17
|
-
"./components.css": "./dist/components.css",
|
|
18
|
-
"./tokens.css": "./dist/tokens.css",
|
|
19
|
-
"./tokens-v4.css": "./dist/tokens-v4.css",
|
|
20
|
-
"./preset.css": "./dist/preset.css",
|
|
21
|
-
"./preset": "./dist/preset.css",
|
|
22
|
-
"./fonts.css": "./dist/fonts.css",
|
|
23
|
-
"./fonts-cdn.css": "./dist/fonts-cdn.css",
|
|
24
|
-
"./slide/themes/default.css": "./dist/slide/themes/default.css",
|
|
25
|
-
"./slide/themes/violet-forge.css": "./dist/slide/themes/violet-forge.css",
|
|
26
15
|
"./account-menu": {
|
|
27
16
|
"types": "./dist/index.d.ts",
|
|
28
17
|
"import": "./dist/composites/account-menu/index.js"
|
|
29
18
|
},
|
|
19
|
+
"./action-bar": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/primitives/action-bar/index.js"
|
|
22
|
+
},
|
|
30
23
|
"./agent-composer": {
|
|
31
24
|
"types": "./dist/index.d.ts",
|
|
32
25
|
"import": "./dist/composites/agent-composer/index.js"
|
|
@@ -143,6 +136,7 @@
|
|
|
143
136
|
"types": "./dist/index.d.ts",
|
|
144
137
|
"import": "./dist/composites/command-palette/index.js"
|
|
145
138
|
},
|
|
139
|
+
"./components.css": "./dist/components.css",
|
|
146
140
|
"./confirm-dialog": {
|
|
147
141
|
"types": "./dist/index.d.ts",
|
|
148
142
|
"import": "./dist/composites/confirm-dialog/index.js"
|
|
@@ -179,6 +173,10 @@
|
|
|
179
173
|
"types": "./dist/index.d.ts",
|
|
180
174
|
"import": "./dist/primitives/danger-zone/index.js"
|
|
181
175
|
},
|
|
176
|
+
"./data-table": {
|
|
177
|
+
"types": "./dist/index.d.ts",
|
|
178
|
+
"import": "./dist/composites/data-table/index.js"
|
|
179
|
+
},
|
|
182
180
|
"./deployment-row": {
|
|
183
181
|
"types": "./dist/index.d.ts",
|
|
184
182
|
"import": "./dist/composites/deployment-row/index.js"
|
|
@@ -195,6 +193,10 @@
|
|
|
195
193
|
"types": "./dist/index.d.ts",
|
|
196
194
|
"import": "./dist/composites/domain-config/index.js"
|
|
197
195
|
},
|
|
196
|
+
"./dropdown-menu": {
|
|
197
|
+
"types": "./dist/index.d.ts",
|
|
198
|
+
"import": "./dist/primitives/dropdown-menu/index.js"
|
|
199
|
+
},
|
|
198
200
|
"./empty-state": {
|
|
199
201
|
"types": "./dist/index.d.ts",
|
|
200
202
|
"import": "./dist/primitives/empty-state/index.js"
|
|
@@ -211,6 +213,8 @@
|
|
|
211
213
|
"types": "./dist/index.d.ts",
|
|
212
214
|
"import": "./dist/primitives/folder-selector/index.js"
|
|
213
215
|
},
|
|
216
|
+
"./fonts-cdn.css": "./dist/fonts-cdn.css",
|
|
217
|
+
"./fonts.css": "./dist/fonts.css",
|
|
214
218
|
"./form-field": {
|
|
215
219
|
"types": "./dist/index.d.ts",
|
|
216
220
|
"import": "./dist/primitives/form-field/index.js"
|
|
@@ -271,6 +275,10 @@
|
|
|
271
275
|
"types": "./dist/index.d.ts",
|
|
272
276
|
"import": "./dist/primitives/model-selector/index.js"
|
|
273
277
|
},
|
|
278
|
+
"./page-shell": {
|
|
279
|
+
"types": "./dist/index.d.ts",
|
|
280
|
+
"import": "./dist/composites/page-shell/index.js"
|
|
281
|
+
},
|
|
274
282
|
"./pagination": {
|
|
275
283
|
"types": "./dist/index.d.ts",
|
|
276
284
|
"import": "./dist/primitives/pagination/index.js"
|
|
@@ -283,10 +291,20 @@
|
|
|
283
291
|
"types": "./dist/index.d.ts",
|
|
284
292
|
"import": "./dist/composites/permission-modal/index.js"
|
|
285
293
|
},
|
|
294
|
+
"./pin-input": {
|
|
295
|
+
"types": "./dist/index.d.ts",
|
|
296
|
+
"import": "./dist/primitives/pin-input/index.js"
|
|
297
|
+
},
|
|
286
298
|
"./plan-badge": {
|
|
287
299
|
"types": "./dist/index.d.ts",
|
|
288
300
|
"import": "./dist/primitives/plan-badge/index.js"
|
|
289
301
|
},
|
|
302
|
+
"./preset": "./dist/preset.css",
|
|
303
|
+
"./preset-v3-legacy": {
|
|
304
|
+
"types": "./dist/preset-v3-legacy.d.ts",
|
|
305
|
+
"import": "./dist/preset-v3-legacy.js"
|
|
306
|
+
},
|
|
307
|
+
"./preset.css": "./dist/preset.css",
|
|
290
308
|
"./preview-env-card": {
|
|
291
309
|
"types": "./dist/index.d.ts",
|
|
292
310
|
"import": "./dist/composites/preview-env-card/index.js"
|
|
@@ -383,6 +401,32 @@
|
|
|
383
401
|
"types": "./dist/index.d.ts",
|
|
384
402
|
"import": "./dist/composites/skills-list/index.js"
|
|
385
403
|
},
|
|
404
|
+
"./slide": {
|
|
405
|
+
"types": "./dist/slide/index.d.ts",
|
|
406
|
+
"import": "./dist/slide/index.js"
|
|
407
|
+
},
|
|
408
|
+
"./slide-deck": {
|
|
409
|
+
"types": "./dist/slide-deck/index.d.ts",
|
|
410
|
+
"import": "./dist/slide-deck/index.js"
|
|
411
|
+
},
|
|
412
|
+
"./slide/plugins/emoji": {
|
|
413
|
+
"types": "./dist/slide/plugins/emoji/index.d.ts",
|
|
414
|
+
"import": "./dist/slide/plugins/emoji/index.js"
|
|
415
|
+
},
|
|
416
|
+
"./slide/plugins/math": {
|
|
417
|
+
"types": "./dist/slide/plugins/math/index.d.ts",
|
|
418
|
+
"import": "./dist/slide/plugins/math/index.js"
|
|
419
|
+
},
|
|
420
|
+
"./slide/plugins/mermaid": {
|
|
421
|
+
"types": "./dist/slide/plugins/mermaid/index.d.ts",
|
|
422
|
+
"import": "./dist/slide/plugins/mermaid/index.js"
|
|
423
|
+
},
|
|
424
|
+
"./slide/plugins/shiki": {
|
|
425
|
+
"types": "./dist/slide/plugins/shiki/index.d.ts",
|
|
426
|
+
"import": "./dist/slide/plugins/shiki/index.js"
|
|
427
|
+
},
|
|
428
|
+
"./slide/themes/default.css": "./dist/slide/themes/default.css",
|
|
429
|
+
"./slide/themes/violet-forge.css": "./dist/slide/themes/violet-forge.css",
|
|
386
430
|
"./social-auth-row": {
|
|
387
431
|
"types": "./dist/index.d.ts",
|
|
388
432
|
"import": "./dist/primitives/social-auth-row/index.js"
|
|
@@ -399,6 +443,8 @@
|
|
|
399
443
|
"types": "./dist/index.d.ts",
|
|
400
444
|
"import": "./dist/primitives/steps-rail/index.js"
|
|
401
445
|
},
|
|
446
|
+
"./styles-v3-legacy.css": "./dist/styles-v3-legacy.css",
|
|
447
|
+
"./styles.css": "./dist/styles.css",
|
|
402
448
|
"./sub-agent-dispatch": {
|
|
403
449
|
"types": "./dist/index.d.ts",
|
|
404
450
|
"import": "./dist/primitives/sub-agent-dispatch/index.js"
|
|
@@ -447,6 +493,8 @@
|
|
|
447
493
|
"types": "./dist/index.d.ts",
|
|
448
494
|
"import": "./dist/primitives/token-usage-chart/index.js"
|
|
449
495
|
},
|
|
496
|
+
"./tokens-v4.css": "./dist/tokens-v4.css",
|
|
497
|
+
"./tokens.css": "./dist/tokens.css",
|
|
450
498
|
"./tool-call": {
|
|
451
499
|
"types": "./dist/index.d.ts",
|
|
452
500
|
"import": "./dist/primitives/tool-call/index.js"
|
|
@@ -475,41 +523,13 @@
|
|
|
475
523
|
"types": "./dist/index.d.ts",
|
|
476
524
|
"import": "./dist/composites/usage-meter/index.js"
|
|
477
525
|
},
|
|
478
|
-
"./whiteboard": {
|
|
479
|
-
"types": "./dist/whiteboard/index.d.ts",
|
|
480
|
-
"import": "./dist/whiteboard/index.js"
|
|
481
|
-
},
|
|
482
|
-
"./slide": {
|
|
483
|
-
"types": "./dist/slide/index.d.ts",
|
|
484
|
-
"import": "./dist/slide/index.js"
|
|
485
|
-
},
|
|
486
|
-
"./slide/plugins/shiki": {
|
|
487
|
-
"types": "./dist/slide/plugins/shiki/index.d.ts",
|
|
488
|
-
"import": "./dist/slide/plugins/shiki/index.js"
|
|
489
|
-
},
|
|
490
|
-
"./slide/plugins/math": {
|
|
491
|
-
"types": "./dist/slide/plugins/math/index.d.ts",
|
|
492
|
-
"import": "./dist/slide/plugins/math/index.js"
|
|
493
|
-
},
|
|
494
|
-
"./slide/plugins/mermaid": {
|
|
495
|
-
"types": "./dist/slide/plugins/mermaid/index.d.ts",
|
|
496
|
-
"import": "./dist/slide/plugins/mermaid/index.js"
|
|
497
|
-
},
|
|
498
|
-
"./slide/plugins/emoji": {
|
|
499
|
-
"types": "./dist/slide/plugins/emoji/index.d.ts",
|
|
500
|
-
"import": "./dist/slide/plugins/emoji/index.js"
|
|
501
|
-
},
|
|
502
|
-
"./slide-deck": {
|
|
503
|
-
"types": "./dist/slide-deck/index.d.ts",
|
|
504
|
-
"import": "./dist/slide-deck/index.js"
|
|
505
|
-
},
|
|
506
526
|
"./vite-plugin": {
|
|
507
527
|
"types": "./dist/vite-plugin.d.ts",
|
|
508
528
|
"import": "./dist/vite-plugin.js"
|
|
509
529
|
},
|
|
510
|
-
"./
|
|
511
|
-
"types": "./dist/
|
|
512
|
-
"import": "./dist/
|
|
530
|
+
"./whiteboard": {
|
|
531
|
+
"types": "./dist/whiteboard/index.d.ts",
|
|
532
|
+
"import": "./dist/whiteboard/index.js"
|
|
513
533
|
}
|
|
514
534
|
},
|
|
515
535
|
"files": [
|
package/registry/index.json
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
"title": "AccountMenu",
|
|
19
19
|
"description": "Sidebar header for PaaS surfaces. Avatar + name + (optional) PlanBadge + (optional) secondary line, with dual mode: with onClick renders as a `button` with trailing chevron (account picker affordance); without, renders as a static `div`. PaaS-shape sibling of ProjectSwitcher."
|
|
20
20
|
},
|
|
21
|
+
{
|
|
22
|
+
"name": "action-bar",
|
|
23
|
+
"type": "registry:ui",
|
|
24
|
+
"title": "ActionBar",
|
|
25
|
+
"description": "Page-top action strip primitive. Three optional slots: search input (flex-1, grows to fill), filter icon button, and primary action button (right-aligned). Returns null when no slots are provided. Used standalone or composed inside <PageShell>. Primary action supports loading state with spinner."
|
|
26
|
+
},
|
|
21
27
|
{
|
|
22
28
|
"name": "agent-composer",
|
|
23
29
|
"type": "registry:ui",
|
|
@@ -264,6 +270,12 @@
|
|
|
264
270
|
"title": "DangerZone",
|
|
265
271
|
"description": "Destructive-actions section primitive with sub-component DangerZone.Action. Red-bordered container with title bar (default 'Danger Zone') and action rows. Each row carries title + description + consumer-provided action slot (typically a destructive Button). Rows separated by hairline dividers; last row drops the bottom border via last:border-b-0."
|
|
266
272
|
},
|
|
273
|
+
{
|
|
274
|
+
"name": "data-table",
|
|
275
|
+
"type": "registry:ui",
|
|
276
|
+
"title": "DataTable",
|
|
277
|
+
"description": "Generic, sortable, expandable composite over <Table>. Operator-grade entity-list patterns: sortable headers (controlled OR uncontrolled), sticky header, expandable rows (multi default + expandMode='single' opt-in), row action menus via DropdownMenu, client-side pagination, loading skeleton, empty state. Generic over T (e.g. DataTable<Domain>)."
|
|
278
|
+
},
|
|
267
279
|
{
|
|
268
280
|
"name": "deployment-row",
|
|
269
281
|
"type": "registry:block",
|
|
@@ -288,6 +300,12 @@
|
|
|
288
300
|
"title": "DomainConfig",
|
|
289
301
|
"description": "Manage custom domains for a project."
|
|
290
302
|
},
|
|
303
|
+
{
|
|
304
|
+
"name": "dropdown-menu",
|
|
305
|
+
"type": "registry:ui",
|
|
306
|
+
"title": "DropdownMenu",
|
|
307
|
+
"description": "Accessible dropdown menu primitive built on @radix-ui/react-dropdown-menu. Sub-components attached via Object.assign (Trigger, Content, Item, CheckboxItem, RadioItem, Label, Separator, Shortcut, Group, Sub, SubTrigger, SubContent, RadioGroup). Styled with @usetheo/ui design tokens. Consolidates the 5 prior direct-Radix usages under a single wrapper."
|
|
308
|
+
},
|
|
291
309
|
{
|
|
292
310
|
"name": "empty-state",
|
|
293
311
|
"type": "registry:ui",
|
|
@@ -414,6 +432,12 @@
|
|
|
414
432
|
"title": "ModelSelector",
|
|
415
433
|
"description": "Chip dropdown for picking the active LLM."
|
|
416
434
|
},
|
|
435
|
+
{
|
|
436
|
+
"name": "page-shell",
|
|
437
|
+
"type": "registry:ui",
|
|
438
|
+
"title": "PageShell",
|
|
439
|
+
"description": "Page-level scaffold composite. Renders title + optional description + optional ActionBar (when search/primaryAction/onFilterClick provided), then one of four mutually-exclusive content states (loading > error > empty > children). Loading defaults to a centered spinner Card; loadingNode escape hatch lets consumers pass custom skeleton. Error state renders Card with message + optional retry button + optional docsHref. Empty state delegates to <EmptyState>. PageShell does NOT manage document.title — pass onTitleChange callback to wire your own hook."
|
|
440
|
+
},
|
|
417
441
|
{
|
|
418
442
|
"name": "pagination",
|
|
419
443
|
"type": "registry:ui",
|
|
@@ -438,6 +462,12 @@
|
|
|
438
462
|
"title": "Theo UI permission types",
|
|
439
463
|
"description": "Shared TypeScript types for permission requests, scopes, and decisions."
|
|
440
464
|
},
|
|
465
|
+
{
|
|
466
|
+
"name": "pin-input",
|
|
467
|
+
"type": "registry:ui",
|
|
468
|
+
"title": "PinInput",
|
|
469
|
+
"description": "Multi-slot OTP / code input primitive. N separate boxes (default 6) that auto-advance focus on input. Paste fills all slots from clipboard with whitespace stripped. Arrow keys navigate; backspace clears current slot then moves focus back when empty. numeric / alphanumeric inputMode (default numeric, triggers mobile numeric keyboard via pattern=[0-9]*). Optional mask renders bullets. Optional error state applies destructive border. onComplete fires once when value reaches length — NOT on mount with pre-filled value."
|
|
470
|
+
},
|
|
441
471
|
{
|
|
442
472
|
"name": "plan-badge",
|
|
443
473
|
"type": "registry:ui",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "action-bar",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "ActionBar",
|
|
6
|
+
"description": "Page-top action strip primitive. Three optional slots: search input (flex-1, grows to fill), filter icon button, and primary action button (right-aligned). Returns null when no slots are provided. Used standalone or composed inside <PageShell>. Primary action supports loading state with spinner.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
12
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/primitives/action-bar/action-bar.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/action-bar.tsx",
|
|
19
|
+
"content": "import { Filter, Loader2, Search } from \"lucide-react\";\nimport { forwardRef } from \"react\";\nimport type { ElementType, HTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * ActionBar — page-top action strip primitive.\n *\n * A horizontal flexbox row with three optional slots:\n * - Search input (flex-1, grows to fill)\n * - Filter icon button (next to search)\n * - Primary action button (right-aligned)\n *\n * Returns `null` when no props are provided — don't render an empty\n * bar. Used standalone or composed inside `<PageShell>` (Brief #5).\n *\n * @example\n * <ActionBar\n * search={{ placeholder: \"Search projects…\", value: q, onChange: setQ }}\n * primaryAction={{ label: \"New project\", icon: Plus, onClick: openModal }}\n * />\n */\n\nexport interface ActionBarProps extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n search?: {\n placeholder: string;\n value: string;\n onChange: (value: string) => void;\n };\n primaryAction?: {\n label: ReactNode;\n icon?: ElementType;\n onClick: () => void;\n loading?: boolean;\n };\n onFilterClick?: () => void;\n}\n\nconst ActionBar = forwardRef<HTMLDivElement, ActionBarProps>(\n ({ className, search, primaryAction, onFilterClick, ...props }, ref) => {\n if (!search && !primaryAction && !onFilterClick) {\n return null;\n }\n\n const PrimaryIcon = primaryAction?.icon;\n const isLoading = primaryAction?.loading === true;\n\n return (\n <div ref={ref} className={cn(\"flex w-full items-center gap-2\", className)} {...props}>\n {search ? (\n <div className=\"relative flex-1\">\n <Search\n aria-hidden=\"true\"\n className=\"-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground\"\n />\n <input\n type=\"search\"\n placeholder={search.placeholder}\n value={search.value}\n onChange={(e) => search.onChange(e.target.value)}\n className={cn(\n \"w-full rounded-md border border-border/40 bg-card py-2 pr-3 pl-9\",\n \"font-sans text-body-sm text-foreground placeholder:text-muted-foreground\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n )}\n />\n </div>\n ) : null}\n {onFilterClick !== undefined ? (\n <button\n type=\"button\"\n onClick={onFilterClick}\n aria-label=\"Filter\"\n className={cn(\n \"inline-flex size-9 items-center justify-center rounded-md border border-border/40\",\n \"text-muted-foreground transition-colors\",\n \"hover:bg-muted hover:text-foreground\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n )}\n >\n <Filter aria-hidden=\"true\" className=\"size-4\" />\n </button>\n ) : null}\n {primaryAction !== undefined ? (\n <button\n type=\"button\"\n onClick={primaryAction.onClick}\n disabled={isLoading}\n className={cn(\n \"ml-auto inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2\",\n \"font-medium font-sans text-body-sm text-primary-foreground\",\n \"transition-colors hover:bg-primary-deep\",\n \"disabled:cursor-not-allowed disabled:opacity-60\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n )}\n >\n {isLoading ? (\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n ) : PrimaryIcon ? (\n <PrimaryIcon aria-hidden=\"true\" className=\"size-4\" />\n ) : null}\n {primaryAction.label}\n </button>\n ) : null}\n </div>\n );\n },\n);\nActionBar.displayName = \"ActionBar\";\n\nexport { ActionBar };\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "data-table",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "DataTable",
|
|
6
|
+
"description": "Generic, sortable, expandable composite over <Table>. Operator-grade entity-list patterns: sortable headers (controlled OR uncontrolled), sticky header, expandable rows (multi default + expandMode='single' opt-in), row action menus via DropdownMenu, client-side pagination, loading skeleton, empty state. Generic over T (e.g. DataTable<Domain>).",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
12
|
+
"https://usetheodev.github.io/theo-ui/r/table.json",
|
|
13
|
+
"https://usetheodev.github.io/theo-ui/r/pagination.json",
|
|
14
|
+
"https://usetheodev.github.io/theo-ui/r/skeleton.json",
|
|
15
|
+
"https://usetheodev.github.io/theo-ui/r/empty-state.json",
|
|
16
|
+
"https://usetheodev.github.io/theo-ui/r/dropdown-menu.json",
|
|
17
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "components/composites/data-table/data-table.tsx",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"target": "components/ui/data-table.tsx",
|
|
24
|
+
"content": "import { ChevronDown, ChevronRight, MoreHorizontal } from \"lucide-react\";\nimport { Fragment, useMemo, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { DropdownMenu } from \"@/components/ui/dropdown-menu\";\nimport { EmptyState } from \"@/components/ui/empty-state\";\nimport { Pagination } from \"@/components/ui/pagination\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Table } from \"@/components/ui/table\";\n\n/**\n * DataTable — generic, sortable, expandable composite over `<Table>`.\n *\n * Adds operator-grade entity-list patterns on top of the plain Table\n * primitive: sortable headers, sticky header, expandable rows\n * (multi-row by default), row action menus (Dropdown), client-side\n * pagination, loading skeleton rows, empty state. Both sort and\n * pagination support controlled OR uncontrolled mode (consumer\n * passes onSortChange / onPageChange to take over state).\n *\n * @example\n * <DataTable\n * columns={[\n * { key: \"name\", label: \"Name\", sortable: true },\n * { key: \"status\", label: \"Status\" },\n * ]}\n * data={domains}\n * rowKey={(d) => d.id}\n * expandable={(d) => d.status === \"pending\" ? <DnsRecords domain={d} /> : null}\n * rowActions={(d) => (\n * <>\n * <DropdownMenu.Item onSelect={() => editDomain(d)}>Edit</DropdownMenu.Item>\n * <DropdownMenu.Item onSelect={() => deleteDomain(d)}>Delete</DropdownMenu.Item>\n * </>\n * )}\n * />\n */\nexport interface DataTableColumn<T> {\n key: string;\n label: ReactNode;\n align?: \"left\" | \"center\" | \"right\";\n sortable?: boolean;\n width?: string;\n render?: (row: T) => ReactNode;\n className?: string;\n}\n\nexport interface DataTableSort {\n key: string;\n direction: \"asc\" | \"desc\";\n}\n\nexport interface DataTableProps<T> {\n data: T[];\n columns: DataTableColumn<T>[];\n rowKey: (row: T) => string;\n stickyHeader?: boolean;\n expandable?: (row: T) => ReactNode | null;\n expandMode?: \"single\" | \"multiple\";\n rowActions?: (row: T) => ReactNode;\n pagination?: {\n pageSize: number;\n controlledPage?: number;\n onPageChange?: (page: number) => void;\n } | null;\n defaultSort?: DataTableSort;\n sort?: DataTableSort | null;\n onSortChange?: (sort: DataTableSort | null) => void;\n loading?: boolean;\n emptyState?: ReactNode;\n className?: string;\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a === b) return 0;\n if (a === null || a === undefined) return -1;\n if (b === null || b === undefined) return 1;\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n return String(a).localeCompare(String(b));\n}\n\nfunction DataTable<T>(props: DataTableProps<T>): ReactNode {\n const {\n data,\n columns,\n rowKey,\n stickyHeader = true,\n expandable,\n expandMode = \"multiple\",\n rowActions,\n pagination,\n defaultSort,\n sort: controlledSort,\n onSortChange,\n loading = false,\n emptyState,\n className,\n } = props;\n\n const isControlledSort = onSortChange !== undefined;\n const [uncontrolledSort, setUncontrolledSort] = useState<DataTableSort | null>(\n defaultSort ?? null,\n );\n const sort = isControlledSort ? (controlledSort ?? null) : uncontrolledSort;\n\n const isControlledPage = pagination?.controlledPage !== undefined;\n const [uncontrolledPage, setUncontrolledPage] = useState(0);\n const currentPage = isControlledPage ? (pagination?.controlledPage ?? 0) : uncontrolledPage;\n\n // EC-9: clamp pageSize to >= 1 to avoid divide-by-zero / infinite render\n const effectivePageSize = Math.max(1, pagination?.pageSize ?? 10);\n\n const [expanded, setExpanded] = useState<Set<string>>(new Set());\n\n function handleSort(columnKey: string) {\n // Cycle: none → asc → desc → none\n let nextSort: DataTableSort | null;\n if (sort?.key !== columnKey) {\n nextSort = { key: columnKey, direction: \"asc\" };\n } else if (sort.direction === \"asc\") {\n nextSort = { key: columnKey, direction: \"desc\" };\n } else {\n nextSort = null;\n }\n if (isControlledSort) {\n onSortChange?.(nextSort);\n } else {\n setUncontrolledSort(nextSort);\n // EC-8: sort change resets pagination to page 0\n if (!isControlledPage) setUncontrolledPage(0);\n }\n }\n\n function handlePageChange(page: number) {\n // Pagination uses 1-indexed; internal state 0-indexed\n const zeroIdx = page - 1;\n if (isControlledPage) {\n pagination?.onPageChange?.(zeroIdx);\n } else {\n setUncontrolledPage(zeroIdx);\n }\n }\n\n function toggleExpand(key: string) {\n if (expandMode === \"single\") {\n setExpanded((prev) => (prev.has(key) ? new Set() : new Set([key])));\n } else {\n setExpanded((prev) => {\n const next = new Set(prev);\n if (next.has(key)) {\n next.delete(key);\n } else {\n next.add(key);\n }\n return next;\n });\n }\n }\n\n // Apply client-side sort in uncontrolled mode\n const sortedData = useMemo(() => {\n if (isControlledSort || sort === null) return data;\n const col = columns.find((c) => c.key === sort.key);\n if (!col) return data;\n const sorted = [...data].sort((a, b) => {\n const aVal = col.render\n ? null\n : (a as Record<string, unknown>)[sort.key as keyof T as string];\n const bVal = col.render\n ? null\n : (b as Record<string, unknown>)[sort.key as keyof T as string];\n const cmp = compareValues(aVal, bVal);\n return sort.direction === \"asc\" ? cmp : -cmp;\n });\n return sorted;\n }, [data, sort, isControlledSort, columns]);\n\n // Apply client-side pagination in uncontrolled mode\n const visibleData = useMemo(() => {\n if (!pagination) return sortedData;\n if (isControlledPage) return sortedData; // consumer pre-sliced\n const start = currentPage * effectivePageSize;\n return sortedData.slice(start, start + effectivePageSize);\n }, [sortedData, pagination, isControlledPage, currentPage, effectivePageSize]);\n\n // EC-1 fix: compute colSpan accounting for chevron + actions columns\n const extraCols = (expandable ? 1 : 0) + (rowActions ? 1 : 0);\n const expandedColSpan = columns.length + extraCols;\n const totalCols = columns.length + extraCols;\n\n // Loading state (EC-7: loading > empty)\n if (loading) {\n return (\n <div className={cn(\"w-full\", className)}>\n <Table>\n <Table.Header className={stickyHeader ? \"sticky top-0 bg-card\" : undefined}>\n <Table.Row>\n {expandable ? <Table.HeaderCell aria-label=\"Expand\" /> : null}\n {columns.map((col) => (\n <Table.HeaderCell key={col.key} align={col.align}>\n {col.label}\n </Table.HeaderCell>\n ))}\n {rowActions ? <Table.HeaderCell aria-label=\"Actions\" /> : null}\n </Table.Row>\n </Table.Header>\n <Table.Body>\n {Array.from({ length: 5 }, (_, i) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows are positional placeholders\n <Table.Row key={`skeleton-${i}`}>\n {Array.from({ length: totalCols }, (_, j) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: skeleton cells are positional placeholders\n <Table.Cell key={`s-${i}-${j}`}>\n <Skeleton className=\"h-4 w-full\" />\n </Table.Cell>\n ))}\n </Table.Row>\n ))}\n </Table.Body>\n </Table>\n </div>\n );\n }\n\n // Empty state (after loading check)\n if (sortedData.length === 0) {\n return (\n <div className={cn(\"w-full\", className)}>\n {emptyState ?? <EmptyState title=\"No data\" description=\"There's nothing here yet.\" />}\n </div>\n );\n }\n\n const totalPages = pagination ? Math.ceil(sortedData.length / effectivePageSize) : 1;\n\n return (\n <div className={cn(\"w-full\", className)}>\n <Table>\n <Table.Header className={stickyHeader ? \"sticky top-0 z-10 bg-card\" : undefined}>\n <Table.Row>\n {expandable ? <Table.HeaderCell aria-label=\"Expand\" /> : null}\n {columns.map((col) => {\n const isSortable = col.sortable === true;\n const isActive = sort?.key === col.key;\n return (\n <Table.HeaderCell\n key={col.key}\n align={col.align}\n onSort={isSortable ? () => handleSort(col.key) : undefined}\n sortDirection={isSortable ? (isActive ? sort?.direction : \"none\") : undefined}\n style={col.width ? { width: col.width } : undefined}\n >\n {col.label}\n </Table.HeaderCell>\n );\n })}\n {rowActions ? <Table.HeaderCell aria-label=\"Actions\" /> : null}\n </Table.Row>\n </Table.Header>\n <Table.Body>\n {visibleData.map((row) => {\n const key = rowKey(row);\n const expandedContent = expandable ? expandable(row) : null;\n const isExpandable = expandedContent !== null && expandedContent !== undefined;\n const isExpanded = expanded.has(key);\n return (\n <Fragment key={key}>\n <Table.Row>\n {expandable ? (\n <Table.Cell>\n {isExpandable ? (\n <button\n type=\"button\"\n onClick={() => toggleExpand(key)}\n aria-expanded={isExpanded}\n aria-controls={`expanded-${key}`}\n aria-label={isExpanded ? \"Collapse row\" : \"Expand row\"}\n className=\"inline-flex items-center justify-center rounded-md p-0.5 hover:bg-muted\"\n >\n {isExpanded ? (\n <ChevronDown aria-hidden=\"true\" className=\"size-4\" />\n ) : (\n <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n )}\n </button>\n ) : null}\n </Table.Cell>\n ) : null}\n {columns.map((col) => (\n <Table.Cell key={col.key} align={col.align} className={col.className}>\n {col.render\n ? col.render(row)\n : String((row as Record<string, unknown>)[col.key] ?? \"\")}\n </Table.Cell>\n ))}\n {rowActions ? (\n <Table.Cell align=\"right\">\n <DropdownMenu>\n <DropdownMenu.Trigger\n aria-label=\"Row actions\"\n className={cn(\n \"inline-flex size-7 items-center justify-center rounded-md\",\n \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n )}\n >\n <MoreHorizontal aria-hidden=\"true\" className=\"size-4\" />\n </DropdownMenu.Trigger>\n <DropdownMenu.Content align=\"end\">{rowActions(row)}</DropdownMenu.Content>\n </DropdownMenu>\n </Table.Cell>\n ) : null}\n </Table.Row>\n {isExpanded && isExpandable ? (\n <tr id={`expanded-${key}`}>\n <td colSpan={expandedColSpan} className=\"bg-muted/30 p-4\">\n {expandedContent}\n </td>\n </tr>\n ) : null}\n </Fragment>\n );\n })}\n </Table.Body>\n </Table>\n {pagination && totalPages > 1 ? (\n <div className=\"mt-4 flex items-center justify-end\">\n <Pagination\n currentPage={currentPage + 1}\n totalPages={totalPages}\n onPageChange={handlePageChange}\n />\n </div>\n ) : null}\n </div>\n );\n}\n\nexport { DataTable };\n"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "dropdown-menu",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "DropdownMenu",
|
|
6
|
+
"description": "Accessible dropdown menu primitive built on @radix-ui/react-dropdown-menu. Sub-components attached via Object.assign (Trigger, Content, Item, CheckboxItem, RadioItem, Label, Separator, Shortcut, Group, Sub, SubTrigger, SubContent, RadioGroup). Styled with @usetheo/ui design tokens. Consolidates the 5 prior direct-Radix usages under a single wrapper.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"@radix-ui/react-dropdown-menu",
|
|
9
|
+
"lucide-react"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
13
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/primitives/dropdown-menu/dropdown-menu.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": "components/ui/dropdown-menu.tsx",
|
|
20
|
+
"content": "import * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { Check, ChevronRight, Circle } from \"lucide-react\";\nimport { forwardRef } from \"react\";\nimport type { ComponentPropsWithoutRef, ElementRef, HTMLAttributes } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * DropdownMenu — accessible menu primitive built on Radix.\n *\n * Composition (single import surface):\n * <DropdownMenu>\n * <DropdownMenu.Trigger>…</DropdownMenu.Trigger>\n * <DropdownMenu.Content>\n * <DropdownMenu.Label>Section</DropdownMenu.Label>\n * <DropdownMenu.Item onSelect={…}>Edit</DropdownMenu.Item>\n * <DropdownMenu.Separator />\n * <DropdownMenu.Item disabled>Delete</DropdownMenu.Item>\n * </DropdownMenu.Content>\n * </DropdownMenu>\n *\n * The primitive consolidates 5 prior direct-Radix usages\n * (`model-selector`, `intent-selector`, `agent-profile`,\n * `theme-switcher`, `theo-code-shell`) under a single styled\n * wrapper so consumers get consistent visuals + the design tokens.\n *\n * a11y note for tests: Radix's focus-guard spans intentionally\n * violate `aria-hidden-focus`. Tests should pass\n * `{ rules: { \"aria-hidden-focus\": { enabled: false } } }` to axe.\n */\n\nconst Trigger = DropdownMenuPrimitive.Trigger;\nconst Portal = DropdownMenuPrimitive.Portal;\nconst Group = DropdownMenuPrimitive.Group;\nconst Sub = DropdownMenuPrimitive.Sub;\nconst RadioGroup = DropdownMenuPrimitive.RadioGroup;\n\nconst Content = forwardRef<\n ElementRef<typeof DropdownMenuPrimitive.Content>,\n ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n <DropdownMenuPrimitive.Portal>\n <DropdownMenuPrimitive.Content\n ref={ref}\n sideOffset={sideOffset}\n className={cn(\n \"z-50 min-w-32 overflow-hidden rounded-lg border border-border/40 bg-card p-1\",\n \"text-card-foreground shadow-md\",\n \"data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:animate-in\",\n \"data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out\",\n className,\n )}\n {...props}\n />\n </DropdownMenuPrimitive.Portal>\n));\nContent.displayName = \"DropdownMenu.Content\";\n\ninterface ItemProps extends ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> {\n inset?: boolean;\n}\n\nconst Item = forwardRef<ElementRef<typeof DropdownMenuPrimitive.Item>, ItemProps>(\n ({ className, inset, ...props }, ref) => (\n <DropdownMenuPrimitive.Item\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5\",\n \"font-sans text-body-sm text-foreground outline-none\",\n \"transition-colors\",\n \"focus:bg-muted focus:text-foreground\",\n \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n inset && \"pl-8\",\n className,\n )}\n {...props}\n />\n ),\n);\nItem.displayName = \"DropdownMenu.Item\";\n\nconst CheckboxItem = forwardRef<\n ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n <DropdownMenuPrimitive.CheckboxItem\n ref={ref}\n checked={checked}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-md py-1.5 pr-2 pl-8\",\n \"font-sans text-body-sm text-foreground outline-none\",\n \"transition-colors focus:bg-muted focus:text-foreground\",\n \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n className,\n )}\n {...props}\n >\n <span className=\"absolute left-2 flex size-3.5 items-center justify-center\">\n <DropdownMenuPrimitive.ItemIndicator>\n <Check aria-hidden=\"true\" className=\"size-4\" />\n </DropdownMenuPrimitive.ItemIndicator>\n </span>\n {children}\n </DropdownMenuPrimitive.CheckboxItem>\n));\nCheckboxItem.displayName = \"DropdownMenu.CheckboxItem\";\n\nconst RadioItem = forwardRef<\n ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n <DropdownMenuPrimitive.RadioItem\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-md py-1.5 pr-2 pl-8\",\n \"font-sans text-body-sm text-foreground outline-none\",\n \"transition-colors focus:bg-muted focus:text-foreground\",\n \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n className,\n )}\n {...props}\n >\n <span className=\"absolute left-2 flex size-3.5 items-center justify-center\">\n <DropdownMenuPrimitive.ItemIndicator>\n <Circle aria-hidden=\"true\" className=\"size-2 fill-current\" />\n </DropdownMenuPrimitive.ItemIndicator>\n </span>\n {children}\n </DropdownMenuPrimitive.RadioItem>\n));\nRadioItem.displayName = \"DropdownMenu.RadioItem\";\n\ninterface LabelProps extends ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> {\n inset?: boolean;\n}\n\nconst Label = forwardRef<ElementRef<typeof DropdownMenuPrimitive.Label>, LabelProps>(\n ({ className, inset, ...props }, ref) => (\n <DropdownMenuPrimitive.Label\n ref={ref}\n className={cn(\n \"px-2 py-1.5 font-medium font-sans text-label-caps text-muted-foreground uppercase tracking-wider\",\n inset && \"pl-8\",\n className,\n )}\n {...props}\n />\n ),\n);\nLabel.displayName = \"DropdownMenu.Label\";\n\nconst Separator = forwardRef<\n ElementRef<typeof DropdownMenuPrimitive.Separator>,\n ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n <DropdownMenuPrimitive.Separator\n ref={ref}\n className={cn(\"-mx-1 my-1 h-px bg-border/40\", className)}\n {...props}\n />\n));\nSeparator.displayName = \"DropdownMenu.Separator\";\n\nconst Shortcut = ({ className, ...props }: HTMLAttributes<HTMLSpanElement>) => (\n <span\n className={cn(\"ml-auto font-mono text-label text-muted-foreground\", className)}\n {...props}\n />\n);\nShortcut.displayName = \"DropdownMenu.Shortcut\";\n\nconst SubTrigger = forwardRef<\n ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }\n>(({ className, inset, children, ...props }, ref) => (\n <DropdownMenuPrimitive.SubTrigger\n ref={ref}\n className={cn(\n \"flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5\",\n \"font-sans text-body-sm text-foreground outline-none\",\n \"focus:bg-muted data-[state=open]:bg-muted\",\n inset && \"pl-8\",\n className,\n )}\n {...props}\n >\n {children}\n <ChevronRight aria-hidden=\"true\" className=\"ml-auto size-3.5\" />\n </DropdownMenuPrimitive.SubTrigger>\n));\nSubTrigger.displayName = \"DropdownMenu.SubTrigger\";\n\nconst SubContent = forwardRef<\n ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n <DropdownMenuPrimitive.SubContent\n ref={ref}\n className={cn(\n \"z-50 min-w-32 overflow-hidden rounded-lg border border-border/40 bg-card p-1\",\n \"text-card-foreground shadow-md\",\n \"data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:animate-in\",\n \"data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out\",\n className,\n )}\n {...props}\n />\n));\nSubContent.displayName = \"DropdownMenu.SubContent\";\n\ntype DropdownMenuRoot = typeof DropdownMenuPrimitive.Root & {\n Trigger: typeof Trigger;\n Portal: typeof Portal;\n Content: typeof Content;\n Item: typeof Item;\n CheckboxItem: typeof CheckboxItem;\n RadioItem: typeof RadioItem;\n Label: typeof Label;\n Separator: typeof Separator;\n Shortcut: typeof Shortcut;\n Group: typeof Group;\n Sub: typeof Sub;\n SubTrigger: typeof SubTrigger;\n SubContent: typeof SubContent;\n RadioGroup: typeof RadioGroup;\n};\n\nconst DropdownMenu: DropdownMenuRoot = Object.assign(DropdownMenuPrimitive.Root, {\n Trigger,\n Portal,\n Content,\n Item,\n CheckboxItem,\n RadioItem,\n Label,\n Separator,\n Shortcut,\n Group,\n Sub,\n SubTrigger,\n SubContent,\n RadioGroup,\n});\n\nexport { DropdownMenu };\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "page-shell",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "PageShell",
|
|
6
|
+
"description": "Page-level scaffold composite. Renders title + optional description + optional ActionBar (when search/primaryAction/onFilterClick provided), then one of four mutually-exclusive content states (loading > error > empty > children). Loading defaults to a centered spinner Card; loadingNode escape hatch lets consumers pass custom skeleton. Error state renders Card with message + optional retry button + optional docsHref. Empty state delegates to <EmptyState>. PageShell does NOT manage document.title — pass onTitleChange callback to wire your own hook.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
12
|
+
"https://usetheodev.github.io/theo-ui/r/action-bar.json",
|
|
13
|
+
"https://usetheodev.github.io/theo-ui/r/card.json",
|
|
14
|
+
"https://usetheodev.github.io/theo-ui/r/empty-state.json",
|
|
15
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/composites/page-shell/page-shell.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"target": "components/ui/page-shell.tsx",
|
|
22
|
+
"content": "import { AlertCircle, Loader2 } from \"lucide-react\";\nimport { forwardRef, useEffect } from \"react\";\nimport type { ElementType, ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ActionBar } from \"@/components/ui/action-bar\";\nimport { Card } from \"@/components/ui/card\";\nimport { EmptyState } from \"@/components/ui/empty-state\";\n\n/**\n * PageShell — page-level scaffold composite.\n *\n * Renders title + optional description + optional ActionBar, then\n * one of four mutually-exclusive content states:\n * 1. loading (highest precedence)\n * 2. error\n * 3. empty\n * 4. children (default)\n *\n * Scope-narrowed per Brief #5 D3: PageShell does NOT manage\n * `document.title`. Use the optional `onTitleChange` callback to\n * wire your own hook (e.g. useSetPageTitle, react-helmet,\n * next/head).\n *\n * @example\n * <PageShell\n * title=\"Domains\"\n * description=\"Custom domains and DNS verification.\"\n * search={{ placeholder: \"Search…\", value: q, onChange: setQ }}\n * primaryAction={{ label: \"Add domain\", icon: Plus, onClick: openModal }}\n * loading={isLoading}\n * error={error ? { message: error.message, onRetry: refetch } : undefined}\n * empty={data?.length === 0 ? { title: \"No domains yet\" } : undefined}\n * >\n * <DataTable columns={…} data={data} />\n * </PageShell>\n */\nexport interface PageShellProps {\n title: string;\n description?: ReactNode;\n /** Optional callback invoked when `title` changes — wire to your own document.title hook. */\n onTitleChange?: (title: string) => void;\n primaryAction?: {\n label: ReactNode;\n icon?: ElementType;\n onClick: () => void;\n loading?: boolean;\n };\n search?: {\n placeholder: string;\n value: string;\n onChange: (v: string) => void;\n };\n onFilterClick?: () => void;\n loading?: boolean;\n /** Custom loading UI. Defaults to a centered spinner card. */\n loadingNode?: ReactNode;\n error?: {\n message: string;\n onRetry?: () => void;\n docsHref?: string;\n };\n empty?: {\n icon?: ElementType;\n title: string;\n description?: ReactNode;\n action?: { label: string; onClick: () => void };\n };\n children?: ReactNode;\n className?: string;\n}\n\nconst PageShell = forwardRef<HTMLElement, PageShellProps>(\n (\n {\n title,\n description,\n onTitleChange,\n primaryAction,\n search,\n onFilterClick,\n loading = false,\n loadingNode,\n error,\n empty,\n children,\n className,\n },\n ref,\n ) => {\n useEffect(() => {\n onTitleChange?.(title);\n }, [title, onTitleChange]);\n\n const hasActionBar =\n search !== undefined || primaryAction !== undefined || onFilterClick !== undefined;\n\n // State precedence: loading > error > empty > children\n let content: ReactNode;\n if (loading) {\n content = loadingNode ?? (\n <Card className=\"flex items-center justify-center gap-3 p-12 text-muted-foreground\">\n <Loader2 aria-hidden=\"true\" className=\"size-5 animate-spin\" />\n <span className=\"font-sans text-body-sm\">Loading…</span>\n </Card>\n );\n } else if (error) {\n content = (\n <Card className=\"flex flex-col items-center gap-3 p-8 text-center\">\n <AlertCircle aria-hidden=\"true\" className=\"size-8 text-destructive\" />\n <p className=\"font-sans text-body-sm text-foreground\">{error.message}</p>\n <div className=\"flex items-center gap-3\">\n {error.onRetry ? (\n <button\n type=\"button\"\n onClick={error.onRetry}\n className={cn(\n \"inline-flex items-center rounded-md border border-border/40 px-3 py-1.5\",\n \"font-sans text-body-sm text-foreground\",\n \"transition-colors hover:bg-muted\",\n )}\n >\n Retry\n </button>\n ) : null}\n {error.docsHref ? (\n <a\n href={error.docsHref}\n className=\"font-sans text-body-sm text-primary hover:underline\"\n >\n View docs\n </a>\n ) : null}\n </div>\n </Card>\n );\n } else if (empty) {\n const emptyAction = empty.action;\n content = (\n <EmptyState\n icon={empty.icon as Parameters<typeof EmptyState>[0][\"icon\"]}\n title={empty.title}\n description={empty.description}\n action={\n emptyAction ? (\n <button\n type=\"button\"\n onClick={emptyAction.onClick}\n className={cn(\n \"inline-flex items-center rounded-md bg-primary px-3 py-1.5\",\n \"font-medium font-sans text-body-sm text-primary-foreground\",\n \"transition-colors hover:bg-primary-deep\",\n )}\n >\n {emptyAction.label}\n </button>\n ) : undefined\n }\n />\n );\n } else {\n content = children;\n }\n\n return (\n <main\n ref={ref}\n aria-busy={loading || undefined}\n className={cn(\"flex flex-col gap-6\", className)}\n >\n <header className=\"flex flex-col gap-1\">\n <h1 className=\"font-display font-semibold text-display-sm text-foreground tracking-tight\">\n {title}\n </h1>\n {description ? (\n <p className=\"font-sans text-body-md text-muted-foreground\">{description}</p>\n ) : null}\n </header>\n {hasActionBar ? (\n <ActionBar search={search} primaryAction={primaryAction} onFilterClick={onFilterClick} />\n ) : null}\n <div>{content}</div>\n </main>\n );\n },\n);\nPageShell.displayName = \"PageShell\";\n\nexport { PageShell };\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "pin-input",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "PinInput",
|
|
6
|
+
"description": "Multi-slot OTP / code input primitive. N separate boxes (default 6) that auto-advance focus on input. Paste fills all slots from clipboard with whitespace stripped. Arrow keys navigate; backspace clears current slot then moves focus back when empty. numeric / alphanumeric inputMode (default numeric, triggers mobile numeric keyboard via pattern=[0-9]*). Optional mask renders bullets. Optional error state applies destructive border. onComplete fires once when value reaches length — NOT on mount with pre-filled value.",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
10
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/primitives/pin-input/pin-input.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"target": "components/ui/pin-input.tsx",
|
|
17
|
+
"content": "import { forwardRef, useEffect, useRef } from \"react\";\nimport type { ClipboardEvent, HTMLAttributes, KeyboardEvent } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * PinInput — multi-slot OTP / code input primitive.\n *\n * Renders N separate boxes (default 6) that auto-advance focus on\n * input. Paste handling fills all slots from clipboard (whitespace\n * stripped). Arrow keys navigate; backspace clears current slot\n * then moves focus back when empty.\n *\n * Industry-standard pattern for email verification codes (Apple,\n * Stripe, Clerk, Auth0, GitHub two-factor).\n *\n * @example\n * <PinInput\n * length={6}\n * value={code}\n * onChange={setCode}\n * onComplete={(v) => verify(v)}\n * inputMode=\"numeric\"\n * aria-label=\"Verification code\"\n * />\n *\n * Note: value is treated as controlled. If you pass a complete value\n * on mount, onComplete will NOT fire — onComplete fires only on\n * transitions from incomplete → complete.\n */\nexport interface PinInputProps\n extends Omit<HTMLAttributes<HTMLDivElement>, \"onChange\" | \"inputMode\"> {\n length?: number;\n value?: string;\n onChange?: (value: string) => void;\n onComplete?: (value: string) => void;\n inputMode?: \"numeric\" | \"alphanumeric\";\n size?: \"sm\" | \"md\" | \"lg\";\n disabled?: boolean;\n error?: boolean;\n \"aria-label\": string;\n autoFocus?: boolean;\n mask?: boolean;\n}\n\nconst SIZE_CLASS: Record<NonNullable<PinInputProps[\"size\"]>, string> = {\n sm: \"size-8 text-body-sm\",\n md: \"size-10 text-body-md\",\n lg: \"size-12 text-title-sm\",\n};\n\nfunction sanitize(raw: string, inputMode: \"numeric\" | \"alphanumeric\"): string {\n const noWhitespace = raw.replace(/\\s/g, \"\");\n if (inputMode === \"numeric\") {\n return noWhitespace.replace(/\\D/g, \"\");\n }\n return noWhitespace.toUpperCase().replace(/[^A-Z0-9]/g, \"\");\n}\n\nconst PinInput = forwardRef<HTMLDivElement, PinInputProps>(\n (\n {\n className,\n length = 6,\n value = \"\",\n onChange,\n onComplete,\n inputMode = \"numeric\",\n size = \"md\",\n disabled = false,\n error = false,\n autoFocus = false,\n mask = false,\n \"aria-label\": ariaLabel,\n ...props\n },\n ref,\n ) => {\n const inputRefs = useRef<Array<HTMLInputElement | null>>([]);\n const wasCompleteRef = useRef<boolean>(value.length === length);\n\n // Auto-focus first slot on mount (SSR-safe)\n useEffect(() => {\n if (!autoFocus) return;\n if (typeof window === \"undefined\") return;\n inputRefs.current[0]?.focus();\n }, [autoFocus]);\n\n // Fire onComplete on transitions from incomplete → complete\n useEffect(() => {\n const isComplete = value.length === length && value.length > 0;\n if (isComplete && !wasCompleteRef.current) {\n onComplete?.(value);\n }\n wasCompleteRef.current = isComplete;\n }, [value, length, onComplete]);\n\n function commit(next: string) {\n const sanitized = sanitize(next, inputMode).slice(0, length);\n onChange?.(sanitized);\n }\n\n function handleChange(slot: number, raw: string) {\n const sanitized = sanitize(raw, inputMode);\n if (sanitized.length === 0) {\n // Clear current slot\n const next = `${value.slice(0, slot)}${value.slice(slot + 1)}`;\n commit(next);\n return;\n }\n // Take the last character typed (handles browser autocomplete that fills multiple)\n const ch = sanitized[sanitized.length - 1] ?? \"\";\n const next = `${value.slice(0, slot)}${ch}${value.slice(slot + 1)}`;\n commit(next);\n // Advance focus\n if (slot < length - 1) {\n inputRefs.current[slot + 1]?.focus();\n }\n }\n\n function handleKeyDown(slot: number, e: KeyboardEvent<HTMLInputElement>) {\n if (disabled) return;\n const slotChar = value[slot] ?? \"\";\n\n if (e.key === \"Backspace\") {\n if (slotChar === \"\") {\n // Move focus back if current is empty\n if (slot > 0) {\n inputRefs.current[slot - 1]?.focus();\n }\n } else {\n // Clear current slot, stay focused\n const next = `${value.slice(0, slot)}${value.slice(slot + 1)}`;\n commit(next);\n }\n e.preventDefault();\n } else if (e.key === \"ArrowLeft\") {\n if (slot > 0) inputRefs.current[slot - 1]?.focus();\n e.preventDefault();\n } else if (e.key === \"ArrowRight\") {\n if (slot < length - 1) inputRefs.current[slot + 1]?.focus();\n e.preventDefault();\n }\n }\n\n function handlePaste(slot: number, e: ClipboardEvent<HTMLInputElement>) {\n if (disabled) return;\n e.preventDefault();\n const pasted = e.clipboardData.getData(\"text/plain\");\n const sanitized = sanitize(pasted, inputMode);\n if (sanitized.length === 0) return;\n // Build slot-indexed array, then overwrite from `slot` onwards.\n // Previous string-concat approach didn't pad when value was shorter\n // than `slot`, which made paste-from-middle-when-empty fill from 0.\n const slotArr: string[] = Array.from({ length }, (_, i) => value[i] ?? \"\");\n const remaining = length - slot;\n const filled = sanitized.slice(0, remaining);\n for (let i = 0; i < filled.length; i++) {\n slotArr[slot + i] = filled[i] ?? \"\";\n }\n const next = slotArr.join(\"\");\n commit(next);\n // Focus the slot after the last filled, or the last slot if completed\n const focusAt = Math.min(slot + filled.length, length - 1);\n requestAnimationFrame(() => inputRefs.current[focusAt]?.focus());\n }\n\n const slots = Array.from({ length }, (_, i) => i);\n\n return (\n <div\n ref={ref}\n // biome-ignore lint/a11y/useSemanticElements: <fieldset> would force a different visual layout (rectangular border by default) and is form-bound; we use a div with role=\"group\" + aria-label for grouping semantics.\n role=\"group\"\n aria-label={ariaLabel}\n className={cn(\"inline-flex items-center gap-2\", className)}\n {...props}\n >\n {slots.map((i) => {\n const ch = value[i] ?? \"\";\n const display = mask && ch !== \"\" ? \"•\" : ch;\n return (\n <input\n // biome-ignore lint/suspicious/noArrayIndexKey: PinInput slots are positional; reorder is impossible by design.\n key={i}\n ref={(el) => {\n inputRefs.current[i] = el;\n }}\n type=\"text\"\n inputMode={inputMode === \"numeric\" ? \"numeric\" : \"text\"}\n pattern={inputMode === \"numeric\" ? \"[0-9]*\" : undefined}\n maxLength={1}\n autoComplete={i === 0 ? \"one-time-code\" : \"off\"}\n disabled={disabled}\n value={display}\n onChange={(e) => handleChange(i, e.target.value)}\n onKeyDown={(e) => handleKeyDown(i, e)}\n onPaste={(e) => handlePaste(i, e)}\n aria-label={`Digit ${i + 1} of ${length}`}\n className={cn(\n \"rounded-md border bg-card text-center font-medium font-mono\",\n \"transition-colors\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n SIZE_CLASS[size],\n error ? \"border-destructive\" : \"border-border/60 hover:border-border\",\n )}\n />\n );\n })}\n </div>\n );\n },\n);\nPinInput.displayName = \"PinInput\";\n\nexport { PinInput };\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|