astralyx-ui 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/bin/index.d.ts +2 -0
- package/bin/index.js +404 -0
- package/bin/registry.d.ts +37 -0
- package/bin/registry.js +69 -0
- package/package.json +89 -0
- package/registry/index.json +3914 -0
- package/registry/items/accordion.json +22 -0
- package/registry/items/address-input.json +21 -0
- package/registry/items/agent-steps.json +23 -0
- package/registry/items/alert-dialog.json +22 -0
- package/registry/items/alert-triage.json +25 -0
- package/registry/items/alert.json +21 -0
- package/registry/items/anomaly-chart.json +18 -0
- package/registry/items/api-keys.json +25 -0
- package/registry/items/aspect-ratio.json +18 -0
- package/registry/items/attachment-preview.json +23 -0
- package/registry/items/audit-log.json +26 -0
- package/registry/items/avatar.json +21 -0
- package/registry/items/badge.json +22 -0
- package/registry/items/banner.json +22 -0
- package/registry/items/benchmark-table.json +20 -0
- package/registry/items/bet-input.json +23 -0
- package/registry/items/blame-view.json +22 -0
- package/registry/items/branch-select.json +21 -0
- package/registry/items/breadcrumb.json +22 -0
- package/registry/items/bridge-status.json +24 -0
- package/registry/items/build-log.json +23 -0
- package/registry/items/bulk-action-bar.json +21 -0
- package/registry/items/bundle-treemap.json +20 -0
- package/registry/items/button-group.json +21 -0
- package/registry/items/button.json +22 -0
- package/registry/items/cache-stats.json +21 -0
- package/registry/items/calendar.json +25 -0
- package/registry/items/campaign-card.json +22 -0
- package/registry/items/candlestick-chart.json +18 -0
- package/registry/items/card-input.json +22 -0
- package/registry/items/card.json +21 -0
- package/registry/items/carousel.json +22 -0
- package/registry/items/cart.json +23 -0
- package/registry/items/chain-select.json +24 -0
- package/registry/items/changelog-entry.json +21 -0
- package/registry/items/chart.json +19 -0
- package/registry/items/checkbox.json +22 -0
- package/registry/items/checkout-summary.json +20 -0
- package/registry/items/citations.json +22 -0
- package/registry/items/code-block.json +23 -0
- package/registry/items/code-search.json +22 -0
- package/registry/items/cohort-table.json +19 -0
- package/registry/items/collapsible.json +23 -0
- package/registry/items/color-picker.json +24 -0
- package/registry/items/combobox.json +23 -0
- package/registry/items/command.json +23 -0
- package/registry/items/commit-graph.json +20 -0
- package/registry/items/commit-list.json +26 -0
- package/registry/items/composer.json +30 -0
- package/registry/items/confidence-meter.json +18 -0
- package/registry/items/connection-string.json +24 -0
- package/registry/items/container-list.json +24 -0
- package/registry/items/context-menu.json +20 -0
- package/registry/items/context-picker.json +25 -0
- package/registry/items/coordinate-input.json +21 -0
- package/registry/items/copy-button.json +22 -0
- package/registry/items/cost-breakdown.json +20 -0
- package/registry/items/coverage-report.json +22 -0
- package/registry/items/cron-schedule.json +24 -0
- package/registry/items/curl-command.json +18 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/date-picker.json +24 -0
- package/registry/items/date-range-compare.json +24 -0
- package/registry/items/dependency-list.json +23 -0
- package/registry/items/deploy-list.json +27 -0
- package/registry/items/description-list.json +18 -0
- package/registry/items/device-list.json +24 -0
- package/registry/items/dialog.json +24 -0
- package/registry/items/diff-proposal.json +23 -0
- package/registry/items/diff-stat.json +18 -0
- package/registry/items/diff-view.json +23 -0
- package/registry/items/donut.json +19 -0
- package/registry/items/drawer.json +19 -0
- package/registry/items/dropdown-menu.json +24 -0
- package/registry/items/dropzone.json +22 -0
- package/registry/items/empty.json +19 -0
- package/registry/items/endpoint-list.json +24 -0
- package/registry/items/env-diff.json +23 -0
- package/registry/items/env-vars.json +24 -0
- package/registry/items/error-boundary.json +22 -0
- package/registry/items/eval-results.json +21 -0
- package/registry/items/feature-flag.json +23 -0
- package/registry/items/feedback.json +23 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-tree.json +21 -0
- package/registry/items/fingerprint-diff.json +23 -0
- package/registry/items/flame-graph.json +19 -0
- package/registry/items/fmt.json +16 -0
- package/registry/items/form.json +21 -0
- package/registry/items/fraud-verdict.json +24 -0
- package/registry/items/funnel.json +18 -0
- package/registry/items/gas-tracker.json +21 -0
- package/registry/items/gauge.json +18 -0
- package/registry/items/governance-proposal.json +24 -0
- package/registry/items/group.json +21 -0
- package/registry/items/health-checks.json +23 -0
- package/registry/items/heatmap-grid.json +19 -0
- package/registry/items/hover-card.json +20 -0
- package/registry/items/http-status.json +19 -0
- package/registry/items/incident-card.json +24 -0
- package/registry/items/input-file.json +22 -0
- package/registry/items/input-otp.json +19 -0
- package/registry/items/input.json +23 -0
- package/registry/items/invoice-list.json +24 -0
- package/registry/items/ip-cluster.json +22 -0
- package/registry/items/json-viewer.json +21 -0
- package/registry/items/jwt-inspector.json +25 -0
- package/registry/items/kanban.json +20 -0
- package/registry/items/kbd.json +18 -0
- package/registry/items/label-picker.json +24 -0
- package/registry/items/label.json +18 -0
- package/registry/items/leaderboard.json +23 -0
- package/registry/items/ledger-table.json +22 -0
- package/registry/items/lib-date.json +14 -0
- package/registry/items/lib-highlighter.json +16 -0
- package/registry/items/lib-styles.json +14 -0
- package/registry/items/lib-use-clipboard.json +14 -0
- package/registry/items/lib-utils.json +16 -0
- package/registry/items/lighthouse-score.json +19 -0
- package/registry/items/liquidity-position.json +23 -0
- package/registry/items/live-announcer.json +16 -0
- package/registry/items/location-picker.json +26 -0
- package/registry/items/log-viewer.json +24 -0
- package/registry/items/login-form.json +26 -0
- package/registry/items/map-embed.json +22 -0
- package/registry/items/market-table.json +23 -0
- package/registry/items/mask-input.json +19 -0
- package/registry/items/media-gallery.json +25 -0
- package/registry/items/mention-input.json +21 -0
- package/registry/items/menubar.json +21 -0
- package/registry/items/merge-conflict.json +20 -0
- package/registry/items/message.json +25 -0
- package/registry/items/migration-list.json +23 -0
- package/registry/items/mint-panel.json +23 -0
- package/registry/items/model-comparison.json +24 -0
- package/registry/items/model-select.json +20 -0
- package/registry/items/moderation-queue.json +26 -0
- package/registry/items/money-input.json +19 -0
- package/registry/items/multi-select.json +23 -0
- package/registry/items/multiplier-chart.json +18 -0
- package/registry/items/navigation-menu.json +23 -0
- package/registry/items/network-status.json +23 -0
- package/registry/items/nft-card.json +23 -0
- package/registry/items/notification-inbox.json +25 -0
- package/registry/items/number-input.json +21 -0
- package/registry/items/odds-display.json +21 -0
- package/registry/items/on-call-schedule.json +25 -0
- package/registry/items/order-book.json +19 -0
- package/registry/items/page-header.json +18 -0
- package/registry/items/pagination.json +21 -0
- package/registry/items/password-input.json +21 -0
- package/registry/items/payment-method.json +23 -0
- package/registry/items/payout-status.json +24 -0
- package/registry/items/permission-matrix.json +22 -0
- package/registry/items/pipeline.json +23 -0
- package/registry/items/popover.json +22 -0
- package/registry/items/port-table.json +22 -0
- package/registry/items/portfolio-balance.json +22 -0
- package/registry/items/postback-config.json +26 -0
- package/registry/items/presence.json +20 -0
- package/registry/items/price-ticker.json +21 -0
- package/registry/items/pricing-table.json +24 -0
- package/registry/items/primitive-collapsible.json +16 -0
- package/registry/items/primitive-dialog.json +16 -0
- package/registry/items/primitive-dismissable.json +14 -0
- package/registry/items/primitive-field.json +16 -0
- package/registry/items/primitive-focus-trap.json +14 -0
- package/registry/items/primitive-media-query.json +14 -0
- package/registry/items/primitive-popper.json +14 -0
- package/registry/items/primitive-portal.json +14 -0
- package/registry/items/primitive-radio-group.json +16 -0
- package/registry/items/primitive-router.json +14 -0
- package/registry/items/primitive-slot.json +16 -0
- package/registry/items/primitive-tabs.json +16 -0
- package/registry/items/progress.json +21 -0
- package/registry/items/prompt-input.json +25 -0
- package/registry/items/prompt-template.json +23 -0
- package/registry/items/pull-request-card.json +25 -0
- package/registry/items/query-editor.json +25 -0
- package/registry/items/query-plan.json +23 -0
- package/registry/items/queue-monitor.json +22 -0
- package/registry/items/radio-group.json +22 -0
- package/registry/items/range-slider.json +19 -0
- package/registry/items/rate-limit-meter.json +20 -0
- package/registry/items/rating.json +21 -0
- package/registry/items/reasoning-block.json +23 -0
- package/registry/items/regex-tester.json +25 -0
- package/registry/items/release-list.json +22 -0
- package/registry/items/request-builder.json +28 -0
- package/registry/items/resizable.json +22 -0
- package/registry/items/resource-meter.json +19 -0
- package/registry/items/response-viewer.json +26 -0
- package/registry/items/retrieval-results.json +23 -0
- package/registry/items/revenue-share-table.json +20 -0
- package/registry/items/review-thread.json +25 -0
- package/registry/items/risk-score.json +18 -0
- package/registry/items/root-cause-tree.json +20 -0
- package/registry/items/round-history.json +21 -0
- package/registry/items/runbook-steps.json +24 -0
- package/registry/items/schema-table.json +22 -0
- package/registry/items/schema-viewer.json +22 -0
- package/registry/items/scroll-area.json +18 -0
- package/registry/items/seed-phrase.json +22 -0
- package/registry/items/select.json +24 -0
- package/registry/items/separator.json +18 -0
- package/registry/items/service-status.json +21 -0
- package/registry/items/session-list.json +24 -0
- package/registry/items/sheet.json +24 -0
- package/registry/items/shortcut-sheet.json +19 -0
- package/registry/items/sidebar.json +24 -0
- package/registry/items/skeleton.json +21 -0
- package/registry/items/skip-link.json +19 -0
- package/registry/items/slider.json +21 -0
- package/registry/items/slo-budget.json +19 -0
- package/registry/items/sparkline.json +18 -0
- package/registry/items/spinner.json +20 -0
- package/registry/items/stack-trace.json +23 -0
- package/registry/items/staking-panel.json +24 -0
- package/registry/items/stat.json +21 -0
- package/registry/items/status-checks.json +23 -0
- package/registry/items/stepper.json +22 -0
- package/registry/items/streaming-text.json +19 -0
- package/registry/items/subscription-state.json +22 -0
- package/registry/items/suggestions.json +19 -0
- package/registry/items/swap-panel.json +22 -0
- package/registry/items/switch.json +21 -0
- package/registry/items/symbol-outline.json +22 -0
- package/registry/items/table-of-contents.json +19 -0
- package/registry/items/table.json +19 -0
- package/registry/items/tabs.json +22 -0
- package/registry/items/tag-input.json +21 -0
- package/registry/items/terminal.json +20 -0
- package/registry/items/test-results.json +24 -0
- package/registry/items/textarea.json +21 -0
- package/registry/items/theme-toggle.json +20 -0
- package/registry/items/theme.json +18 -0
- package/registry/items/thread-list.json +24 -0
- package/registry/items/ticket-card.json +24 -0
- package/registry/items/time-picker.json +23 -0
- package/registry/items/timeline.json +19 -0
- package/registry/items/timezone-select.json +18 -0
- package/registry/items/toast.json +22 -0
- package/registry/items/toggle.json +21 -0
- package/registry/items/token-amount.json +18 -0
- package/registry/items/token-approvals.json +25 -0
- package/registry/items/token-select.json +25 -0
- package/registry/items/token-usage.json +20 -0
- package/registry/items/tool-call.json +24 -0
- package/registry/items/toolbar.json +19 -0
- package/registry/items/tooltip.json +19 -0
- package/registry/items/topology-map.json +20 -0
- package/registry/items/tour.json +25 -0
- package/registry/items/traffic-split.json +20 -0
- package/registry/items/transaction-list.json +23 -0
- package/registry/items/transaction-status.json +25 -0
- package/registry/items/tree.json +21 -0
- package/registry/items/two-factor-setup.json +25 -0
- package/registry/items/typing-indicator.json +19 -0
- package/registry/items/typography.json +18 -0
- package/registry/items/upload-list.json +24 -0
- package/registry/items/uptime-strip.json +19 -0
- package/registry/items/user-menu.json +24 -0
- package/registry/items/utm-builder.json +24 -0
- package/registry/items/validator-list.json +23 -0
- package/registry/items/video-player.json +23 -0
- package/registry/items/virtual-list.json +18 -0
- package/registry/items/visually-hidden.json +19 -0
- package/registry/items/wallet-address.json +22 -0
- package/registry/items/wallet-connect.json +25 -0
- package/registry/items/webhook-inspector.json +26 -0
- package/registry/items/websocket-frames.json +24 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-field",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "Field",
|
|
5
|
+
"description": "Wires a label, a control, help text and an error message together — id, aria-describedby and aria-invalid, all of which are easy to get one short of by hand.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "components/primitives/field.tsx",
|
|
12
|
+
"type": "registry:primitive",
|
|
13
|
+
"content": "import {\n createContext,\n use,\n useMemo,\n useRef,\n type ComponentProps,\n type MouseEvent,\n type Ref,\n type RefObject,\n} from 'react'\n\n/**\n * A headless text field, modelled on Radix Themes' `TextField.Root` /\n * `TextField.Slot` split: the box, the control and the adornments are three\n * separate parts, and this file owns only their behaviour — no styling.\n *\n * The behaviour worth having is focus delegation. A styled field is a box\n * around an `<input>`, so the padding and any icon are not the input; clicking\n * them would otherwise do nothing, or worse, blur the field. Root captures\n * those clicks and forwards focus to the control.\n */\n\ntype FieldContextValue = {\n controlRef: RefObject<HTMLInputElement | null>\n}\n\nconst FieldContext = createContext<FieldContextValue | null>(null)\n\nfunction useFieldContext(part: string) {\n const context = use(FieldContext)\n\n if (!context) {\n throw new Error(`${part} must be used inside <Field.Root>`)\n }\n\n return context\n}\n\nfunction mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {\n return (node: T | null) => {\n for (const ref of refs) {\n if (typeof ref === 'function') ref(node)\n else if (ref) ref.current = node\n }\n }\n}\n\n/** Anything matching this already handles its own clicks. */\nconst INTERACTIVE = 'input, textarea, select, button, a, [data-field-interactive]'\n\nfunction FieldRoot({ onMouseDown, children, ...props }: ComponentProps<'div'>) {\n const controlRef = useRef<HTMLInputElement>(null)\n const context = useMemo(() => ({ controlRef }), [])\n\n function handleMouseDown(event: MouseEvent<HTMLDivElement>) {\n onMouseDown?.(event)\n\n if (event.defaultPrevented || event.button !== 0) return\n\n const target = event.target as HTMLElement\n if (target.closest(INTERACTIVE)) return\n\n // Suppress the default so the press never moves focus off the control.\n event.preventDefault()\n controlRef.current?.focus()\n }\n\n return (\n <FieldContext value={context}>\n <div data-slot=\"field\" onMouseDown={handleMouseDown} {...props}>\n {children}\n </div>\n </FieldContext>\n )\n}\n\nfunction FieldControl({ ref, ...props }: ComponentProps<'input'>) {\n const { controlRef } = useFieldContext('Field.Control')\n\n return (\n <input\n data-slot=\"field-control\"\n ref={mergeRefs(ref, controlRef)}\n {...props}\n />\n )\n}\n\ntype FieldSlotProps = ComponentProps<'span'> & {\n side?: 'start' | 'end'\n /** Set when the slot holds something clickable, so Root leaves it alone. */\n interactive?: boolean\n}\n\nfunction FieldSlot({ side = 'start', interactive, ...props }: FieldSlotProps) {\n return (\n <span\n data-slot=\"field-slot\"\n data-side={side}\n data-field-interactive={interactive || undefined}\n {...props}\n />\n )\n}\n\nconst Field = {\n Root: FieldRoot,\n Control: FieldControl,\n Slot: FieldSlot,\n}\n\nexport { Field, FieldControl, FieldRoot, FieldSlot, mergeRefs }\nexport type { FieldSlotProps }\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-focus-trap",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "focus-trap",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "components/primitives/focus-trap.tsx",
|
|
10
|
+
"type": "registry:primitive",
|
|
11
|
+
"content": "import { useEffect, useRef, type ReactNode, type RefObject } from 'react'\n\n/**\n * Keeps Tab inside a container while it is active.\n *\n * Only for surfaces that cannot use a native `<dialog>` — a tour step, a\n * non-modal panel that still owns the keyboard. `showModal()` traps focus and\n * inerts the rest of the page for free, and a hand-rolled trap is strictly\n * worse than it, so Dialog and Sheet stay on the native element.\n *\n * The tabbable set is recomputed on each Tab rather than cached on mount,\n * because the content behind it changes: a step that reveals a button would\n * otherwise skip it, and one that removes the element focus is on would trap\n * the user against a node that no longer exists.\n */\nconst TABBABLE = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(',')\n\nfunction tabbableWithin(root: HTMLElement) {\n return [...root.querySelectorAll<HTMLElement>(TABBABLE)].filter(\n (node) =>\n !node.hasAttribute('inert') &&\n node.offsetWidth + node.offsetHeight > 0 &&\n getComputedStyle(node).visibility !== 'hidden',\n )\n}\n\nfunction useFocusTrap(\n ref: RefObject<HTMLElement | null>,\n active = true,\n { restoreFocus = true }: { restoreFocus?: boolean } = {},\n) {\n useEffect(() => {\n const root = ref.current\n if (!active || !root) return\n\n const previous = document.activeElement as HTMLElement | null\n\n // Focus the first thing inside, or the container, so the next Tab starts\n // from within the trap rather than from wherever focus happened to be.\n const initial = tabbableWithin(root)[0] ?? root\n initial.focus({ preventScroll: true })\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Tab') return\n const nodes = tabbableWithin(root)\n if (nodes.length === 0) {\n event.preventDefault()\n return\n }\n\n const first = nodes[0]\n const last = nodes[nodes.length - 1]\n const current = document.activeElement\n\n if (event.shiftKey && (current === first || !root.contains(current))) {\n event.preventDefault()\n last.focus()\n } else if (!event.shiftKey && current === last) {\n event.preventDefault()\n first.focus()\n }\n }\n\n document.addEventListener('keydown', onKeyDown, true)\n return () => {\n document.removeEventListener('keydown', onKeyDown, true)\n if (restoreFocus) previous?.focus?.({ preventScroll: true })\n }\n }, [ref, active, restoreFocus])\n}\n\n/** Component form, for when a ref is inconvenient. */\nfunction FocusTrap({\n active = true,\n restoreFocus = true,\n children,\n}: {\n active?: boolean\n restoreFocus?: boolean\n children: ReactNode\n}) {\n const ref = useRef<HTMLDivElement>(null)\n useFocusTrap(ref, active, { restoreFocus })\n\n return (\n <div ref={ref} tabIndex={-1} className=\"outline-none\">\n {children}\n </div>\n )\n}\n\nexport { FocusTrap, useFocusTrap, tabbableWithin }\n"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-media-query",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "media-query",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "components/primitives/media-query.tsx",
|
|
10
|
+
"type": "registry:primitive",
|
|
11
|
+
"content": "import { useSyncExternalStore } from 'react'\n\n/**\n * Subscribe to a media query.\n *\n * `useSyncExternalStore` rather than an effect: the value is read during the\n * first render instead of after it, so a component that branches on the\n * breakpoint does not paint the wrong layout and then correct itself.\n *\n * The server snapshot is `false` — a layout that stacks by default degrades\n * more gracefully than one that assumes room it may not have.\n */\nexport function useMediaQuery(query: string) {\n return useSyncExternalStore(\n (onChange) => {\n const list = window.matchMedia(query)\n list.addEventListener('change', onChange)\n return () => list.removeEventListener('change', onChange)\n },\n () => window.matchMedia(query).matches,\n () => false,\n )\n}\n\n/** Tailwind's default breakpoints, so JS and CSS agree on where things change. */\nexport const BREAKPOINTS = {\n sm: '40rem',\n md: '48rem',\n lg: '64rem',\n xl: '80rem',\n} as const\n\nexport type Breakpoint = keyof typeof BREAKPOINTS\n\n/** True once the viewport is at or above the named breakpoint. */\nexport function useBreakpoint(breakpoint: Breakpoint) {\n return useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]})`)\n}\n"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-popper",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "popper",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "components/primitives/popper.tsx",
|
|
10
|
+
"type": "registry:primitive",
|
|
11
|
+
"content": "import { useCallback, useEffect, useState, type RefObject } from 'react'\n\n/**\n * Position a floating layer against an anchor, without a positioning library.\n *\n * `position: fixed` is deliberate — it takes the layer out of every ancestor's\n * overflow, so a menu inside a scrolling panel is not clipped by it. The cost is\n * that the position has to be recomputed on scroll and resize, which is what the\n * listeners below do.\n *\n * Collision handling flips to the opposite side when the preferred one does not\n * fit, then clamps along the cross axis so the layer stays on screen.\n */\nexport type Side = 'top' | 'right' | 'bottom' | 'left'\nexport type Align = 'start' | 'center' | 'end'\n\ntype PopperOptions = {\n open: boolean\n anchorRef: RefObject<HTMLElement | null>\n floatingRef: RefObject<HTMLElement | null>\n side?: Side\n align?: Align\n /** Gap between anchor and layer, in px. */\n offset?: number\n /** Keep at least this much room to the viewport edge. */\n padding?: number\n /** Stretch the layer to the anchor's width — for select and combobox menus. */\n matchAnchorWidth?: boolean\n /**\n * Sides to try, in order, when the preferred one does not fit.\n *\n * Defaults to the opposite side, which is right for a panel hanging off a\n * trigger. A submenu wants a longer chain — right, then left, then above —\n * because a cascading menu near the corner of the viewport can run out of\n * room on both sides.\n */\n fallbackSides?: Side[]\n}\n\nexport type PopperState = {\n style: React.CSSProperties\n side: Side\n}\n\nconst OPPOSITE: Record<Side, Side> = {\n top: 'bottom',\n bottom: 'top',\n left: 'right',\n right: 'left',\n}\n\nfunction place(\n anchor: DOMRect,\n layer: { width: number; height: number },\n side: Side,\n align: Align,\n offset: number,\n) {\n const vertical = side === 'top' || side === 'bottom'\n\n const main =\n side === 'bottom'\n ? anchor.bottom + offset\n : side === 'top'\n ? anchor.top - layer.height - offset\n : side === 'right'\n ? anchor.right + offset\n : anchor.left - layer.width - offset\n\n const size = vertical ? layer.width : layer.height\n const start = vertical ? anchor.left : anchor.top\n const extent = vertical ? anchor.width : anchor.height\n\n const cross =\n align === 'start'\n ? start\n : align === 'end'\n ? start + extent - size\n : start + extent / 2 - size / 2\n\n return vertical ? { top: main, left: cross } : { top: cross, left: main }\n}\n\nfunction fits(\n position: { top: number; left: number },\n layer: { width: number; height: number },\n padding: number,\n) {\n return (\n position.top >= padding &&\n position.left >= padding &&\n position.top + layer.height <= window.innerHeight - padding &&\n position.left + layer.width <= window.innerWidth - padding\n )\n}\n\nexport function usePopper({\n open,\n anchorRef,\n floatingRef,\n side = 'bottom',\n align = 'center',\n offset = 6,\n padding = 8,\n matchAnchorWidth = false,\n fallbackSides,\n}: PopperOptions): PopperState {\n // `fallbackSides` is normally a literal array, so a new identity arrives on\n // every render. Comparing its contents keeps the callback stable without\n // asking every caller to memoise the array.\n const fallbackKey = fallbackSides?.join() ?? ''\n const [state, setState] = useState<PopperState>({\n // Hidden until measured, or the layer flashes at 0,0 on first paint.\n style: { position: 'fixed', top: 0, left: 0, visibility: 'hidden' },\n side,\n })\n\n const update = useCallback(() => {\n const anchor = anchorRef.current\n const floating = floatingRef.current\n if (!anchor || !floating) return\n\n const anchorRect = anchor.getBoundingClientRect()\n const layer = {\n width: floating.offsetWidth,\n height: floating.offsetHeight,\n }\n\n let resolved = side\n let position = place(anchorRect, layer, side, align, offset)\n\n if (!fits(position, layer, padding)) {\n // First side that fits wins; if none do, the clamp below keeps the\n // preferred placement on screen rather than leaving it half outside.\n for (const candidate of fallbackSides ?? [OPPOSITE[side]]) {\n const next = place(anchorRect, layer, candidate, align, offset)\n if (fits(next, layer, padding)) {\n resolved = candidate\n position = next\n break\n }\n }\n }\n\n // Clamp whatever side won, so a layer wider than the gap still stays on\n // screen rather than running off the edge.\n const top = Math.min(\n Math.max(position.top, padding),\n Math.max(padding, window.innerHeight - layer.height - padding),\n )\n const left = Math.min(\n Math.max(position.left, padding),\n Math.max(padding, window.innerWidth - layer.width - padding),\n )\n\n setState({\n side: resolved,\n style: {\n position: 'fixed',\n top,\n left,\n visibility: 'visible',\n ...(matchAnchorWidth ? { width: anchorRect.width } : null),\n maxHeight: `calc(100vh - ${padding * 2}px)`,\n },\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [\n anchorRef,\n floatingRef,\n side,\n align,\n offset,\n padding,\n matchAnchorWidth,\n fallbackKey,\n ])\n\n useEffect(() => {\n if (!open) return\n\n update()\n\n // `true` for capture: catches scrolling in any ancestor, not just the page.\n window.addEventListener('scroll', update, true)\n window.addEventListener('resize', update)\n\n const observer = new ResizeObserver(update)\n if (floatingRef.current) observer.observe(floatingRef.current)\n if (anchorRef.current) observer.observe(anchorRef.current)\n\n return () => {\n window.removeEventListener('scroll', update, true)\n window.removeEventListener('resize', update)\n observer.disconnect()\n }\n }, [open, update, anchorRef, floatingRef])\n\n return state\n}\n"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-portal",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "portal",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "components/primitives/portal.tsx",
|
|
10
|
+
"type": "registry:primitive",
|
|
11
|
+
"content": "import { useEffect, useState, type ReactNode } from 'react'\nimport { createPortal } from 'react-dom'\n\n/**\n * Renders children into another part of the document.\n *\n * Mounting is deferred by one effect rather than portalling during render.\n * `document.body` does not exist while rendering on the server, and reading it\n * during the first client render would produce markup that does not match what\n * the server sent.\n *\n * Most overlays in this kit do not need this: Dialog and Sheet use a native\n * `<dialog>`, which the browser already promotes to the top layer. Portal is\n * for the cases that cannot — an element that must escape an ancestor's\n * `overflow: hidden` or stacking context without becoming modal.\n */\nfunction Portal({\n children,\n container,\n}: {\n children: ReactNode\n /** Defaults to `document.body`. */\n container?: Element | null\n}) {\n const [mounted, setMounted] = useState(false)\n\n useEffect(() => setMounted(true), [])\n\n if (!mounted) return null\n return createPortal(children, container ?? document.body)\n}\n\nexport { Portal }\n"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-radio-group",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "Radio Group",
|
|
5
|
+
"description": "One choice out of several. Built on native radio inputs, so arrow-key roving, form submission and the single-selection invariant come from the platform.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "components/primitives/radio-group.tsx",
|
|
12
|
+
"type": "registry:primitive",
|
|
13
|
+
"content": "import {\n createContext,\n use,\n useId,\n useMemo,\n useState,\n type ReactNode,\n} from 'react'\n\n/**\n * Headless radio group. Native `<input type=\"radio\">` elements already give\n * roving focus, arrow-key movement and form submission for free, so this only\n * has to share the group's name, value and change handler — and own the\n * controlled/uncontrolled split so items stay dumb.\n */\n\ntype RadioGroupContextValue = {\n name: string\n value: string | undefined\n select: (value: string) => void\n disabled: boolean\n}\n\nconst RadioGroupContext = createContext<RadioGroupContextValue | null>(null)\n\nexport function useRadioGroup() {\n const context = use(RadioGroupContext)\n if (!context) throw new Error('<Radio> must be used inside <RadioGroup>')\n return context\n}\n\ntype RadioGroupProviderProps = {\n name?: string\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n disabled?: boolean\n children: ReactNode\n}\n\nexport function RadioGroupProvider({\n name,\n value: valueProp,\n defaultValue,\n onValueChange,\n disabled = false,\n children,\n}: RadioGroupProviderProps) {\n const generated = useId()\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n\n const context = useMemo<RadioGroupContextValue>(\n () => ({\n name: name ?? generated,\n value,\n disabled,\n select: (next) => {\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n },\n }),\n [name, generated, value, disabled, controlled, onValueChange],\n )\n\n return <RadioGroupContext value={context}>{children}</RadioGroupContext>\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-router",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "router",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "components/primitives/router.tsx",
|
|
10
|
+
"type": "registry:primitive",
|
|
11
|
+
"content": "import {\n createContext,\n use,\n useCallback,\n useEffect,\n useMemo,\n useState,\n type ComponentProps,\n type MouseEvent,\n type ReactNode,\n} from 'react'\n\ntype NavigateOptions = { replace?: boolean }\n\ntype RouterValue = {\n path: string\n navigate: (to: string, options?: NavigateOptions) => void\n}\n\nconst RouterContext = createContext<RouterValue | null>(null)\n\n/**\n * A minimal History API router — enough for a static, data-less site.\n *\n * Deliberately hand-rolled to keep the kit dependency-free. The surface\n * (`Link`, `useRoute`, `useLocation`, `navigate`) mirrors React Router's, so\n * swapping in the real thing later is an import change.\n */\nfunction Router({ children }: { children: ReactNode }) {\n const [path, setPath] = useState(() => window.location.pathname)\n\n useEffect(() => {\n const onPopState = () => setPath(window.location.pathname)\n\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const navigate = useCallback((to: string, options: NavigateOptions = {}) => {\n if (to === window.location.pathname) return\n\n window.history[options.replace ? 'replaceState' : 'pushState']({}, '', to)\n setPath(to)\n }, [])\n\n const value = useMemo(() => ({ path, navigate }), [path, navigate])\n\n return <RouterContext value={value}>{children}</RouterContext>\n}\n\nfunction useRouter() {\n const value = use(RouterContext)\n\n if (!value) throw new Error('useRouter must be used inside <Router>')\n\n return value\n}\n\nfunction useLocation() {\n return useRouter().path\n}\n\nfunction useNavigate() {\n return useRouter().navigate\n}\n\n/**\n * Match the current path against a pattern with `:param` segments.\n * Returns the params on a match, or `null`.\n */\nfunction matchRoute(pattern: string, path: string) {\n const patternParts = pattern.split('/').filter(Boolean)\n const pathParts = path.split('/').filter(Boolean)\n\n if (patternParts.length !== pathParts.length) return null\n\n const params: Record<string, string> = {}\n\n for (const [index, part] of patternParts.entries()) {\n const value = pathParts[index]\n\n if (part.startsWith(':')) {\n params[part.slice(1)] = decodeURIComponent(value)\n continue\n }\n\n if (part !== value) return null\n }\n\n return params\n}\n\nfunction useRoute(pattern: string) {\n const path = useLocation()\n\n return useMemo(() => matchRoute(pattern, path), [pattern, path])\n}\n\ntype LinkProps = ComponentProps<'a'> & {\n to: string\n replace?: boolean\n}\n\n/** An `<a>` that navigates in-app, while staying a real, openable link. */\nfunction Link({ to, replace, onClick, target, ...props }: LinkProps) {\n const navigate = useNavigate()\n\n function handleClick(event: MouseEvent<HTMLAnchorElement>) {\n onClick?.(event)\n\n // Leave modified clicks and non-self targets to the browser.\n if (event.defaultPrevented) return\n if (event.button !== 0) return\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return\n if (target && target !== '_self') return\n\n event.preventDefault()\n navigate(to, { replace })\n }\n\n return <a href={to} target={target} onClick={handleClick} {...props} />\n}\n\nexport { Link, Router, matchRoute, useLocation, useNavigate, useRoute }\nexport type { LinkProps }\n"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-slot",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "slot",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"lib-utils"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "components/primitives/slot.tsx",
|
|
12
|
+
"type": "registry:primitive",
|
|
13
|
+
"content": "import {\n Children,\n cloneElement,\n isValidElement,\n type AnyActionArg,\n type CSSProperties,\n type ReactElement,\n type ReactNode,\n} from 'react'\nimport { cn } from '@/lib/utils'\n\ntype AnyProps = Record<string, unknown>\n\n/**\n * Merge a parent's props onto a child element's own props.\n *\n * Event handlers are chained (child first, then parent), `className` is merged\n * through `cn`, `style` is shallow-merged, and everything else the parent sets\n * wins — except that a child's explicit value is never overwritten by\n * `undefined`.\n */\nfunction mergeProps(parent: AnyProps, child: AnyProps): AnyProps {\n const merged: AnyProps = { ...child }\n\n for (const key in parent) {\n const parentValue = parent[key]\n const childValue = child[key]\n\n if (/^on[A-Z]/.test(key)) {\n if (typeof parentValue === 'function' && typeof childValue === 'function') {\n merged[key] = (...args: AnyActionArg) => {\n childValue(...args)\n parentValue(...args)\n }\n } else {\n merged[key] = parentValue ?? childValue\n }\n continue\n }\n\n if (key === 'className') {\n merged[key] = cn(parentValue as string, childValue as string)\n continue\n }\n\n if (key === 'style') {\n merged[key] = {\n ...(parentValue as CSSProperties),\n ...(childValue as CSSProperties),\n }\n continue\n }\n\n if (parentValue !== undefined) merged[key] = parentValue\n }\n\n return merged\n}\n\ntype SlotProps = AnyProps & { children?: ReactNode }\n\n/**\n * Render into the single child element instead of a wrapper of our own — the\n * primitive behind every `asChild` prop in the kit. React 19 treats `ref` as an\n * ordinary prop, so it merges like anything else.\n */\nfunction Slot({ children, ...props }: SlotProps) {\n const child = Children.only(children)\n\n if (!isValidElement(child)) return null\n\n const element = child as ReactElement<AnyProps>\n\n return cloneElement(element, mergeProps(props, element.props))\n}\n\nexport { Slot, mergeProps }\nexport type { SlotProps }\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-tabs",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "Tabs",
|
|
5
|
+
"description": "Switch between panels in the same context. Full WAI-ARIA keyboard support: arrows rove, Home and End jump, and only the active tab is in the tab order.",
|
|
6
|
+
"category": "Navigation",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "components/primitives/tabs.tsx",
|
|
12
|
+
"type": "registry:primitive",
|
|
13
|
+
"content": "import {\n createContext,\n use,\n useCallback,\n useId,\n useMemo,\n useState,\n type KeyboardEvent,\n type ReactNode,\n} from 'react'\n\n/**\n * Headless tabs. Owns the selected value, the ids that wire a trigger to its\n * panel, and the arrow-key roving the WAI-ARIA tabs pattern expects.\n *\n * Triggers are found by querying the list on keydown rather than registering\n * themselves: tabs can be composed, wrapped or conditionally rendered, and a\n * registry would drift out of order the moment they are.\n */\ntype TabsContextValue = {\n value: string | undefined\n select: (value: string) => void\n orientation: 'horizontal' | 'vertical'\n activationMode: 'automatic' | 'manual'\n baseId: string\n onListKeyDown: (event: KeyboardEvent<HTMLElement>) => void\n}\n\nconst TabsContext = createContext<TabsContextValue | null>(null)\n\nexport function useTabs() {\n const context = use(TabsContext)\n if (!context) throw new Error('Must be used inside <Tabs>')\n return context\n}\n\nexport function tabIds(baseId: string, value: string) {\n return {\n trigger: `${baseId}-trigger-${value}`,\n panel: `${baseId}-panel-${value}`,\n }\n}\n\ntype TabsProviderProps = {\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n orientation?: 'horizontal' | 'vertical'\n activationMode?: 'automatic' | 'manual'\n children: ReactNode\n}\n\nexport function TabsProvider({\n value: valueProp,\n defaultValue,\n onValueChange,\n orientation = 'horizontal',\n activationMode = 'automatic',\n children,\n}: TabsProviderProps) {\n const baseId = useId()\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n\n const select = useCallback(\n (next: string) => {\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n },\n [controlled, onValueChange],\n )\n\n const onListKeyDown = useCallback(\n (event: KeyboardEvent<HTMLElement>) => {\n const [previous, next] =\n orientation === 'vertical'\n ? ['ArrowUp', 'ArrowDown']\n : ['ArrowLeft', 'ArrowRight']\n\n const keys = [previous, next, 'Home', 'End']\n if (!keys.includes(event.key)) return\n\n const list = event.currentTarget\n const triggers = Array.from(\n list.querySelectorAll<HTMLElement>('[role=\"tab\"]:not([disabled])'),\n )\n if (triggers.length === 0) return\n\n const current = triggers.indexOf(document.activeElement as HTMLElement)\n let target = current\n\n if (event.key === 'Home') target = 0\n else if (event.key === 'End') target = triggers.length - 1\n else if (event.key === next)\n target = (current + 1 + triggers.length) % triggers.length\n else target = (current - 1 + triggers.length) % triggers.length\n\n event.preventDefault()\n const element = triggers[target]\n element?.focus()\n\n // Automatic activation selects as focus moves; manual waits for Enter or\n // Space, which the trigger's click handler already covers.\n if (activationMode === 'automatic') {\n const nextValue = element?.dataset.value\n if (nextValue) select(nextValue)\n }\n },\n [orientation, activationMode, select],\n )\n\n const context = useMemo<TabsContextValue>(\n () => ({\n value,\n select,\n orientation,\n activationMode,\n baseId,\n onListKeyDown,\n }),\n [value, select, orientation, activationMode, baseId, onListKeyDown],\n )\n\n return <TabsContext value={context}>{children}</TabsContext>\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "progress",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Progress",
|
|
5
|
+
"description": "How far along a task is. Omit the value for an indeterminate bar, which sweeps rather than fills — the one honest way to say \"working, duration unknown\".",
|
|
6
|
+
"category": "Feedback",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"class-variance-authority@^0.7.1"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/progress.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { colorSet, tintStyle } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * How far along a task is.\n *\n * With no `value` it becomes indeterminate: the bar animates position, which is\n * the one honest way to say \"working, duration unknown\". It stops under\n * `prefers-reduced-motion`, where the track alone still communicates progress.\n */\nconst trackVariants = cva('bg-secondary w-full overflow-hidden rounded-full', {\n variants: {\n size: {\n sm: 'h-1',\n default: 'h-1.5',\n lg: 'h-2.5',\n },\n },\n defaultVariants: { size: 'default' },\n})\n\ntype ProgressProps = Omit<ComponentProps<'div'>, 'color'> &\n VariantProps<typeof trackVariants> & {\n /** 0–100. Omit for an indeterminate bar. */\n value?: number\n color?: keyof typeof colorSet\n tint?: string\n /** Render the percentage beside the track. */\n showValue?: boolean\n }\n\nfunction Progress({\n className,\n size,\n value,\n color = 'neutral',\n tint,\n showValue = false,\n style,\n ...props\n}: ProgressProps) {\n const indeterminate = value === undefined\n const clamped = indeterminate ? 0 : Math.min(100, Math.max(0, value))\n\n const track = (\n <div\n role=\"progressbar\"\n aria-valuemin={0}\n aria-valuemax={100}\n aria-valuenow={indeterminate ? undefined : clamped}\n data-slot=\"progress\"\n className={cn(trackVariants({ size }), colorSet[color], className)}\n style={tint ? { ...tintStyle(tint), ...style } : style}\n {...props}\n >\n <div\n data-slot=\"progress-fill\"\n className={cn(\n 'h-full rounded-full bg-[var(--ui)]',\n indeterminate\n ? 'w-2/5 animate-[progress-indeterminate_1.4s_ease-in-out_infinite] motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50'\n : 'transition-[width] duration-300 ease-out motion-reduce:transition-none',\n )}\n style={indeterminate ? undefined : { width: `${clamped}%` }}\n />\n </div>\n )\n\n if (!showValue) return track\n\n return (\n <div className=\"flex w-full items-center gap-3\">\n {track}\n <span className=\"text-muted-foreground w-9 shrink-0 text-right font-mono text-xs tabular-nums\">\n {indeterminate ? '—' : `${Math.round(clamped)}%`}\n </span>\n </div>\n )\n}\n\nexport { Progress, trackVariants as progressVariants }\nexport type { ProgressProps }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prompt-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Prompt Input",
|
|
5
|
+
"description": "The composer for a chat: a growing textarea, attached context as removable chips, and a toolbar. Enter sends, Shift+Enter adds a newline, and IME composition is checked first.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils",
|
|
15
|
+
"textarea",
|
|
16
|
+
"tooltip"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/prompt-input.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import {\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactNode,\n} from 'react'\nimport { ArrowUp, Paperclip, Square, X } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The composer for a chat: a growing textarea, a row of attached context, and a\n * toolbar.\n *\n * Enter sends and Shift+Enter inserts a newline — the convention every chat\n * client shares, and the one users have muscle memory for. IME composition is\n * checked first, or the Enter that confirms a Japanese or Chinese candidate\n * would send the message instead.\n */\nexport type ContextItem = {\n id: string\n label: string\n icon?: ReactNode\n /** Shown after the label, e.g. a line range or a file size. */\n detail?: string\n}\n\ntype PromptInputProps = Omit<ComponentProps<'div'>, 'onSubmit'> & {\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n onSubmit?: (value: string) => void\n placeholder?: string\n disabled?: boolean\n /** Show the stop button instead of send, and block submission. */\n busy?: boolean\n onStop?: () => void\n /** Attached files, selections, docs — rendered as removable chips. */\n context?: ContextItem[]\n onRemoveContext?: (id: string) => void\n /** Rendered at the start of the toolbar — a context picker, a model select. */\n toolbar?: ReactNode\n /** Character or token budget, shown when close to the limit. */\n maxLength?: number\n stopLabel?: string\n sendLabel?: string\n}\n\nfunction PromptInput({\n className,\n value: valueProp,\n defaultValue = '',\n onValueChange,\n onSubmit,\n placeholder = 'Ask anything…',\n disabled = false,\n busy = false,\n onStop,\n context = [],\n onRemoveContext,\n toolbar,\n maxLength,\n stopLabel = 'Stop',\n sendLabel = 'Send',\n ...props\n}: PromptInputProps) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n const composing = useRef(false)\n\n const empty = value.trim().length === 0\n const canSend = !empty && !disabled && !busy\n\n function set(next: string) {\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n }\n\n function submit() {\n if (!canSend) return\n onSubmit?.(value.trim())\n if (!controlled) setUncontrolled('')\n }\n\n function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {\n // An IME uses Enter to accept a candidate; sending there loses the word.\n if (composing.current) return\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n submit()\n }\n }\n\n return (\n <div\n data-slot=\"prompt-input\"\n className={cn(\n surface,\n radius.panel,\n // No focus outline on the card or the textarea inside it. The caret is\n // already in view and the composer fills its column, so a border that\n // lights up adds nothing but noise.\n 'flex flex-col gap-2 p-2',\n disabled && 'pointer-events-none opacity-60',\n className,\n )}\n {...props}\n >\n {context.length > 0 && (\n <div className=\"flex flex-wrap gap-1.5 px-1 pt-1\">\n {context.map((item) => (\n <Badge\n key={item.id}\n size=\"sm\"\n variant=\"secondary\"\n shape=\"rounded\"\n icon={item.icon}\n className=\"ps-2 pe-1\"\n >\n <span className=\"max-w-40 truncate\">{item.label}</span>\n {item.detail && (\n <span className=\"text-muted-foreground/70 font-mono\">\n {item.detail}\n </span>\n )}\n {onRemoveContext && (\n <button\n type=\"button\"\n aria-label={`Remove ${item.label}`}\n onClick={() => onRemoveContext(item.id)}\n className=\"hover:bg-accent ms-0.5 rounded-full p-0.5\"\n >\n <X className=\"size-3\" />\n </button>\n )}\n </Badge>\n ))}\n </div>\n )}\n\n <Textarea\n value={value}\n onChange={(event) => set(event.target.value)}\n onKeyDown={onKeyDown}\n onCompositionStart={() => (composing.current = true)}\n onCompositionEnd={() => (composing.current = false)}\n placeholder={placeholder}\n disabled={disabled}\n maxLength={maxLength}\n autoResize\n rows={1}\n variant=\"ghost\"\n // The card is the field, so the textarea must not draw a second border,\n // a hover fill, or a focus border of its own.\n className=\"max-h-56 border-transparent bg-transparent hover:bg-transparent focus-within:border-transparent\"\n />\n\n <div className=\"flex items-center gap-1 px-1 pb-0.5\">\n {toolbar}\n\n <div className=\"ms-auto flex items-center gap-2\">\n {maxLength && value.length > maxLength * 0.8 && (\n <span\n className={cn(\n 'font-mono text-xs tabular-nums',\n value.length >= maxLength\n ? 'text-[var(--destructive-soft-foreground)]'\n : 'text-muted-foreground',\n )}\n >\n {value.length}/{maxLength}\n </span>\n )}\n\n {busy ? (\n <Tooltip content=\"Stop generating\">\n <Button size=\"icon-sm\" variant=\"secondary\" aria-label={stopLabel} onClick={onStop}>\n <Square />\n </Button>\n </Tooltip>\n ) : (\n <Button\n size=\"icon-sm\"\n aria-label={sendLabel}\n disabled={!canSend}\n onClick={submit}\n >\n <ArrowUp />\n </Button>\n )}\n </div>\n </div>\n </div>\n )\n}\n\n/** The usual leading toolbar button, for attaching context. */\nfunction PromptAttachButton({\n label = 'Attach context',\n ...props\n}: ComponentProps<typeof Button> & {\n /** Accessible name and tooltip for the button. */\n label?: string\n}) {\n return (\n <Tooltip content={label}>\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label={label} {...props}>\n <Paperclip />\n </Button>\n </Tooltip>\n )\n}\n\nexport { PromptInput, PromptAttachButton }\nexport type { PromptInputProps }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prompt-template",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Prompt Template",
|
|
5
|
+
"description": "A prompt with {{variable}} placeholders and a form to fill them. Variables are parsed out of the template rather than declared separately, so the two cannot drift.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"badge",
|
|
10
|
+
"copy-button",
|
|
11
|
+
"input",
|
|
12
|
+
"label",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/prompt-template.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useId, useMemo, useState, type ComponentProps } from 'react'\nimport { Badge } from '@/components/ui/badge'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A prompt with `{{variable}}` placeholders and a form to fill them.\n *\n * Variables are parsed out of the template rather than declared separately.\n * Two sources for the same list drift the moment someone edits the text, and\n * the failure is silent — a placeholder with no input, or an input that fills\n * nothing.\n *\n * Unfilled placeholders stay visible in the preview instead of rendering empty.\n * A prompt that quietly sends \"Summarise in words\" is worse than one that\n * shows you what is missing.\n */\nconst VARIABLE = /\\{\\{\\s*(\\w+)\\s*\\}\\}/g\n\nfunction extractVariables(template: string) {\n const found = new Set<string>()\n for (const match of template.matchAll(VARIABLE)) found.add(match[1])\n return [...found]\n}\n\nfunction PromptTemplate({\n template,\n values: valuesProp,\n onValuesChange,\n title,\n showPreview = true,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'title' | 'onChange'> & {\n template: string\n values?: Record<string, string>\n onValuesChange?: (values: Record<string, string>) => void\n title?: string\n showPreview?: boolean\n}) {\n const variables = useMemo(() => extractVariables(template), [template])\n // Namespaced per instance: two templates on one page would otherwise both\n // mint `var-repo`, giving duplicate ids and labels pointing at the wrong\n // field.\n const fieldId = useId()\n\n const controlled = valuesProp !== undefined\n const [uncontrolled, setUncontrolled] = useState<Record<string, string>>({})\n const values = controlled ? valuesProp : uncontrolled\n\n const filled = variables.filter((name) => values[name]?.trim()).length\n\n const preview = template.replace(VARIABLE, (whole, name: string) =>\n values[name]?.trim() ? values[name] : whole,\n )\n\n function set(name: string, value: string) {\n const next = { ...values, [name]: value }\n if (!controlled) setUncontrolled(next)\n onValuesChange?.(next)\n }\n\n return (\n <div\n data-slot=\"prompt-template\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-wrap items-center gap-2 border-b p-3\">\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {title ?? 'Prompt template'}\n </span>\n <Badge size=\"sm\" color={filled === variables.length ? 'green' : 'amber'}>\n {filled} / {variables.length} filled\n </Badge>\n <CopyButton value={() => preview} label=\"Copy prompt\" />\n </div>\n\n {variables.length > 0 && (\n <div className=\"border-border grid gap-3 border-b p-3 sm:grid-cols-2\">\n {variables.map((name) => (\n <div key={name} className=\"flex flex-col gap-1.5\">\n <Label htmlFor={`${fieldId}-${name}`} className=\"font-mono text-xs\">\n {name}\n </Label>\n <Input\n id={`${fieldId}-${name}`}\n size=\"sm\"\n variant=\"secondary\"\n placeholder={`{{${name}}}`}\n value={values[name] ?? ''}\n onChange={(event) => set(name, event.target.value)}\n />\n </div>\n ))}\n </div>\n )}\n\n {showPreview && (\n <pre className=\"max-h-64 overflow-auto p-3 font-mono text-xs whitespace-pre-wrap\">\n {/* Unfilled placeholders stay literal, so gaps are visible. */}\n {preview.split(VARIABLE).map((part, index) =>\n index % 2 === 1 ? (\n <span\n key={index}\n className=\"bg-[color-mix(in_oklab,var(--amber),transparent_80%)] rounded-sm px-1\"\n >\n {`{{${part}}}`}\n </span>\n ) : (\n <span key={index}>{part}</span>\n ),\n )}\n </pre>\n )}\n </div>\n )\n}\n\nexport { PromptTemplate, extractVariables }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pull-request-card",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Pull Request Card",
|
|
5
|
+
"description": "A pull request at a glance — state, branches, checks, reviewers. State is shown as an icon and a word, never colour alone.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"avatar",
|
|
12
|
+
"badge",
|
|
13
|
+
"diff-stat",
|
|
14
|
+
"fmt",
|
|
15
|
+
"lib-styles",
|
|
16
|
+
"lib-utils"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/pull-request-card.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { GitMerge, GitPullRequest, GitPullRequestClosed, MessageSquare } from 'lucide-react'\nimport { Avatar, AvatarGroup } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { DiffStat } from '@/components/ui/diff-stat'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A pull request at a glance.\n *\n * The state is an icon *and* a word, never colour alone — merged and closed are\n * purple and red in every forge, which is exactly the pair that disappears for\n * the most common form of colour blindness.\n */\nexport type PullRequestState = 'open' | 'draft' | 'merged' | 'closed'\n\nconst STATE = {\n open: { Icon: GitPullRequest, label: 'Open', color: 'green' },\n draft: { Icon: GitPullRequest, label: 'Draft', color: 'neutral' },\n merged: { Icon: GitMerge, label: 'Merged', color: 'violet' },\n closed: { Icon: GitPullRequestClosed, label: 'Closed', color: 'destructive' },\n} as const\n\nfunction PullRequestCard({\n number,\n title,\n state = 'open',\n author,\n branch,\n baseBranch = 'main',\n updated,\n comments,\n additions,\n deletions,\n reviewers,\n labels,\n checks,\n reviewersLabel = 'Reviewers',\n className,\n ...props\n}: Omit<ComponentProps<'article'>, 'title'> & {\n number: number\n title: ReactNode\n state?: PullRequestState\n author?: string\n branch?: string\n baseBranch?: string\n updated?: Date\n comments?: number\n additions?: number\n deletions?: number\n reviewers?: string[]\n labels?: ReactNode\n /** A StatusChecks summary, or any node. */\n checks?: ReactNode\n reviewersLabel?: ReactNode\n}) {\n const { Icon, label, color } = STATE[state]\n\n return (\n <article\n data-slot=\"pull-request-card\"\n data-state={state}\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex items-start gap-2.5\">\n <Icon\n className={cn(\n 'mt-0.5 size-4 shrink-0',\n state === 'open' && 'text-[var(--green-soft-foreground)]',\n state === 'merged' && 'text-[var(--violet-soft-foreground)]',\n state === 'closed' && 'text-[var(--destructive-soft-foreground)]',\n state === 'draft' && 'text-muted-foreground',\n )}\n aria-hidden=\"true\"\n />\n\n <div className=\"min-w-0 flex-1\">\n <h3 className=\"text-sm font-medium text-pretty\">{title}</h3>\n <p className=\"text-muted-foreground mt-0.5 text-xs\">\n #{number} · {label}\n {author && ` · by ${author}`}\n {updated && (\n <>\n {' · updated '}\n <Fmt type=\"relative\" value={updated} />\n </>\n )}\n </p>\n </div>\n\n <Badge size=\"sm\" color={color}>\n {label}\n </Badge>\n </div>\n\n {branch && (\n <div className=\"text-muted-foreground flex flex-wrap items-center gap-1.5 font-mono text-xs\">\n <span className=\"bg-muted rounded-sm px-1.5 py-0.5\">{branch}</span>\n <span aria-hidden=\"true\">→</span>\n <span className=\"bg-muted rounded-sm px-1.5 py-0.5\">{baseBranch}</span>\n </div>\n )}\n\n {labels && <div className=\"flex flex-wrap gap-1.5\">{labels}</div>}\n {checks}\n\n <div className=\"text-muted-foreground flex flex-wrap items-center gap-3 text-xs\">\n {(additions !== undefined || deletions !== undefined) && (\n <DiffStat additions={additions ?? 0} deletions={deletions ?? 0} />\n )}\n {comments !== undefined && (\n <span className=\"inline-flex items-center gap-1\">\n <MessageSquare className=\"size-3.5\" aria-hidden=\"true\" />\n {comments}\n </span>\n )}\n {reviewers && reviewers.length > 0 && (\n <span className=\"ms-auto inline-flex items-center gap-1.5\">\n <span className=\"sr-only\">{reviewersLabel}</span>\n <AvatarGroup>\n {reviewers.map((name) => (\n <Avatar key={name} size=\"xs\" name={name} />\n ))}\n </AvatarGroup>\n </span>\n )}\n </div>\n </article>\n )\n}\n\nexport { PullRequestCard }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "query-editor",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Query Editor",
|
|
5
|
+
"description": "A SQL editor that asks before running an UPDATE or DELETE with no WHERE clause. That is the most expensive mistake anyone makes in a query console, and it is trivial to detect.",
|
|
6
|
+
"category": "Database",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"code-block",
|
|
14
|
+
"kbd",
|
|
15
|
+
"lib-styles",
|
|
16
|
+
"lib-utils"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/query-editor.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Play, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Kbd } from '@/components/ui/kbd'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A SQL editor with a run action and a guard on destructive statements.\n *\n * An UPDATE or DELETE without a WHERE clause requires confirmation. This is the\n * single most expensive mistake anyone makes in a query console, it is trivial\n * to detect, and detecting it is worth far more than syntax highlighting.\n *\n * Cmd/Ctrl+Enter runs. Anyone who has used a query console expects it, and\n * making them reach for a button breaks the loop that makes a console useful.\n *\n * The editor is a `CodeBlock` in editable mode rather than a bare textarea, so\n * the SQL is syntax-highlighted as it is typed. A console where every statement\n * is one undifferentiated grey block is a console people paste out of into a\n * real editor.\n *\n * Statement count is shown when the buffer holds more than one. \"Run\" on a\n * multi-statement buffer does something quite different from what a single\n * statement does, and the difference should not be a surprise.\n */\nconst DESTRUCTIVE = /^\\s*(update|delete)\\b/i\nconst HAS_WHERE = /\\bwhere\\b/i\nconst SCHEMA_CHANGE = /^\\s*(drop|truncate|alter)\\b/i\n\n/** Statement split on semicolons outside quotes — good enough to count. */\nfunction statements(sql: string) {\n return sql\n .replace(/'([^'\\\\]|\\\\.)*'/g, \"''\")\n .replace(/--[^\\n]*/g, '')\n .split(';')\n .map((s) => s.trim())\n .filter(Boolean)\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_STATEMENTS_LABEL: (count: number) => ReactNode = (count) => `${count} statements`\n\nfunction QueryEditor({\n value: valueProp,\n onValueChange,\n onRun,\n running = false,\n dialect = 'SQL',\n rows = 8,\n lineNumbers = true,\n runLabel = 'Run',\n confirmLabel = 'Run anyway',\n cancelLabel = 'Cancel',\n editorLabel = 'SQL query',\n unguardedNote = 'This statement has no WHERE clause — it will affect every row in the table.',\n schemaChangeNote = 'This changes the schema and cannot be rolled back on every engine.',\n statementsLabel = DEFAULT_STATEMENTS_LABEL,\n shortcutKeys = '⌘+↵',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange' | 'children'> & {\n value?: string\n onValueChange?: (value: string) => void\n onRun?: (sql: string) => void\n running?: boolean\n dialect?: ReactNode\n /** Minimum visible lines. The editor grows past this with the query. */\n rows?: number\n lineNumbers?: boolean\n runLabel?: ReactNode\n confirmLabel?: ReactNode\n cancelLabel?: ReactNode\n editorLabel?: string\n unguardedNote?: ReactNode\n schemaChangeNote?: ReactNode\n statementsLabel?: (count: number) => ReactNode\n /** Run chord, as a `+`-separated string for `Kbd`. Pass `null` to hide it. */\n shortcutKeys?: string | null\n}) {\n const [own, setOwn] = useState(valueProp ?? '')\n const [confirming, setConfirming] = useState(false)\n const sql = valueProp ?? own\n\n const parsed = useMemo(() => statements(sql), [sql])\n\n // The mistake worth catching: a mass update with no predicate.\n const unguarded = parsed.some((s) => DESTRUCTIVE.test(s) && !HAS_WHERE.test(s))\n const schemaChange = parsed.some((s) => SCHEMA_CHANGE.test(s))\n const risky = unguarded || schemaChange\n\n const set = (next: string) => {\n if (valueProp === undefined) setOwn(next)\n onValueChange?.(next)\n }\n\n const run = () => {\n if (risky && !confirming) {\n setConfirming(true)\n return\n }\n setConfirming(false)\n onRun?.(sql)\n }\n\n return (\n <div\n data-slot=\"query-editor\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-wrap items-center gap-2 border-b p-3\">\n <span className=\"text-muted-foreground text-xs font-medium\">{dialect}</span>\n {parsed.length > 1 && (\n <Badge size=\"sm\" color=\"amber\">\n {statementsLabel(parsed.length)}\n </Badge>\n )}\n {shortcutKeys && <Kbd keys={shortcutKeys} className=\"ms-auto\" />}\n </div>\n\n {/* The chord is caught on the wrapper: keydown bubbles out of the\n editable overlay, so the shortcut works without CodeBlock having to\n know anything about running a query. */}\n <div\n role=\"presentation\"\n className={cn(\n 'min-w-0',\n '[&_[data-slot=code-block]]:rounded-none [&_[data-slot=code-block]]:border-x-0 [&_[data-slot=code-block]]:border-t-0',\n )}\n onKeyDown={(event) => {\n // The loop a console lives or dies by.\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault()\n run()\n }\n }}\n >\n <CodeBlock\n code={sql}\n language=\"sql\"\n editable\n header={false}\n copyable={false}\n resettable={false}\n lineNumbers={lineNumbers}\n minLines={rows}\n aria-label={editorLabel}\n onCodeChange={set}\n />\n </div>\n\n {risky && (\n <p className=\"border-border flex items-start gap-1.5 border-t p-3 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {unguarded ? unguardedNote : schemaChangeNote}\n </p>\n )}\n\n {onRun && (\n <div className=\"border-border flex flex-wrap items-center gap-2 border-t p-3\">\n {confirming ? (\n <>\n <Button size=\"sm\" variant=\"colored\" color=\"destructive\" onClick={run}>\n {confirmLabel}\n </Button>\n <Button size=\"sm\" variant=\"secondary\" onClick={() => setConfirming(false)}>\n {cancelLabel}\n </Button>\n </>\n ) : (\n <Button size=\"sm\" disabled={running || !sql.trim()} onClick={run}>\n <Play />\n {runLabel}\n </Button>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport { QueryEditor, statements as splitStatements }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "query-plan",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Query Plan",
|
|
5
|
+
"description": "An EXPLAIN tree with cost bars drawn from self cost, not total. Every root holds 100% of total by definition, so a total-cost bar makes the root look like the problem in every query ever profiled.",
|
|
6
|
+
"category": "Database",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"fmt",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/query-plan.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, ChevronRight, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An EXPLAIN plan as a tree, with cost and row estimates.\n *\n * Self cost is what drives the bar, not total. Every plan's root has 100% of\n * the total cost by definition, so a bar drawn from total makes the root look\n * like the problem in every query ever profiled.\n *\n * The estimate-versus-actual ratio is computed and flagged past a threshold.\n * That mismatch is the single most useful number in a plan — it means the\n * statistics are stale, and it is why the planner picked a nested loop over a\n * hash join and turned 40ms into 40 seconds.\n *\n * Sequential scans on large row counts are marked. Not because a seq scan is\n * wrong — on a small table it is optimal — but because a seq scan over two\n * million rows is usually a missing index.\n */\nexport type PlanNode = {\n id: string\n /** \"Seq Scan\", \"Hash Join\", \"Index Scan using …\". */\n operation: string\n relation?: string\n /** Planner's estimate. */\n estimatedRows?: number\n actualRows?: number\n /** Total cost including children. */\n cost?: number\n actualMs?: number\n children?: PlanNode[]\n}\n\ntype Row = { node: PlanNode; depth: number; self: number }\n\nfunction flatten(node: PlanNode, depth = 0, out: Row[] = []): Row[] {\n const childCost = (node.children ?? []).reduce((sum, c) => sum + (c.cost ?? 0), 0)\n out.push({ node, depth, self: Math.max(0, (node.cost ?? 0) - childCost) })\n for (const child of node.children ?? []) flatten(child, depth + 1, out)\n return out\n}\n\nfunction QueryPlan({\n plan,\n misestimateFactor = 10,\n seqScanRows = 100_000,\n locale = 'en-GB',\n rowsLabel = 'rows',\n estimateLabel = 'est',\n misestimateLabel = 'estimate off',\n seqScanLabel = 'seq scan',\n misestimateNote = 'Estimates are far from actuals — usually stale statistics, and the reason the planner chose this shape.',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n plan: PlanNode\n /** Ratio past which estimate-vs-actual is flagged. */\n misestimateFactor?: number\n /** Row count above which a sequential scan is called out. */\n seqScanRows?: number\n locale?: string\n rowsLabel?: ReactNode\n estimateLabel?: ReactNode\n misestimateLabel?: ReactNode\n seqScanLabel?: ReactNode\n misestimateNote?: ReactNode\n}) {\n const [collapsed, setCollapsed] = useState<string[]>([])\n\n const rows = useMemo(() => flatten(plan), [plan])\n // Self cost, not total: every root is 100% of total by construction.\n const peakSelf = rows.reduce((max, row) => Math.max(max, row.self), 0)\n\n const hidden = useMemo(() => {\n const out = new Set<string>()\n const hide = (node: PlanNode) => {\n for (const child of node.children ?? []) {\n out.add(child.id)\n hide(child)\n }\n }\n for (const row of rows) if (collapsed.includes(row.node.id)) hide(row.node)\n return out\n }, [rows, collapsed])\n\n const anyMisestimate = rows.some(({ node }) => {\n if (node.estimatedRows === undefined || node.actualRows === undefined) return false\n const ratio = Math.max(node.estimatedRows, 1) / Math.max(node.actualRows, 1)\n return ratio > misestimateFactor || 1 / ratio > misestimateFactor\n })\n\n return (\n <div\n data-slot=\"query-plan\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <ul className=\"divide-border/60 list-none divide-y\">\n {rows\n .filter(({ node }) => !hidden.has(node.id))\n .map(({ node, depth, self }) => {\n const hasChildren = Boolean(node.children?.length)\n const isCollapsed = collapsed.includes(node.id)\n const ratio =\n node.estimatedRows !== undefined && node.actualRows !== undefined\n ? Math.max(node.estimatedRows, 1) / Math.max(node.actualRows, 1)\n : undefined\n const misestimated =\n ratio !== undefined && (ratio > misestimateFactor || 1 / ratio > misestimateFactor)\n const bigSeqScan =\n /seq scan/i.test(node.operation) && (node.actualRows ?? 0) > seqScanRows\n\n return (\n <li key={node.id} className=\"flex items-start gap-2 p-3\">\n <span style={{ width: depth * 14 }} aria-hidden=\"true\" className=\"shrink-0\" />\n\n {hasChildren ? (\n <button\n type=\"button\"\n aria-expanded={!isCollapsed}\n aria-label={`${isCollapsed ? 'Expand' : 'Collapse'} ${node.operation}`}\n className=\"text-muted-foreground hover:text-foreground mt-0.5 shrink-0\"\n onClick={() =>\n setCollapsed((current) =>\n isCollapsed ? current.filter((id) => id !== node.id) : [...current, node.id],\n )\n }\n >\n {isCollapsed ? <ChevronRight className=\"size-3.5\" /> : <ChevronDown className=\"size-3.5\" />}\n </button>\n ) : (\n <span className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n )}\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex flex-wrap items-center gap-2\">\n <span className=\"font-mono text-xs font-medium\">{node.operation}</span>\n {node.relation && (\n <span className=\"text-muted-foreground font-mono text-xs\">on {node.relation}</span>\n )}\n {misestimated && (\n <Badge size=\"sm\" color=\"destructive\">\n {misestimateLabel} {ratio! > 1 ? `${Math.round(ratio!)}×` : `${Math.round(1 / ratio!)}×`}\n </Badge>\n )}\n {bigSeqScan && (\n <Badge size=\"sm\" color=\"amber\">\n {seqScanLabel}\n </Badge>\n )}\n </p>\n\n <p className=\"text-muted-foreground mt-0.5 flex flex-wrap gap-x-3 text-xs tabular-nums\">\n {node.actualRows !== undefined && (\n <span>\n <Fmt type=\"number\" value={node.actualRows} locale={locale} /> {rowsLabel}\n </span>\n )}\n {node.estimatedRows !== undefined && (\n <span className=\"opacity-70\">\n {estimateLabel} <Fmt type=\"number\" value={node.estimatedRows} locale={locale} />\n </span>\n )}\n {node.actualMs !== undefined && <span>{node.actualMs.toFixed(1)}ms</span>}\n </p>\n </div>\n\n {/* Self cost — the bar that actually points at the problem. */}\n {peakSelf > 0 && (\n <span className=\"mt-1 hidden h-1.5 w-24 shrink-0 overflow-hidden rounded-full bg-[var(--secondary)] sm:block\">\n <span\n className=\"block h-full\"\n style={{\n width: `${(self / peakSelf) * 100}%`,\n background: misestimated ? 'var(--destructive)' : 'var(--blue)',\n }}\n />\n </span>\n )}\n </li>\n )\n })}\n </ul>\n\n {anyMisestimate && misestimateNote && (\n <p className=\"border-border text-[var(--amber-soft-foreground)] flex items-start gap-1.5 border-t p-3 text-xs\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {misestimateNote}\n </p>\n )}\n </div>\n )\n}\n\nexport { QueryPlan, flatten as flattenPlan }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "queue-monitor",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Queue Monitor",
|
|
5
|
+
"description": "Depth, throughput and whether the queue is keeping up. Depth alone says nothing — 40,000 draining at 5,000/s is fine, 300 draining at 2/s is an incident.",
|
|
6
|
+
"category": "Runtime",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"badge",
|
|
10
|
+
"fmt",
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"sparkline"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/queue-monitor.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Sparkline } from '@/components/ui/sparkline'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A job queue: depth, throughput, and whether it is keeping up.\n *\n * Depth alone says nothing. A queue at 40,000 that drains at 5,000/s is fine;\n * one at 300 that drains at 2/s is an incident. The component computes the\n * drain estimate from arrival and completion rates and leads with that, because\n * \"clearing in 8 seconds\" and \"never\" are the only two answers anyone wants.\n *\n * Failed and dead-letter counts are separate. A retryable failure is noise; a\n * job that has exhausted its retries and landed in the dead-letter queue is\n * lost work that a human has to decide about, and averaging them together hides\n * it completely.\n *\n * Oldest-job age is shown rather than average wait. Averages hide the one job\n * that has been stuck for six hours behind a poison message.\n */\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_DRAINING_LABEL: (duration: string) => ReactNode = (text) => `draining, empty in ${text}`\n\nfunction QueueMonitor({\n name,\n depth,\n processing = 0,\n failed = 0,\n deadLettered = 0,\n arrivalRate,\n completionRate,\n oldestAt,\n history,\n now,\n locale = 'en-GB',\n depthLabel = 'queued',\n processingLabel = 'processing',\n failedLabel = 'failed',\n deadLetterLabel = 'dead letter',\n oldestLabel = 'oldest',\n drainingLabel = DEFAULT_DRAINING_LABEL,\n growingLabel = 'growing — arrivals exceed completions',\n idleLabel = 'idle',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n name: ReactNode\n depth: number\n processing?: number\n failed?: number\n /** Exhausted retries — lost work needing a decision. */\n deadLettered?: number\n /** Jobs per second arriving. */\n arrivalRate?: number\n /** Jobs per second completing. */\n completionRate?: number\n /** Enqueue time of the oldest waiting job. */\n oldestAt?: Date\n history?: number[]\n now?: Date\n locale?: string\n depthLabel?: ReactNode\n processingLabel?: ReactNode\n failedLabel?: ReactNode\n deadLetterLabel?: ReactNode\n oldestLabel?: ReactNode\n /** Drain estimate, given the formatted duration. */\n drainingLabel?: (duration: string) => ReactNode\n growingLabel?: ReactNode\n idleLabel?: ReactNode\n}) {\n // The only question that matters: is it keeping up?\n const net =\n arrivalRate !== undefined && completionRate !== undefined\n ? completionRate - arrivalRate\n : undefined\n const drainSeconds = net !== undefined && net > 0 ? depth / net : undefined\n\n const state =\n depth === 0 ? 'idle' : net !== undefined && net <= 0 ? 'growing' : 'draining'\n const tone =\n state === 'growing'\n ? 'var(--destructive)'\n : state === 'idle'\n ? 'var(--muted-foreground)'\n : 'var(--green)'\n\n const duration = (seconds: number) => {\n const s = Math.round(seconds)\n if (s < 60) return `${s}s`\n if (s < 3600) return `${Math.round(s / 60)}m`\n return `${Math.round(s / 3600)}h`\n }\n\n return (\n <div\n data-slot=\"queue-monitor\"\n data-state={state}\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline gap-2\">\n <span className=\"truncate text-sm font-medium\">{name}</span>\n {arrivalRate !== undefined && completionRate !== undefined && (\n <span className=\"text-muted-foreground ms-auto text-xs tabular-nums\">\n {arrivalRate.toFixed(1)} in / {completionRate.toFixed(1)} out per s\n </span>\n )}\n </div>\n\n <p className=\"flex items-baseline gap-2\">\n <span className=\"text-3xl font-semibold tabular-nums\" style={{ color: tone }}>\n <Fmt type=\"number\" value={depth} locale={locale} />\n </span>\n <span className=\"text-muted-foreground text-xs\">{depthLabel}</span>\n </p>\n\n {/* Leads with the answer, not the number. */}\n <p className=\"text-xs\" style={{ color: tone }}>\n {state === 'idle'\n ? idleLabel\n : state === 'growing'\n ? growingLabel\n : drainSeconds !== undefined\n ? drainingLabel(duration(drainSeconds))\n : null}\n </p>\n\n {history && history.length > 1 && (\n <Sparkline values={history} variant=\"area\" color={tone} className=\"h-8\" />\n )}\n\n <dl className=\"grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-4\">\n <div>\n <dt className=\"text-muted-foreground\">{processingLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"number\" value={processing} locale={locale} />\n </dd>\n </div>\n <div>\n <dt className=\"text-muted-foreground\">{failedLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"number\" value={failed} locale={locale} />\n </dd>\n </div>\n {/* Never folded into `failed`: this is lost work. */}\n <div>\n <dt className=\"text-muted-foreground\">{deadLetterLabel}</dt>\n <dd className=\"mt-0.5\">\n {deadLettered > 0 ? (\n <Badge size=\"sm\" color=\"destructive\">\n <Fmt type=\"number\" value={deadLettered} locale={locale} />\n </Badge>\n ) : (\n <span className=\"font-medium tabular-nums\">0</span>\n )}\n </dd>\n </div>\n {oldestAt && (\n <div>\n <dt className=\"text-muted-foreground\">{oldestLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"relative\" value={oldestAt} now={now} locale={locale} />\n </dd>\n </div>\n )}\n </dl>\n </div>\n )\n}\n\nexport { QueueMonitor }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "radio-group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Radio Group",
|
|
5
|
+
"description": "One choice out of several. Built on native radio inputs, so arrow-key roving, form submission and the single-selection invariant come from the platform.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"class-variance-authority@^0.7.1"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"primitive-radio-group"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/radio-group.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport {\n RadioGroupProvider,\n useRadioGroup,\n} from '@/components/primitives/radio-group'\nimport { indicatorBase, radioSize, type Responsive } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\nconst dotVariants = cva(\n [\n indicatorBase,\n 'rounded-full [corner-shape:round]',\n 'peer-checked:border-primary peer-checked:bg-primary',\n 'peer-focus-visible:border-ring peer-focus-visible:ring-ring/50 peer-focus-visible:ring-[3px]',\n // The dot is a descendant of this box, so the variant lives here.\n '[&>span]:opacity-0 peer-checked:[&>span]:opacity-100',\n ].join(' '),\n {\n variants: {\n size: {\n sm: radioSize.sm,\n default: radioSize.default,\n lg: radioSize.lg,\n },\n error: { true: 'border-destructive', false: '' },\n },\n defaultVariants: { size: 'default', error: false },\n },\n)\n\nconst DOT_SIZE = {\n sm: 'size-1.5',\n default: 'size-2',\n lg: 'size-2.5',\n} as const\n\ntype RadioGroupProps = ComponentProps<'div'> & {\n name?: string\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n disabled?: boolean\n /** Lay the options out in a row instead of a column. */\n orientation?: 'vertical' | 'horizontal'\n /** Breakpoint a horizontal group becomes a row at. */\n responsive?: Responsive\n children: ReactNode\n}\n\n/** Written out per breakpoint; an interpolated prefix generates no CSS. */\nconst RESPONSIVE_ROW = {\n sm: 'flex-col sm:flex-row sm:flex-wrap',\n md: 'flex-col md:flex-row md:flex-wrap',\n lg: 'flex-col lg:flex-row lg:flex-wrap',\n} as const\n\nfunction RadioGroup({\n className,\n name,\n value,\n defaultValue,\n onValueChange,\n disabled,\n orientation = 'vertical',\n responsive = 'sm',\n children,\n ...props\n}: RadioGroupProps) {\n const stacks = orientation === 'horizontal' && responsive !== false\n return (\n <RadioGroupProvider\n name={name}\n value={value}\n defaultValue={defaultValue}\n onValueChange={onValueChange}\n disabled={disabled}\n >\n <div\n role=\"radiogroup\"\n data-slot=\"radio-group\"\n aria-orientation={orientation}\n className={cn(\n 'flex gap-3',\n orientation === 'vertical' && 'flex-col',\n orientation === 'horizontal' &&\n (stacks ? RESPONSIVE_ROW[responsive] : 'flex-row flex-wrap'),\n className,\n )}\n {...props}\n >\n {children}\n </div>\n </RadioGroupProvider>\n )\n}\n\ntype RadioProps = Omit<ComponentProps<'input'>, 'size' | 'type' | 'value'> &\n VariantProps<typeof dotVariants> & {\n value: string\n label?: ReactNode\n description?: ReactNode\n containerClassName?: string\n }\n\nfunction Radio({\n className,\n containerClassName,\n size = 'default',\n error,\n value,\n label,\n description,\n disabled,\n ...props\n}: RadioProps) {\n const group = useRadioGroup()\n const isDisabled = disabled || group.disabled\n\n return (\n <label\n data-slot=\"radio\"\n className={cn(\n 'flex w-fit items-start gap-2.5 select-none',\n isDisabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',\n containerClassName,\n )}\n >\n <input\n type=\"radio\"\n name={group.name}\n value={value}\n checked={group.value === value}\n disabled={isDisabled}\n aria-invalid={error || undefined}\n onChange={() => group.select(value)}\n className=\"peer sr-only\"\n {...props}\n />\n\n <span className={cn(dotVariants({ size, error }), className)}>\n <span\n className={cn(\n 'bg-primary-foreground rounded-full transition-opacity duration-150 ease-out motion-reduce:transition-none',\n DOT_SIZE[size ?? 'default'],\n )}\n />\n </span>\n\n {(label || description) && (\n <span className=\"grid gap-0.5 leading-tight\">\n {label && <span className=\"text-sm font-medium\">{label}</span>}\n {description && (\n <span className=\"text-muted-foreground text-xs\">{description}</span>\n )}\n </span>\n )}\n </label>\n )\n}\n\nexport { Radio, RadioGroup }\nexport type { RadioGroupProps, RadioProps }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "range-slider",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Range Slider",
|
|
5
|
+
"description": "Two thumbs over one track. Slider is a restyled native input[type=range], which has exactly one thumb by construction — so a range needs a custom control with the ARIA written out.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/range-slider.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import {\n useCallback,\n useId,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { focusRing, sliderSize } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Two thumbs over one track, for a minimum and maximum.\n *\n * `Slider` is a restyled native `input[type=range]`, which has exactly one\n * thumb by construction — a range cannot be built from it, so this is a custom\n * control with the ARIA and keyboard behaviour written out.\n *\n * Track and thumb come from the shared `sliderSize` scale, which sets\n * `--slider-track` and `--slider-thumb` — so a range slider and a plain one of\n * the same size line up exactly.\n *\n * Both thumbs are real buttons with `role=\"slider\"`, so each is a tab stop with\n * its own value and its own bounds. A single element reporting two numbers has\n * no way to be announced or operated.\n *\n * The thumbs can cross: dragging the low one past the high one swaps which is\n * which rather than stopping dead at the other. Clamping is the more common\n * implementation and it feels broken — the pointer keeps moving while the thumb\n * stays put.\n */\ntype RangeValue = [number, number]\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_FORMAT_VALUE: (value: number) => string = (value: number) => String(value)\n\nfunction RangeSlider({\n min = 0,\n max = 100,\n step = 1,\n value: valueProp,\n defaultValue = [25, 75],\n onValueChange,\n onValueCommit,\n disabled = false,\n size = 'default',\n label,\n formatValue = DEFAULT_FORMAT_VALUE,\n showValues = false,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange'> & {\n min?: number\n max?: number\n step?: number\n value?: RangeValue\n defaultValue?: RangeValue\n onValueChange?: (value: RangeValue) => void\n /** Fires once on release, for anything expensive. */\n onValueCommit?: (value: RangeValue) => void\n disabled?: boolean\n size?: keyof typeof sliderSize\n label?: string\n formatValue?: (value: number) => string\n showValues?: boolean\n}) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState<RangeValue>(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n\n const trackRef = useRef<HTMLDivElement>(null)\n const dragging = useRef<0 | 1 | null>(null)\n const id = useId()\n\n const [low, high] = value\n const span = max - min || 1\n const percent = (v: number) => ((v - min) / span) * 100\n\n const clamp = useCallback(\n (raw: number) => {\n const snapped = Math.round((raw - min) / step) * step + min\n return Math.min(max, Math.max(min, Number(snapped.toFixed(6))))\n },\n [min, max, step],\n )\n\n const commit = useCallback(\n (next: RangeValue, release = false) => {\n // Sorted on write, so a crossed drag simply becomes the other thumb\n // instead of jamming against its neighbour.\n const sorted: RangeValue = next[0] <= next[1] ? next : [next[1], next[0]]\n if (!controlled) setUncontrolled(sorted)\n onValueChange?.(sorted)\n if (release) onValueCommit?.(sorted)\n },\n [controlled, onValueChange, onValueCommit],\n )\n\n function valueFromPointer(clientX: number) {\n const track = trackRef.current\n if (!track) return min\n const rect = track.getBoundingClientRect()\n const ratio = (clientX - rect.left) / rect.width\n // Right-to-left reverses the mapping; the track box does not.\n const adjusted = getComputedStyle(track).direction === 'rtl' ? 1 - ratio : ratio\n return clamp(min + adjusted * span)\n }\n\n function onPointerDown(index: 0 | 1) {\n return (event: ReactPointerEvent<HTMLElement>) => {\n if (disabled) return\n dragging.current = index\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n }\n\n function onPointerMove(event: ReactPointerEvent<HTMLElement>) {\n if (dragging.current === null) return\n const next = valueFromPointer(event.clientX)\n const updated: RangeValue = [...value] as RangeValue\n updated[dragging.current] = next\n commit(updated)\n }\n\n function onPointerUp(event: ReactPointerEvent<HTMLElement>) {\n if (dragging.current === null) return\n event.currentTarget.releasePointerCapture(event.pointerId)\n dragging.current = null\n commit(value, true)\n }\n\n function onKeyDown(index: 0 | 1) {\n return (event: KeyboardEvent<HTMLElement>) => {\n if (disabled) return\n const big = (max - min) / 10\n const deltas: Record<string, number> = {\n ArrowRight: step,\n ArrowUp: step,\n ArrowLeft: -step,\n ArrowDown: -step,\n PageUp: big,\n PageDown: -big,\n }\n\n let next: number | undefined\n if (event.key in deltas) next = clamp(value[index] + deltas[event.key])\n else if (event.key === 'Home') next = min\n else if (event.key === 'End') next = max\n if (next === undefined) return\n\n event.preventDefault()\n const updated: RangeValue = [...value] as RangeValue\n updated[index] = next\n commit(updated, true)\n }\n }\n\n const metrics = sliderSize[size]\n\n return (\n <div\n data-slot=\"range-slider\"\n className={cn('flex w-full flex-col gap-2', disabled && 'opacity-50', className)}\n {...props}\n >\n {(label || showValues) && (\n <div className=\"flex items-baseline justify-between gap-2\">\n {label && (\n <span id={id} className=\"text-sm font-medium\">\n {label}\n </span>\n )}\n {showValues && (\n <span className=\"text-muted-foreground text-xs tabular-nums whitespace-nowrap\">\n {formatValue(low)} – {formatValue(high)}\n </span>\n )}\n </div>\n )}\n\n <div\n ref={trackRef}\n className={cn('relative w-full touch-none select-none', metrics)}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n >\n <div className=\"bg-secondary absolute inset-x-0 top-1/2 h-[var(--slider-track)] -translate-y-1/2 rounded-full [corner-shape:round]\" />\n <div\n className=\"bg-primary absolute top-1/2 h-[var(--slider-track)] -translate-y-1/2 rounded-full [corner-shape:round]\"\n style={{ left: `${percent(low)}%`, width: `${percent(high) - percent(low)}%` }}\n />\n\n {([0, 1] as const).map((index) => (\n <button\n key={index}\n type=\"button\"\n role=\"slider\"\n aria-labelledby={label ? id : undefined}\n aria-label={label ? undefined : index === 0 ? 'Minimum' : 'Maximum'}\n aria-valuemin={index === 0 ? min : low}\n aria-valuemax={index === 0 ? high : max}\n aria-valuenow={value[index]}\n aria-valuetext={formatValue(value[index])}\n aria-orientation=\"horizontal\"\n disabled={disabled}\n onPointerDown={onPointerDown(index)}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onKeyDown={onKeyDown(index)}\n style={{ left: `${percent(value[index])}%` }}\n className={cn(\n 'border-primary bg-background absolute top-1/2 size-[var(--slider-thumb)] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 [corner-shape:round]',\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n focusRing,\n !disabled && 'hover:bg-secondary cursor-grab active:cursor-grabbing',\n )}\n />\n ))}\n </div>\n </div>\n )\n}\n\nexport { RangeSlider }\nexport type { RangeValue }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rate-limit-meter",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Rate Limit Meter",
|
|
5
|
+
"description": "A quota with its reset time given equal weight. \"9,800 of 10,000\" is alarming; \"resets in 40 seconds\" makes it fine — showing the first without the second sends people to build a cache they do not need.",
|
|
6
|
+
"category": "Runtime",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"fmt",
|
|
10
|
+
"lib-styles",
|
|
11
|
+
"lib-utils"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/rate-limit-meter.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A quota and the window it resets in.\n *\n * The reset time is as prominent as the count. \"9,800 of 10,000\" is alarming\n * and \"resets in 40 seconds\" makes it fine; showing the first without the\n * second sends people to write a caching layer they do not need.\n *\n * Burn rate is derived from what has been consumed against how much of the\n * window has elapsed, and the projection says whether the quota runs out before\n * the reset. That is the question — not the current number.\n *\n * Remaining is shown, not used. Both are on screen, but the headline is the one\n * a caller acts on, and inverting it costs a subtraction every glance.\n */\nfunction RateLimitMeter({\n limit,\n remaining,\n resetAt,\n windowSeconds,\n now,\n label = 'Rate limit',\n remainingLabel = 'remaining',\n resetsLabel = 'resets',\n exhaustedLabel = 'Quota exhausted',\n projectionLabel = 'On the current rate this window runs out before it resets.',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n limit: number\n remaining: number\n resetAt?: Date\n /** Length of the window. Enables the burn-rate projection. */\n windowSeconds?: number\n now?: Date\n label?: ReactNode\n remainingLabel?: ReactNode\n resetsLabel?: ReactNode\n exhaustedLabel?: ReactNode\n /** Warning when the projection says the quota will not last. */\n projectionLabel?: ReactNode\n}) {\n const reference = now ?? new Date()\n const used = Math.max(0, limit - remaining)\n const share = limit > 0 ? remaining / limit : 0\n const exhausted = remaining <= 0\n\n // Elapsed share of the window, from the reset time and the window length.\n const secondsLeft = resetAt ? (resetAt.getTime() - reference.getTime()) / 1000 : undefined\n const elapsedShare =\n windowSeconds && secondsLeft !== undefined\n ? Math.min(1, Math.max(0.0001, (windowSeconds - secondsLeft) / windowSeconds))\n : undefined\n\n // Will the quota outlast the window at the current rate?\n const willExhaust =\n elapsedShare !== undefined && !exhausted && used / elapsedShare > limit\n\n const tone = exhausted\n ? 'var(--destructive)'\n : share < 0.15 || willExhaust\n ? 'var(--amber)'\n : 'var(--green)'\n\n return (\n <div\n data-slot=\"rate-limit-meter\"\n className={cn(surface, radius.surface, 'flex flex-col gap-2 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline gap-x-2\">\n <span className=\"text-muted-foreground text-xs font-medium\">{label}</span>\n {/* Reset is as prominent as the count — it is what makes a scary\n number harmless. */}\n {resetAt && (\n <span className=\"text-muted-foreground ms-auto text-xs\">\n {resetsLabel} <Fmt type=\"relative\" value={resetAt} now={reference} />\n </span>\n )}\n </div>\n\n <p className=\"flex items-baseline gap-1.5\">\n <span className=\"text-2xl font-semibold tabular-nums\" style={{ color: tone }}>\n <Fmt type=\"number\" value={remaining} />\n </span>\n <span className=\"text-muted-foreground text-xs tabular-nums\">\n / <Fmt type=\"number\" value={limit} /> {remainingLabel}\n </span>\n </p>\n\n <div\n role=\"meter\"\n aria-valuenow={remaining}\n aria-valuemin={0}\n aria-valuemax={limit}\n aria-label={typeof label === 'string' ? label : undefined}\n className=\"bg-secondary relative h-1.5 w-full overflow-hidden rounded-full\"\n >\n <div\n className=\"h-full transition-[width] duration-300\"\n style={{ width: `${share * 100}%`, background: tone }}\n />\n {/* Where the window itself has got to, for comparison against spend. */}\n {elapsedShare !== undefined && (\n <span\n aria-hidden=\"true\"\n className=\"bg-foreground/40 absolute inset-y-0 w-px\"\n style={{ insetInlineStart: `${(1 - elapsedShare) * 100}%` }}\n />\n )}\n </div>\n\n {exhausted ? (\n <p className=\"text-xs text-[var(--destructive-soft-foreground)]\">{exhaustedLabel}</p>\n ) : willExhaust ? (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">{projectionLabel}</p>\n ) : null}\n </div>\n )\n}\n\nexport { RateLimitMeter }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rating",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Rating",
|
|
5
|
+
"description": "A rating, readable or settable, drawn with monochrome marks. The icon is a prop; read-only renders one image with a text alternative — \"4 out of 5\" — rather than five separate announcements.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/rating.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import {\n useState,\n type ComponentProps,\n type ComponentType,\n type KeyboardEvent,\n} from 'react'\nimport { Hexagon } from 'lucide-react'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A rating, readable or settable.\n *\n * Read-only mode renders a single `img` with a text alternative rather than a\n * row of interactive elements — \"4 out of 5\" is what a reader needs, not five\n * separate announcements. Interactive mode is a radio group, which is exactly\n * what picking one of five mutually exclusive values is.\n *\n * Hover preview is a colour change only; nothing grows or moves, in line with\n * the rest of the kit.\n *\n * Marks are monochrome: filled is the foreground colour, empty is an outline.\n * A rating is a quantity, not a status, and colouring it amber makes it read as\n * a warning next to components where amber means exactly that.\n *\n * `icon` takes a component, not an element, because each mark is drawn twice —\n * once as the outline and once clipped for the fill. An element could only be\n * rendered once, so a half value would have nothing to clip.\n *\n * Half values are supported for display by clipping the icon, not by rendering\n * a second half-filled glyph — that keeps a 3.5 aligned with a 3 on the grid.\n */\nfunction Rating({\n value: valueProp,\n defaultValue = 0,\n onValueChange,\n count = 5,\n readOnly = false,\n icon: Icon = Hexagon,\n size = 'default',\n label = 'Rating',\n showValue = false,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange'> & {\n value?: number\n defaultValue?: number\n onValueChange?: (value: number) => void\n count?: number\n readOnly?: boolean\n /**\n * The mark. Any component taking a `className` — a lucide icon, or your own\n * SVG. Must be a component rather than an element: each mark renders twice.\n */\n icon?: ComponentType<{ className?: string }>\n size?: 'sm' | 'default' | 'lg'\n label?: string\n showValue?: boolean\n}) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n const [preview, setPreview] = useState<number | null>(null)\n\n const shown = preview ?? value\n const iconSize = { sm: 'size-3.5', default: 'size-4', lg: 'size-5' }[size]\n\n function set(next: number) {\n if (readOnly) return\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n }\n\n function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n if (readOnly) return\n const deltas: Record<string, number> = {\n ArrowRight: 1,\n ArrowUp: 1,\n ArrowLeft: -1,\n ArrowDown: -1,\n }\n if (event.key in deltas) {\n event.preventDefault()\n set(Math.min(count, Math.max(0, value + deltas[event.key])))\n } else if (event.key === 'Home') {\n event.preventDefault()\n set(0)\n } else if (event.key === 'End') {\n event.preventDefault()\n set(count)\n }\n }\n\n const stars = Array.from({ length: count }, (_, index) => {\n const position = index + 1\n // Clip rather than swap in a half glyph, so partial stars stay on grid.\n const fill = Math.max(0, Math.min(1, shown - index))\n\n return (\n // `inline-flex`, not the default inline: width and height do not apply\n // to an inline box, so in read-only mode — where the star is not a flex\n // item of a button — the icon collapsed to nothing.\n <span key={index} className={cn('relative inline-flex shrink-0', iconSize)}>\n {/* Empty: outline only, no fill. */}\n <Icon className={cn(iconSize, 'text-muted-foreground/50 absolute inset-0 fill-none')} />\n {fill > 0 && (\n <span\n className=\"absolute inset-0 overflow-hidden\"\n style={{ width: `${fill * 100}%` }}\n >\n <Icon\n // Monochrome by design: filled stars take the foreground colour\n // rather than a hue, so a rating reads as a quantity and not as\n // a status. Inverts correctly in the dark theme.\n className={cn(iconSize, 'fill-foreground text-foreground')}\n />\n </span>\n )}\n <span className=\"sr-only\">{position}</span>\n </span>\n )\n })\n\n if (readOnly) {\n return (\n <div\n data-slot=\"rating\"\n role=\"img\"\n aria-label={`${label}: ${value} out of ${count}`}\n className={cn('inline-flex items-center gap-0.5', className)}\n {...props}\n >\n {stars.map((star, index) => (\n <span key={index} aria-hidden=\"true\">\n {star}\n </span>\n ))}\n {showValue && (\n <span className=\"text-muted-foreground ms-1.5 text-xs tabular-nums\">\n {value.toFixed(1)}\n </span>\n )}\n </div>\n )\n }\n\n return (\n <div\n data-slot=\"rating\"\n role=\"radiogroup\"\n aria-label={label}\n onKeyDown={onKeyDown}\n onPointerLeave={() => setPreview(null)}\n className={cn('inline-flex items-center gap-0.5', className)}\n {...props}\n >\n {Array.from({ length: count }, (_, index) => {\n const position = index + 1\n const checked = Math.ceil(value) === position\n\n return (\n <button\n key={index}\n type=\"button\"\n role=\"radio\"\n aria-checked={checked}\n aria-label={`${position} of ${count}`}\n // One tab stop for the group, as a radio group should have.\n tabIndex={checked || (value === 0 && index === 0) ? 0 : -1}\n onPointerEnter={() => setPreview(position)}\n onFocus={() => setPreview(position)}\n onBlur={() => setPreview(null)}\n onClick={() => set(value === position ? 0 : position)}\n className={cn(\n 'flex items-center justify-center p-0.5',\n radius.xs,\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n )}\n >\n {stars[index]}\n </button>\n )\n })}\n\n {showValue && (\n <span className=\"text-muted-foreground ms-1.5 text-xs tabular-nums\">\n {value.toFixed(1)}\n </span>\n )}\n </div>\n )\n}\n\nexport { Rating }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|