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,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chain-select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Chain Select",
|
|
5
|
+
"description": "A network switcher. Testnets are labelled and grouped below mainnets — transacting on the wrong network is the most expensive user error in this space, and an unlabelled list invites it.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"primitive-dismissable",
|
|
15
|
+
"primitive-popper"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/chain-select.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import { useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { AlertTriangle, Check, ChevronDown } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport { Badge } from '@/components/ui/badge'\nimport {\n fieldBase,\n fieldSize,\n focusRing,\n menuItem,\n menuSurface,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A network switcher.\n *\n * Testnets are labelled, always, and grouped below mainnets. The single most\n * expensive user error in this space is transacting on the wrong network, and\n * an unlabelled list where Sepolia sits between Ethereum and Arbitrum invites\n * exactly that.\n *\n * An unsupported chain is a first-class state rather than an empty trigger: a\n * wallet connected to a network the app does not handle must say so, since\n * every balance and address on screen is then meaningless.\n */\nexport type Chain = {\n id: number\n name: string\n /** Brand colour for the dot. */\n color?: string\n testnet?: boolean\n icon?: ReactNode\n /** Native currency symbol, shown beside the name. */\n symbol?: string\n}\n\nfunction ChainSelect({\n chains,\n value,\n onValueChange,\n size = 'md',\n disabled = false,\n unsupportedLabel = 'Unsupported network',\n testnetLabel = 'testnet',\n listLabel = 'Networks',\n testnetGroupLabel = 'Test networks',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n chains: Chain[]\n /** Chain id. An id absent from `chains` renders the unsupported state. */\n value?: number\n onValueChange?: (id: number) => void\n size?: keyof typeof fieldSize\n disabled?: boolean\n unsupportedLabel?: string\n /** Badge on a test network. */\n testnetLabel?: ReactNode\n /** Accessible name for the listbox. */\n listLabel?: string\n /** Heading over the test networks, which sort last. */\n testnetGroupLabel?: ReactNode\n}) {\n const [open, setOpen] = useState(false)\n // Real refs, like every other layer in the kit: usePopper reads `.current`\n // from an effect, and a fresh object each render would churn its deps.\n const triggerRef = useRef<HTMLButtonElement>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n\n const { style } = usePopper({\n open,\n anchorRef: triggerRef,\n floatingRef: panelRef,\n side: 'bottom',\n align: 'start',\n offset: 4,\n matchAnchorWidth: true,\n })\n\n useDismissable({\n open,\n onDismiss: () => setOpen(false),\n refs: [triggerRef, panelRef],\n })\n\n const active = chains.find((chain) => chain.id === value)\n const unsupported = value !== undefined && !active\n\n // Mainnets first: a testnet should never sit between two production chains.\n const ordered = [...chains].sort(\n (a, b) => Number(Boolean(a.testnet)) - Number(Boolean(b.testnet)),\n )\n\n return (\n <div data-slot=\"chain-select\" className={cn('relative', className)} {...props}>\n <button\n ref={triggerRef}\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n disabled={disabled}\n onClick={() => setOpen(!open)}\n className={cn(\n fieldBase,\n fieldSize[size],\n 'justify-between gap-2 text-start',\n unsupported\n ? 'border-[var(--destructive)] bg-[color-mix(in_oklab,var(--destructive),transparent_92%)]'\n : 'border-border bg-background border',\n 'disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n {unsupported ? (\n <>\n <AlertTriangle className=\"size-4 shrink-0 text-[var(--destructive-soft-foreground)]\" />\n <span className=\"min-w-0 flex-1 truncate text-[var(--destructive-soft-foreground)]\">\n {unsupportedLabel}\n </span>\n </>\n ) : (\n <>\n {active?.icon ?? (\n <span\n aria-hidden=\"true\"\n className=\"size-2.5 shrink-0 rounded-full [corner-shape:round]\"\n style={{ backgroundColor: active?.color ?? 'var(--border)' }}\n />\n )}\n <span className=\"min-w-0 flex-1 truncate\">\n {active?.name ?? 'Select network'}\n </span>\n {active?.testnet && <Badge size=\"sm\">{testnetLabel}</Badge>}\n </>\n )}\n\n <ChevronDown\n className={cn(\n 'text-muted-foreground shrink-0 transition-transform duration-150 ease-out motion-reduce:transition-none',\n open && 'rotate-180',\n )}\n />\n </button>\n\n {open && (\n <div\n ref={panelRef}\n style={style}\n className={cn(menuSurface, radius.surface, 'max-h-72 min-w-52')}\n >\n <ul role=\"listbox\" aria-label={listLabel} className=\"list-none\">\n {ordered.map((chain, index) => {\n const first = chain.testnet && !ordered[index - 1]?.testnet\n return (\n <li key={chain.id}>\n {first && (\n <div className=\"text-muted-foreground border-border mt-1 border-t p-2.5 text-[11px] font-medium tracking-wide uppercase\">\n {testnetGroupLabel}\n </div>\n )}\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={chain.id === value}\n onClick={() => {\n onValueChange?.(chain.id)\n setOpen(false)\n }}\n className={cn(menuItem, radius.control, focusRing)}\n >\n {chain.icon ?? (\n <span\n aria-hidden=\"true\"\n className=\"size-2.5 shrink-0 rounded-full [corner-shape:round]\"\n style={{ backgroundColor: chain.color ?? 'var(--border)' }}\n />\n )}\n <span className=\"min-w-0 flex-1 truncate\">{chain.name}</span>\n {chain.symbol && (\n <span className=\"text-muted-foreground shrink-0 text-xs\">\n {chain.symbol}\n </span>\n )}\n {chain.id === value && <Check className=\"size-3.5 shrink-0\" />}\n </button>\n </li>\n )\n })}\n </ul>\n </div>\n )}\n </div>\n )\n}\n\nexport { ChainSelect }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "changelog-entry",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Changelog Entry",
|
|
5
|
+
"description": "A release grouped by change type, with breaking changes hoisted to the top whatever order they came in. Everything else in a changelog is optional reading; a breaking change is not.",
|
|
6
|
+
"category": "Code Tools",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"badge",
|
|
10
|
+
"fmt",
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/changelog-entry.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps, ReactNode } from '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 * One release's notes, grouped by change type.\n *\n * Breaking changes are hoisted to the top regardless of the order they were\n * supplied in, and they carry their own tone. Everything else in a changelog is\n * optional reading; a breaking change is the one thing that has to be seen, and\n * burying it under \"Features\" is how a minor bump takes a service down.\n *\n * The version is a heading and the date is secondary. People navigate a\n * changelog by version, and a date-led entry forces a translation step every\n * time.\n *\n * Groups render in a fixed order — breaking, added, changed, fixed, removed,\n * deprecated, security — rather than in object key order, so two releases are\n * visually comparable.\n */\nexport type ChangeGroup =\n | 'breaking' | 'added' | 'changed' | 'fixed' | 'removed' | 'deprecated' | 'security'\n\nconst ORDER: ChangeGroup[] = [\n 'breaking', 'security', 'added', 'changed', 'fixed', 'deprecated', 'removed',\n]\n\nconst GROUP = {\n breaking: { label: 'Breaking', color: 'destructive' },\n security: { label: 'Security', color: 'destructive' },\n added: { label: 'Added', color: 'green' },\n changed: { label: 'Changed', color: 'blue' },\n fixed: { label: 'Fixed', color: 'violet' },\n deprecated: { label: 'Deprecated', color: 'amber' },\n removed: { label: 'Removed', color: 'neutral' },\n} as const\n\nfunction ChangelogEntry({\n version,\n date,\n changes,\n summary,\n prerelease = false,\n yanked = false,\n locale = 'en-GB',\n groupLabels,\n prereleaseLabel = 'pre-release',\n yankedLabel = 'yanked',\n yankedNote = 'This release was withdrawn. Do not upgrade to it.',\n className,\n ...props\n}: Omit<ComponentProps<'article'>, 'children'> & {\n version: ReactNode\n date?: Date\n /** Bullet lists keyed by change type. */\n changes: Partial<Record<ChangeGroup, ReactNode[]>>\n summary?: ReactNode\n prerelease?: boolean\n yanked?: boolean\n locale?: string\n groupLabels?: Partial<Record<ChangeGroup, ReactNode>>\n prereleaseLabel?: ReactNode\n yankedLabel?: ReactNode\n yankedNote?: ReactNode\n}) {\n return (\n <article\n data-slot=\"changelog-entry\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', yanked && 'opacity-70', className)}\n {...props}\n >\n <header className=\"flex flex-wrap items-baseline gap-2\">\n {/* Version leads: that is how a changelog is navigated. */}\n <h3 className=\"font-mono text-base font-semibold\">{version}</h3>\n {date && (\n <time className=\"text-muted-foreground text-xs\">\n <Fmt type=\"date\" value={date} format=\"D MMMM YYYY\" locale={locale} />\n </time>\n )}\n {prerelease && (\n <Badge size=\"sm\" color=\"amber\">\n {prereleaseLabel}\n </Badge>\n )}\n {yanked && (\n <Badge size=\"sm\" color=\"destructive\">\n {yankedLabel}\n </Badge>\n )}\n </header>\n\n {yanked && yankedNote && (\n <p className=\"text-xs text-[var(--destructive-soft-foreground)]\">{yankedNote}</p>\n )}\n\n {summary && <p className=\"text-muted-foreground text-sm\">{summary}</p>}\n\n {/* Fixed order, so two releases can be compared at a glance. */}\n {ORDER.filter((group) => changes[group]?.length).map((group) => {\n const meta = GROUP[group]\n return (\n <section key={group} className=\"flex flex-col gap-1.5\">\n <Badge size=\"sm\" color={meta.color} className=\"self-start\">\n {groupLabels?.[group] ?? meta.label}\n </Badge>\n <ul className=\"ms-1 flex list-none flex-col gap-1\">\n {changes[group]!.map((change, index) => (\n <li key={index} className=\"flex items-start gap-2 text-sm\">\n <span\n aria-hidden=\"true\"\n className={cn(\n 'mt-1.5 size-1 shrink-0 rounded-full',\n group === 'breaking' || group === 'security'\n ? 'bg-[var(--destructive)]'\n : 'bg-muted-foreground/40',\n )}\n />\n <span className=\"min-w-0 flex-1\">{change}</span>\n </li>\n ))}\n </ul>\n </section>\n )\n })}\n </article>\n )\n}\n\nexport { ChangelogEntry, ORDER as changelogGroupOrder }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chart",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Chart",
|
|
5
|
+
"description": "A small line, area or bar plot in plain SVG. Not a charting library — everything here is a scale and a path, which is the right trade for six numbers on a dashboard card.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/chart.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import { useId, useMemo, type ComponentProps } from 'react'\nimport { dataFills } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A small plot — line, area, bar, stacked bar or scatter — in plain SVG.\n *\n * Deliberately not a charting library. Everything here is a scale and a path,\n * and the alternative is 200 kB of dependency for a component that renders six\n * numbers on a dashboard card. When a real chart is needed (brushing, zoom,\n * stacked transitions) reach for a library and use it directly; this covers the\n * shape you actually put beside a Stat.\n *\n * The plot area is a fixed 100×60 grid stretched by `preserveAspectRatio:none`,\n * so it fills its container without measuring. Anything that must keep its\n * proportions under that stretch — strokes, dots, text — either sets\n * `vector-effect: non-scaling-stroke` or is drawn outside the stretched group.\n */\nexport type ChartSeries = {\n name: string\n values: number[]\n /** Any CSS colour. Falls back to a palette entry by index. */\n color?: string\n}\n\nconst PLOT = { width: 100, height: 60 } as const\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_VALUE_FORMAT: (value: number) => string = (value: number) => String(value)\n\nfunction Chart({\n series,\n labels,\n variant = 'line',\n height = 180,\n grid = true,\n axis = true,\n legend,\n min: minProp,\n max: maxProp,\n valueFormat = DEFAULT_VALUE_FORMAT,\n className,\n ...props\n}: Omit<ComponentProps<'figure'>, 'height'> & {\n series: ChartSeries[]\n /** X labels. Should match the longest series. */\n labels?: string[]\n variant?: 'line' | 'area' | 'bar' | 'stacked-bar' | 'scatter'\n height?: number\n grid?: boolean\n axis?: boolean\n legend?: boolean\n min?: number\n max?: number\n valueFormat?: (value: number) => string\n}) {\n const titleId = useId()\n\n const { min, max, ticks } = useMemo(() => {\n const all = series.flatMap((entry) => entry.values)\n // A stacked chart is scaled by column totals; scaling by the largest single\n // value would push every stack off the top of the plot.\n const totals =\n variant === 'stacked-bar'\n ? Array.from({ length: Math.max(...series.map((s) => s.values.length), 0) }, (_, i) =>\n series.reduce((sum, entry) => sum + (entry.values[i] ?? 0), 0),\n )\n : []\n const low = minProp ?? Math.min(0, ...all)\n const high = maxProp ?? Math.max(...all, ...totals, 1)\n const span = high - low || 1\n return {\n min: low,\n max: high,\n ticks: [0, 0.5, 1].map((step) => low + span * step),\n }\n }, [series, minProp, maxProp, variant])\n\n const span = max - min || 1\n const length = Math.max(...series.map((entry) => entry.values.length), 1)\n\n const x = (index: number) =>\n length === 1 ? PLOT.width / 2 : (index / (length - 1)) * PLOT.width\n const y = (value: number) => PLOT.height - ((value - min) / span) * PLOT.height\n\n const showLegend = legend ?? series.length > 1\n\n return (\n <figure\n data-slot=\"chart\"\n className={cn('flex min-w-0 flex-col gap-2', className)}\n {...props}\n >\n <div className=\"flex min-w-0 gap-2\">\n {axis && (\n <div\n className=\"text-muted-foreground/70 flex shrink-0 flex-col justify-between text-[10px] tabular-nums\"\n style={{ height }}\n aria-hidden=\"true\"\n >\n {[...ticks].reverse().map((tick, index) => (\n <span key={index}>{valueFormat(Math.round(tick))}</span>\n ))}\n </div>\n )}\n\n <svg\n viewBox={`0 0 ${PLOT.width} ${PLOT.height}`}\n preserveAspectRatio=\"none\"\n role=\"img\"\n aria-labelledby={titleId}\n style={{ height }}\n className=\"min-w-0 flex-1 overflow-visible\"\n >\n <title id={titleId}>\n {series.map((entry) => entry.name).join(', ')}\n </title>\n\n {grid &&\n ticks.map((tick, index) => (\n <line\n key={index}\n x1={0}\n x2={PLOT.width}\n y1={y(tick)}\n y2={y(tick)}\n stroke=\"var(--border)\"\n strokeWidth={1}\n vectorEffect=\"non-scaling-stroke\"\n />\n ))}\n\n {series.map((entry, seriesIndex) => {\n const colour = entry.color ?? dataFills[seriesIndex % dataFills.length]\n const points = entry.values.map((value, index) => `${x(index)},${y(value)}`)\n\n if (variant === 'scatter') {\n return (\n <g key={entry.name}>\n {entry.values.map((value, index) => (\n <circle\n key={index}\n cx={x(index)}\n cy={y(value)}\n // Radius in viewBox units would stretch with the plot;\n // non-scaling-stroke on a hairline ring keeps it round.\n r={1.6}\n fill={colour}\n vectorEffect=\"non-scaling-stroke\"\n />\n ))}\n </g>\n )\n }\n\n if (variant === 'stacked-bar') {\n const groupWidth = PLOT.width / entry.values.length\n return (\n <g key={entry.name}>\n {entry.values.map((value, index) => {\n const below = series\n .slice(0, seriesIndex)\n .reduce((sum, other) => sum + (other.values[index] ?? 0), 0)\n const top = y(below + value)\n const bottom = y(below)\n return (\n <rect\n key={index}\n x={index * groupWidth + groupWidth * 0.15}\n y={top}\n width={groupWidth * 0.7}\n height={Math.max(bottom - top, 0.5)}\n fill={colour}\n />\n )\n })}\n </g>\n )\n }\n\n if (variant === 'bar') {\n const groupWidth = PLOT.width / entry.values.length\n const barWidth = (groupWidth * 0.7) / series.length\n return (\n <g key={entry.name}>\n {entry.values.map((value, index) => (\n <rect\n key={index}\n x={index * groupWidth + groupWidth * 0.15 + seriesIndex * barWidth}\n y={y(value)}\n width={barWidth}\n height={Math.max(PLOT.height - y(value), 0.5)}\n fill={colour}\n />\n ))}\n </g>\n )\n }\n\n return (\n <g key={entry.name}>\n {variant === 'area' && (\n <polygon\n points={[\n `0,${PLOT.height}`,\n ...points,\n `${PLOT.width},${PLOT.height}`,\n ].join(' ')}\n fill={colour}\n opacity={0.14}\n />\n )}\n <polyline\n points={points.join(' ')}\n fill=\"none\"\n stroke={colour}\n strokeWidth={1.75}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n vectorEffect=\"non-scaling-stroke\"\n />\n </g>\n )\n })}\n </svg>\n </div>\n\n {labels && (\n <div\n className=\"text-muted-foreground/70 flex justify-between text-[10px]\"\n style={{ marginInlineStart: axis ? '2.5rem' : undefined }}\n aria-hidden=\"true\"\n >\n {labels.map((label, index) => (\n <span key={index}>{label}</span>\n ))}\n </div>\n )}\n\n {showLegend && (\n <figcaption className=\"flex flex-wrap gap-x-4 gap-y-1\">\n {series.map((entry, index) => (\n <span\n key={entry.name}\n className=\"text-muted-foreground flex items-center gap-1.5 text-xs\"\n >\n <span\n aria-hidden=\"true\"\n className=\"size-2 rounded-full [corner-shape:round]\"\n style={{ backgroundColor: entry.color ?? dataFills[index % dataFills.length] }}\n />\n {entry.name}\n </span>\n ))}\n </figcaption>\n )}\n </figure>\n )\n}\n\nexport { Chart }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "checkbox",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Checkbox",
|
|
5
|
+
"description": "A binary choice. A real native checkbox under a styled box, so labels, forms, keyboard and the indeterminate state all work.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"class-variance-authority@^0.7.1",
|
|
9
|
+
"lucide-react@^1.39.0"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/checkbox.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import { useEffect, useRef, type ComponentProps, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { Check, Minus } from 'lucide-react'\nimport { checkIconSize, checkSize, indicatorBase } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A real `<input type=\"checkbox\">` visually replaced by a sibling box.\n *\n * Keeping the native input (screen-reader-only, not hidden) means form\n * submission, the label association, keyboard toggling and the indeterminate\n * state all come for free — a div with `role=\"checkbox\"` would have to\n * reimplement every one of them.\n */\nconst boxVariants = cva(\n [\n indicatorBase,\n 'peer-checked:bg-primary peer-checked:border-primary peer-checked:text-primary-foreground',\n 'peer-indeterminate:bg-primary peer-indeterminate:border-primary peer-indeterminate:text-primary-foreground',\n 'peer-focus-visible:border-ring peer-focus-visible:ring-ring/50 peer-focus-visible:ring-[3px]',\n // The tick is painted by the box, so it must not intercept the click.\n '[&_svg]:pointer-events-none',\n // `peer-*` only reaches siblings of the input, and the tick is a descendant\n // of the box — so the box carries the variant and targets the svg itself.\n '[&_svg]:opacity-0',\n 'peer-checked:[&_svg]:opacity-100 peer-indeterminate:[&_svg]:opacity-100',\n ].join(' '),\n {\n variants: {\n size: {\n sm: `${checkSize.sm} ${checkIconSize.sm}`,\n default: `${checkSize.default} ${checkIconSize.default}`,\n lg: `${checkSize.lg} ${checkIconSize.lg}`,\n },\n error: { true: 'border-destructive', false: '' },\n },\n defaultVariants: { size: 'default', error: false },\n },\n)\n\ntype CheckboxProps = Omit<ComponentProps<'input'>, 'size' | 'type'> &\n VariantProps<typeof boxVariants> & {\n /** Text beside the box; also makes the whole row clickable. */\n label?: ReactNode\n description?: ReactNode\n /** Visually a dash, and `indeterminate` to assistive tech. */\n indeterminate?: boolean\n /** `start` puts the label before the box, for a settings row. */\n labelPosition?: 'start' | 'end'\n containerClassName?: string\n }\n\nfunction Checkbox({\n className,\n containerClassName,\n size,\n error,\n label,\n description,\n indeterminate = false,\n labelPosition = 'end',\n ref,\n ...props\n}: CheckboxProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n\n // `indeterminate` exists only as a DOM property — there is no attribute.\n useEffect(() => {\n if (inputRef.current) inputRef.current.indeterminate = indeterminate\n }, [indeterminate])\n\n const text = (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\n return (\n <label\n data-slot=\"checkbox\"\n className={cn(\n 'group flex w-fit items-start gap-2.5 select-none',\n props.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',\n containerClassName,\n )}\n >\n <input\n type=\"checkbox\"\n ref={(node) => {\n inputRef.current = node\n if (typeof ref === 'function') ref(node)\n else if (ref) ref.current = node\n }}\n aria-invalid={error || undefined}\n className=\"peer sr-only\"\n {...props}\n />\n\n {labelPosition === 'start' && text}\n\n <span className={cn(boxVariants({ size, error }), className)}>\n {indeterminate ? <Minus /> : <Check />}\n </span>\n\n {labelPosition === 'end' && text}\n </label>\n )\n}\n\nexport { Checkbox, boxVariants as checkboxVariants }\nexport type { CheckboxProps }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "checkout-summary",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Checkout Summary",
|
|
5
|
+
"description": "The order total, broken into its lines. A charge that is not yet known renders as \"calculated at next step\" rather than as £0.00 — showing zero for a charge you will apply is a promise you cannot keep.",
|
|
6
|
+
"category": "Commerce",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"fmt",
|
|
10
|
+
"lib-styles",
|
|
11
|
+
"lib-utils"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/checkout-summary.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 * The order total, broken into the lines that make it.\n *\n * Every adjustment is its own row. A total that silently includes tax and\n * shipping is the single most common cause of an abandoned basket, and in\n * several jurisdictions showing tax as a separate line is not optional.\n *\n * Amounts are integer minor units, and the total is summed here rather than\n * accepted as a prop. A total that disagrees with the rows above it is a\n * customer-service ticket at best.\n *\n * Unknown values are supported explicitly: shipping that depends on an address\n * not yet given renders as \"calculated at next step\" rather than as zero.\n * Showing £0.00 for a charge that will be applied is a promise you cannot keep.\n */\nexport type CheckoutLine = {\n id: string\n label: ReactNode\n /** Minor units. Negative for a discount. `null` when not yet known. */\n amount: number | null\n note?: ReactNode\n /** Renders in the discount tone. Inferred from a negative amount otherwise. */\n discount?: boolean\n}\n\nfunction CheckoutSummary({\n subtotal,\n lines = [],\n currency = 'USD',\n locale = 'en-GB',\n totalLabel = 'Total',\n subtotalLabel = 'Subtotal',\n pendingLabel = 'calculated at next step',\n pendingNote = 'Final total confirmed once delivery details are entered.',\n footer,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n /** Minor units, before adjustments. */\n subtotal: number\n lines?: CheckoutLine[]\n currency?: string\n locale?: string\n totalLabel?: ReactNode\n subtotalLabel?: ReactNode\n /** Stands in for an amount that is not yet known. */\n pendingLabel?: ReactNode\n /** Note shown while any line is still pending. */\n pendingNote?: ReactNode\n footer?: ReactNode\n}) {\n const money = (minor: number) => (\n <Fmt type=\"currency\" value={minor / 100} currency={currency} locale={locale} decimals={2} />\n )\n\n // Summed from the rows shown, so the two cannot disagree.\n const total = lines.reduce((sum, line) => sum + (line.amount ?? 0), subtotal)\n const pending = lines.some((line) => line.amount === null)\n\n return (\n <div\n data-slot=\"checkout-summary\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <dl className=\"flex flex-col gap-2 text-sm\">\n <div className=\"flex items-baseline gap-3\">\n <dt className=\"text-muted-foreground min-w-0 flex-1\">{subtotalLabel}</dt>\n <dd className=\"shrink-0 tabular-nums\">{money(subtotal)}</dd>\n </div>\n\n {lines.map((line) => {\n const isDiscount = line.discount ?? (line.amount !== null && line.amount < 0)\n return (\n <div key={line.id} className=\"flex items-baseline gap-3\">\n <dt className=\"text-muted-foreground min-w-0 flex-1\">\n {line.label}\n {line.note && (\n <span className=\"text-muted-foreground/70 block text-xs\">{line.note}</span>\n )}\n </dt>\n <dd\n className={cn(\n 'shrink-0 tabular-nums',\n // Never £0.00 for a charge that is coming.\n line.amount === null && 'text-muted-foreground text-xs',\n isDiscount && 'text-[var(--green-soft-foreground)]',\n )}\n >\n {line.amount === null ? pendingLabel : money(line.amount)}\n </dd>\n </div>\n )\n })}\n\n <div className=\"border-border flex items-baseline gap-3 border-t pt-3\">\n <dt className=\"min-w-0 flex-1 font-medium\">{totalLabel}</dt>\n <dd className=\"shrink-0 text-base font-semibold tabular-nums\">\n {money(total)}\n {pending && <span className=\"text-muted-foreground ms-1 text-xs font-normal\">+</span>}\n </dd>\n </div>\n </dl>\n\n {pending && (\n <p className=\"text-muted-foreground text-xs\">\n {pendingNote}\n </p>\n )}\n\n {footer && <div className=\"flex flex-col gap-2\">{footer}</div>}\n </div>\n )\n}\n\nexport { CheckoutSummary }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "citations",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Citations",
|
|
5
|
+
"description": "Numbered sources for a generated answer. A marker is a real anchor with an accessible name, because a bare superscript \"3\" is meaningless read aloud — exactly when checking a claim matters most.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"hover-card",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/citations.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { ExternalLink } from 'lucide-react'\nimport { HoverCard } from '@/components/ui/hover-card'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Numbered sources for a generated answer.\n *\n * A citation marker is a link to something the reader can verify, so it is a\n * real anchor with an accessible name — \"Source 3: Tailwind v4 release notes\",\n * not a bare \"3\". A superscript number alone is meaningless read aloud, which\n * is exactly the case where checking a claim matters most.\n *\n * The hover preview is supplementary. Everything it shows also appears in the\n * source list, because hover is not available to every reader.\n *\n * `scope` namespaces the anchor ids. Two answers on one page each carry their\n * own source list, and without a scope both would mint `#citation-3` — giving\n * duplicate ids and markers that jump to the wrong list.\n */\nexport type Citation = {\n id: string\n title: string\n url?: string\n /** Quoted passage or summary shown on hover. */\n snippet?: ReactNode\n source?: string\n}\n\n/** An inline marker. Index is 1-based to match the list below. */\nfunction CitationMark({\n citation,\n index,\n scope = 'citation',\n className,\n ...props\n}: Omit<ComponentProps<'a'>, 'children'> & {\n citation: Citation\n index: number\n /** Must match the `scope` on the matching Citations list. */\n scope?: string\n}) {\n const mark = (\n <a\n href={citation.url ?? `#${scope}-${citation.id}`}\n aria-label={`Source ${index}: ${citation.title}`}\n className={cn(\n 'bg-secondary text-muted-foreground hover:text-foreground ms-0.5 inline-flex h-4 min-w-4 items-center justify-center px-1 align-super text-[10px] font-medium tabular-nums',\n radius.xs,\n interactive,\n focusRing,\n className,\n )}\n {...props}\n >\n {index}\n </a>\n )\n\n if (!citation.snippet) return mark\n\n return (\n <HoverCard\n content={\n <div className=\"flex flex-col gap-1\">\n <p className=\"text-sm font-medium\">{citation.title}</p>\n {citation.source && (\n <p className=\"text-muted-foreground text-xs\">{citation.source}</p>\n )}\n <p className=\"text-muted-foreground text-xs\">{citation.snippet}</p>\n </div>\n }\n >\n {mark}\n </HoverCard>\n )\n}\n\n/** The numbered list under an answer. */\nfunction Citations({\n citations,\n label = 'Sources',\n scope = 'citation',\n className,\n ...props\n}: ComponentProps<'section'> & {\n citations: Citation[]\n label?: ReactNode\n /** Namespaces the anchor ids. Give each list on a page its own. */\n scope?: string\n}) {\n if (citations.length === 0) return null\n\n return (\n <section\n data-slot=\"citations\"\n className={cn('flex flex-col gap-1.5', className)}\n {...props}\n >\n <h3 className=\"text-muted-foreground text-xs font-medium tracking-wide uppercase\">\n {label}\n </h3>\n\n <ol className=\"flex list-none flex-col gap-1\">\n {citations.map((citation, index) => (\n <li\n key={citation.id}\n id={`${scope}-${citation.id}`}\n className=\"flex gap-2 text-sm\"\n >\n <span className=\"text-muted-foreground/70 min-w-4 shrink-0 text-end text-xs tabular-nums\">\n {index + 1}\n </span>\n <span className=\"min-w-0 flex-1\">\n {citation.url ? (\n <a\n href={citation.url}\n className={cn(\n 'hover:text-foreground inline-flex items-center gap-1 underline-offset-4 hover:underline',\n focusRing,\n radius.xs,\n )}\n >\n {citation.title}\n <ExternalLink className=\"size-3 shrink-0\" aria-hidden=\"true\" />\n </a>\n ) : (\n citation.title\n )}\n {citation.source && (\n <span className=\"text-muted-foreground ms-1.5 text-xs\">\n {citation.source}\n </span>\n )}\n </span>\n </li>\n ))}\n </ol>\n </section>\n )\n}\n\nexport { CitationMark, Citations }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "code-block",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Code Block",
|
|
5
|
+
"description": "Syntax-highlighted source with an optional title or file path, copy, inline editing, line numbers and collapsing.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-highlighter",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-use-clipboard",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/code-block.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n type UIEvent,\n} from 'react'\nimport {\n Check,\n ChevronDown,\n ChevronUp,\n Copy,\n FileCode2,\n RotateCcw,\n} from 'lucide-react'\nimport type { HighlightOptions, Language } from '@/lib/highlighter'\nimport {\n controlSize,\n focusRingInset,\n ghostControl,\n radius,\n surface,\n} from '@/lib/styles'\nimport { useClipboard } from '@/lib/use-clipboard'\nimport { cn } from '@/lib/utils'\n\ntype CodeBlockProps = Omit<\n ComponentProps<'div'>,\n 'children' | 'onChange' | 'defaultValue'\n> & {\n /** Source to render. Acts as the initial value when `editable`. */\n code: string\n language?: Language\n /** Free-form header label, e.g. \"Variants\". */\n title?: string\n /** A path, e.g. \"src/components/ui/button.tsx\". Occupies the same header\n * slot as `title` but renders the directory muted and the file emphasized. */\n filePath?: string\n /** Show the language chip. On by default whenever the header has a label. */\n showLanguage?: boolean\n /** Show the copy button. */\n copyable?: boolean\n /** Let the reader type into the block; highlighting follows every keystroke. */\n editable?: boolean\n /**\n * A control bar under the code — a Run button, a language switch, a status\n * line. Sits below the expand row, so \"show more\" stays attached to the code\n * it expands rather than being pushed away from it by the controls.\n */\n footer?: ReactNode\n /** Fires on every edit. Only meaningful with `editable`. */\n onCodeChange?: (code: string) => void\n /** Show a reset-to-original button once the code has been edited. */\n resettable?: boolean\n /** Gutter with 1-based line numbers. */\n lineNumbers?: boolean\n /** Soft-wrap long lines instead of scrolling horizontally. */\n wrap?: boolean\n /** 1-based lines to emphasize. */\n highlightLines?: number[]\n /** Collapse past this many lines behind a \"Show more\" toggle. */\n maxLines?: number\n /**\n * Keep the code surface at least this tall, in lines. Mostly for `editable`:\n * an editor that starts one line high and grows is a fiddly target, and the\n * caret has nowhere to land when the buffer is empty.\n */\n minLines?: number\n /** Force the header on or off. Defaults to on when there is anything in it. */\n header?: boolean\n}\n\n/** Both layers must share these exactly or the edit caret drifts off the text. */\nconst SURFACE = 'font-mono text-[13px] leading-[var(--code-lh)] py-4'\nconst LAYER = '[&_pre]:!bg-transparent'\nconst WRAPPED = '[&_.line]:break-words [&_pre]:whitespace-pre-wrap'\n\nfunction CodeBlock({\n code,\n language = 'tsx',\n title,\n filePath,\n showLanguage,\n copyable = true,\n editable = false,\n footer,\n onCodeChange,\n resettable = editable,\n lineNumbers = false,\n wrap = false,\n highlightLines,\n maxLines,\n minLines,\n header,\n className,\n ...props\n}: CodeBlockProps) {\n const [value, setValue] = useState(code)\n const [expanded, setExpanded] = useState(false)\n const { copy, copied } = useClipboard()\n\n // Re-seed when the caller swaps in different source. Adjusting during render\n // (rather than in an effect) avoids a throwaway pass with stale code.\n const [seed, setSeed] = useState(code)\n if (seed !== code) {\n setSeed(code)\n setValue(code)\n }\n\n const html = useHighlighted(value, language, { highlightLines })\n\n const highlightRef = useRef<HTMLDivElement>(null)\n const dirty = value !== code\n\n const lineCount = value.split('\\n').length\n const collapsible = maxLines !== undefined && lineCount > maxLines\n const measure = useMeasuredHeight(collapsible)\n const contentHeight = measure.height\n\n /**\n * The highlighted and plain layers are separate elements that swap once\n * shiki resolves. A ref object alone would leave the ResizeObserver attached\n * to the one that just unmounted — the measured height would go stale, and\n * expanding would animate to it, collapsing the block instead of opening it.\n * A callback ref re-attaches on every swap.\n */\n const attachLayer = useCallback(\n (node: HTMLDivElement | null) => {\n highlightRef.current = node\n measure.observe(node)\n },\n [measure],\n )\n\n const showReset = resettable && dirty\n const label = title ?? filePath\n const showHeader =\n header ?? Boolean(label || copyable || showReset || editable)\n // The chip would just repeat itself when the language is already the label.\n const withLanguage = showLanguage ?? Boolean(label)\n\n // Line numbers live in a ::before pseudo-element, so the editable overlay\n // needs the same gutter reserved on its left edge.\n const padding = {\n paddingLeft: lineNumbers ? 'calc(1rem + 3.5ch)' : '1rem',\n paddingRight: '1rem',\n }\n\n function handleScroll(event: UIEvent<HTMLTextAreaElement>) {\n const target = event.currentTarget\n const layer = highlightRef.current\n if (!layer) return\n\n layer.scrollTop = target.scrollTop\n layer.scrollLeft = target.scrollLeft\n }\n\n return (\n <div\n data-slot=\"code-block\"\n data-editable={editable || undefined}\n className={cn(\n surface,\n radius.surface,\n 'bg-muted/40 relative overflow-hidden text-sm [--code-lh:20px]',\n lineNumbers && 'code-block--numbers',\n className,\n )}\n {...props}\n >\n {showHeader && (\n <div className=\"border-border text-muted-foreground flex items-center justify-between gap-2 border-b p-2\">\n <div className=\"flex min-w-0 items-center gap-2\">\n {filePath ? (\n <FilePath path={filePath} />\n ) : (\n // With no label the language holds the slot — unless the chip is\n // already showing it on the right.\n (label || !withLanguage) && (\n <span className=\"truncate font-mono text-xs\">\n {label ?? language}\n </span>\n )\n )}\n </div>\n <div className=\"flex shrink-0 items-center gap-1\">\n {withLanguage && (\n <span\n className={cn(\n 'border-border text-muted-foreground border px-1.5 py-0.5 font-mono text-[10px] leading-none',\n radius.xs,\n )}\n >\n {language}\n </span>\n )}\n {showReset && (\n <IconButton\n label=\"Reset code\"\n onClick={() => {\n setValue(code)\n onCodeChange?.(code)\n }}\n >\n <RotateCcw className=\"size-3.5\" />\n </IconButton>\n )}\n {copyable && (\n <IconButton\n label={copied ? 'Copied' : 'Copy code'}\n onClick={() => copy(value)}\n >\n {copied ? (\n <Check className=\"size-3.5\" />\n ) : (\n <Copy className=\"size-3.5\" />\n )}\n </IconButton>\n )}\n </div>\n </div>\n )}\n\n <div\n className={cn(\n 'relative',\n collapsible &&\n 'overflow-hidden transition-[max-height] duration-300 ease-out motion-reduce:transition-none',\n )}\n style={{\n // Both bounds are expressed in lines against the same token, and set\n // here because `--code-lh` is declared on this component — a caller\n // one level up cannot resolve it.\n ...(minLines\n ? { minHeight: `calc(${minLines} * var(--code-lh) + 2rem)` }\n : undefined),\n ...(collapsible\n ? {\n // A pixel target on both ends — `auto` cannot be transitioned.\n maxHeight: expanded\n ? `${contentHeight}px`\n : `calc(${maxLines} * var(--code-lh) + 2rem)`,\n }\n : undefined),\n }}\n >\n {/*\n Two separate elements, not one that swaps props. Toggling between\n `children` and `dangerouslySetInnerHTML` on a single node makes React\n try to remove a child that innerHTML has already destroyed, so the\n highlighted markup can silently fail to appear. Distinct keys mean one\n unmounts and the other mounts instead.\n */}\n {html ? (\n <div\n key=\"highlighted\"\n ref={attachLayer}\n aria-hidden={editable || undefined}\n className={cn(SURFACE, LAYER, wrap ? WRAPPED : 'overflow-x-auto')}\n style={padding}\n // Markup comes from shiki, generated from `value` — never raw input.\n dangerouslySetInnerHTML={{ __html: html }}\n />\n ) : (\n <div\n key=\"plain\"\n ref={attachLayer}\n aria-hidden={editable || undefined}\n className={cn(SURFACE, LAYER, wrap ? WRAPPED : 'overflow-x-auto')}\n style={padding}\n >\n <pre>{value}</pre>\n </div>\n )}\n\n {editable && (\n <textarea\n value={value}\n spellCheck={false}\n wrap={wrap ? 'soft' : 'off'}\n aria-label={label ? `${label} source` : 'Editable code'}\n onScroll={handleScroll}\n onChange={(event) => {\n setValue(event.target.value)\n onCodeChange?.(event.target.value)\n }}\n className={cn(\n SURFACE,\n 'caret-foreground absolute inset-0 resize-none overflow-auto bg-transparent text-transparent',\n focusRingInset,\n wrap ? 'whitespace-pre-wrap' : 'whitespace-pre',\n )}\n style={padding}\n />\n )}\n\n {collapsible && (\n <div\n aria-hidden\n className={cn(\n 'from-card pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t to-transparent transition-opacity duration-300 motion-reduce:transition-none',\n expanded && 'opacity-0',\n )}\n />\n )}\n </div>\n\n {collapsible && (\n <button\n type=\"button\"\n onClick={() => setExpanded((v) => !v)}\n className={cn(\n 'border-border text-muted-foreground hover:text-foreground flex w-full items-center justify-center gap-1 border-t p-2 text-xs',\n focusRingInset,\n )}\n >\n {expanded\n ? 'Show less'\n : `Show ${lineCount - (maxLines ?? 0)} more lines`}\n {expanded ? (\n <ChevronUp className=\"size-3.5\" />\n ) : (\n <ChevronDown className=\"size-3.5\" />\n )}\n </button>\n )}\n\n {/* Square padding, matching the header and the expand row: every chrome\n strip insets its contents by the same amount on all four edges, so a\n control sits the same distance from the left edge as from the top. */}\n {footer && (\n <div className=\"border-border text-muted-foreground flex flex-wrap items-center gap-2 border-t p-2\">\n {footer}\n </div>\n )}\n </div>\n )\n}\n\n/**\n * Render a path with its directory de-emphasized, so the file name stays the\n * thing you read first. A bare name (no slash) renders as-is.\n */\nfunction FilePath({ path }: { path: string }) {\n const index = path.lastIndexOf('/')\n const dir = index === -1 ? '' : path.slice(0, index + 1)\n const file = index === -1 ? path : path.slice(index + 1)\n\n return (\n <span className=\"flex min-w-0 items-center gap-1.5 font-mono text-xs\">\n <FileCode2 className=\"size-3.5 shrink-0 opacity-60\" />\n <span className=\"truncate\">\n {dir && <span className=\"opacity-60\">{dir}</span>}\n <span className=\"text-foreground\">{file}</span>\n </span>\n </span>\n )\n}\n\nfunction IconButton({\n label,\n children,\n ...props\n}: ComponentProps<'button'> & { label: string }) {\n return (\n <button\n type=\"button\"\n aria-label={label}\n className={cn(\n 'inline-flex items-center justify-center',\n controlSize.iconXs,\n ghostControl,\n focusRingInset,\n )}\n {...props}\n >\n {children}\n </button>\n )\n}\n\n/**\n * Track an element's rendered height so the collapse can animate to a real\n * pixel value.\n *\n * Returns an `observe` callback rather than taking a ref, so the caller can\n * hand it whichever element is currently mounted. Re-measures on edits and on\n * late-arriving highlighting.\n */\nfunction useMeasuredHeight(enabled: boolean) {\n const [height, setHeight] = useState(0)\n const observer = useRef<ResizeObserver | null>(null)\n\n const observe = useCallback(\n (node: HTMLElement | null) => {\n observer.current?.disconnect()\n observer.current = null\n if (!node || !enabled) return\n\n const read = () => setHeight(node.getBoundingClientRect().height)\n observer.current = new ResizeObserver(read)\n observer.current.observe(node)\n read()\n },\n [enabled],\n )\n\n useEffect(() => () => observer.current?.disconnect(), [])\n\n return useMemo(() => ({ height, observe }), [height, observe])\n}\n\n/** Highlight off the render path; falls back to plain text until ready. */\nfunction useHighlighted(\n code: string,\n language: Language,\n { highlightLines }: HighlightOptions = {},\n) {\n const [html, setHtml] = useState<string | null>(null)\n const lines = highlightLines?.join(',')\n\n useEffect(() => {\n let stale = false\n\n // Loaded on demand so shiki stays out of the initial bundle.\n import('@/lib/highlighter').then(\n ({ highlight }) =>\n highlight(code, language, {\n highlightLines: lines ? lines.split(',').map(Number) : undefined,\n }).then(\n (result) => {\n if (!stale) setHtml(result)\n },\n onFailure,\n ),\n onFailure,\n )\n\n function onFailure(error: unknown) {\n // Falling back to plain text is silent by nature — say so, or a missing\n // highlight looks like a styling bug.\n console.warn('[CodeBlock] highlighting failed, showing plain text', error)\n if (!stale) setHtml(null)\n }\n\n return () => {\n stale = true\n }\n }, [code, language, lines])\n\n return html\n}\n\nexport { CodeBlock }\nexport type { CodeBlockProps }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "code-search",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Code Search",
|
|
5
|
+
"description": "Search hits grouped by file, with the match marked inside its line. Ranges are clamped on render, because a wrong highlight is worse than none — it looks right.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/code-search.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, ChevronRight, FileCode } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Search hits grouped by file, with the match marked in its own line.\n *\n * Matches carry a column range rather than pre-split strings, so the same\n * result can be rendered here, counted, and jumped to in an editor without the\n * caller keeping three shapes of the same data in sync.\n *\n * Ranges are clamped on render. An off-by-one from a backend that counts\n * columns from one would otherwise slice the line silently and show the wrong\n * text — a wrong highlight is worse than no highlight, because it looks right.\n */\nexport type CodeMatch = {\n line: number\n text: string\n /** 0-based [start, end) within `text`. */\n range?: [number, number]\n}\n\nexport type CodeSearchFile = {\n path: string\n matches: CodeMatch[]\n language?: string\n}\n\nfunction MatchLine({ match, query }: { match: CodeMatch; query?: string }) {\n let start: number\n let end: number\n\n if (match.range) {\n start = Math.max(0, Math.min(match.range[0], match.text.length))\n end = Math.max(start, Math.min(match.range[1], match.text.length))\n } else if (query) {\n const at = match.text.toLowerCase().indexOf(query.toLowerCase())\n start = at === -1 ? 0 : at\n end = at === -1 ? 0 : at + query.length\n } else {\n start = 0\n end = 0\n }\n\n return (\n <div className=\"hover:bg-accent/40 flex gap-3 px-3 py-1 font-mono text-xs transition-colors duration-150 ease-out motion-reduce:transition-none\">\n <span className=\"text-muted-foreground/50 w-10 shrink-0 text-end tabular-nums select-none\">\n {match.line}\n </span>\n <span className=\"min-w-0 flex-1 truncate whitespace-pre\">\n {match.text.slice(0, start)}\n {end > start && (\n <mark className=\"bg-[var(--amber)]/30 text-foreground rounded-sm\">\n {match.text.slice(start, end)}\n </mark>\n )}\n {match.text.slice(end)}\n </span>\n </div>\n )\n}\n\nfunction CodeSearch({\n files,\n query,\n defaultCollapsed = false,\n onOpen,\n empty,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n files: CodeSearchFile[]\n /** Used to locate the hit when a match carries no explicit range. */\n query?: string\n defaultCollapsed?: boolean\n onOpen?: (path: string, line: number) => void\n empty?: ReactNode\n}) {\n const [collapsed, setCollapsed] = useState<string[]>(\n defaultCollapsed ? files.map((file) => file.path) : [],\n )\n\n const total = files.reduce((sum, file) => sum + file.matches.length, 0)\n\n if (files.length === 0) {\n return <div className={className}>{empty ?? null}</div>\n }\n\n return (\n <div\n data-slot=\"code-search\"\n className={cn('flex flex-col gap-2', className)}\n {...props}\n >\n <p className=\"text-muted-foreground text-xs\">\n {total} {total === 1 ? 'result' : 'results'} in {files.length}{' '}\n {files.length === 1 ? 'file' : 'files'}\n </p>\n\n {files.map((file) => {\n const open = !collapsed.includes(file.path)\n const Chevron = open ? ChevronDown : ChevronRight\n\n return (\n <div\n key={file.path}\n className={cn('border-border overflow-hidden border', radius.surface)}\n >\n <button\n type=\"button\"\n aria-expanded={open}\n onClick={() =>\n setCollapsed((current) =>\n open\n ? [...current, file.path]\n : current.filter((path) => path !== file.path),\n )\n }\n className={cn(\n 'bg-muted/40 hover:bg-muted flex w-full items-center gap-2 p-3',\n interactive,\n focusRing,\n )}\n >\n <Chevron className=\"text-muted-foreground size-4 shrink-0\" />\n <FileCode className=\"text-muted-foreground size-4 shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate text-start font-mono text-xs\">\n {file.path}\n </span>\n {file.language && <Badge size=\"sm\">{file.language}</Badge>}\n <Badge size=\"sm\">{file.matches.length}</Badge>\n </button>\n\n {open && (\n <div className=\"divide-border/50 divide-y\">\n {file.matches.map((match, index) =>\n onOpen ? (\n <button\n key={index}\n type=\"button\"\n onClick={() => onOpen(file.path, match.line)}\n className={cn('block w-full text-start', focusRing)}\n >\n <MatchLine match={match} query={query} />\n </button>\n ) : (\n <MatchLine key={index} match={match} query={query} />\n ),\n )}\n </div>\n )}\n </div>\n )\n })}\n </div>\n )\n}\n\nexport { CodeSearch }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cohort-table",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Cohort Table",
|
|
5
|
+
"description": "Retention by signup period, as a triangle. Cells are shaded by rate rather than by count, so a small cohort that retains well is not washed out by a large one that does not.",
|
|
6
|
+
"category": "Analytics",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/cohort-table.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A retention cohort grid — signup period down, periods since across.\n *\n * Incomplete cohorts are marked rather than plotted. The newest cohort has not\n * lived long enough to have a week-4 number, and rendering an empty cell as 0%\n * makes retention look like it fell off a cliff. This is the single most common\n * way a cohort chart lies.\n *\n * Period 0 is always 100% by definition, so it is rendered as the cohort size\n * instead — that cell is the only place the absolute number appears, and\n * without it every percentage is unanchored.\n */\nexport type Cohort = {\n /** Period label — \"Jan 2026\", \"Week 12\". */\n label: ReactNode\n /** People in the cohort at period 0. */\n size: number\n /** Retained counts per period, index 0 being the cohort period. */\n values: (number | null)[]\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_PERIOD_LABEL: (index: number) => ReactNode = (index) => `${index}`\n\nfunction CohortTable({\n cohorts,\n periods,\n periodLabel = DEFAULT_PERIOD_LABEL,\n locale = 'en-GB',\n cohortHeader = 'Cohort',\n sizeHeader = 'Size',\n averageLabel = 'Average',\n notReachedLabel = 'Not yet reached',\n footnote = 'A dot means the cohort has not reached that period yet — not zero retention. Averages count only cohorts that have.',\n className,\n ...props\n}: ComponentProps<'div'> & {\n cohorts: Cohort[]\n /** How many period columns to render. */\n periods?: number\n periodLabel?: (index: number) => ReactNode\n locale?: string\n cohortHeader?: ReactNode\n sizeHeader?: ReactNode\n /** Label on the averages row. */\n averageLabel?: ReactNode\n /** Accessible name for a period a cohort has not reached. */\n notReachedLabel?: string\n /** Note under the table. Pass `null` to drop it. */\n footnote?: ReactNode\n}) {\n const columns = periods ?? Math.max(...cohorts.map((c) => c.values.length), 1)\n const num = new Intl.NumberFormat(locale)\n\n const averages = useMemo(\n () =>\n Array.from({ length: columns }, (_, index) => {\n // Only complete cohorts contribute — otherwise the average drifts\n // downward as young cohorts are counted as zero.\n const present = cohorts\n .map((cohort) => {\n const value = cohort.values[index]\n return value === null || value === undefined ? null : value / cohort.size\n })\n .filter((v): v is number => v !== null)\n return present.length\n ? present.reduce((sum, v) => sum + v, 0) / present.length\n : null\n }),\n [cohorts, columns],\n )\n\n return (\n <div\n data-slot=\"cohort-table\"\n className={cn(surface, radius.surface, 'w-full overflow-x-auto', className)}\n {...props}\n >\n <table className=\"w-full text-sm\">\n <thead>\n <tr className=\"border-border border-b\">\n <th className=\"text-muted-foreground sticky start-0 bg-[var(--card)] px-3 py-2 text-start text-xs font-medium\">\n {cohortHeader}\n </th>\n <th className=\"text-muted-foreground px-3 py-2 text-end text-xs font-medium\">{sizeHeader}</th>\n {Array.from({ length: columns }, (_, index) => (\n <th\n key={index}\n className=\"text-muted-foreground px-3 py-2 text-center text-xs font-medium whitespace-nowrap\"\n >\n {periodLabel(index)}\n </th>\n ))}\n </tr>\n </thead>\n\n <tbody>\n {cohorts.map((cohort, rowIndex) => (\n <tr key={rowIndex} className=\"border-border/60 border-b\">\n <th className=\"sticky start-0 bg-[var(--card)] px-3 py-2 text-start text-sm font-medium whitespace-nowrap\">\n {cohort.label}\n </th>\n <td className=\"text-muted-foreground px-3 py-2 text-end tabular-nums\">\n {num.format(cohort.size)}\n </td>\n\n {Array.from({ length: columns }, (_, index) => {\n const raw = cohort.values[index]\n // Not yet reached — never rendered as zero.\n if (raw === null || raw === undefined) {\n return (\n <td key={index} className=\"px-3 py-2 text-center\">\n <span className=\"text-muted-foreground/25\" aria-label={notReachedLabel}>\n ·\n </span>\n </td>\n )\n }\n\n const share = cohort.size > 0 ? raw / cohort.size : 0\n return (\n <td\n key={index}\n style={{\n backgroundColor: `color-mix(in oklab, var(--blue), transparent ${\n 100 - Math.round(share * 55)\n }%)`,\n }}\n className=\"px-3 py-2 text-center tabular-nums\"\n title={`${num.format(raw)} of ${num.format(cohort.size)}`}\n >\n {Math.round(share * 100)}%\n </td>\n )\n })}\n </tr>\n ))}\n </tbody>\n\n <tfoot>\n <tr className=\"border-border border-t-2\">\n <th className=\"sticky start-0 bg-[var(--card)] px-3 py-2 text-start text-xs font-medium\">\n {averageLabel}\n </th>\n <td />\n {averages.map((average, index) => (\n <td key={index} className=\"px-3 py-2 text-center text-xs font-medium tabular-nums\">\n {average === null ? '—' : `${Math.round(average * 100)}%`}\n </td>\n ))}\n </tr>\n </tfoot>\n </table>\n\n <p className=\"border-border text-muted-foreground border-t p-3 text-xs\">\n A dot means the cohort has not reached that period yet — not zero\n retention. Averages count only cohorts that have.\n </p>\n </div>\n )\n}\n\nexport { CohortTable }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "collapsible",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Collapsible",
|
|
5
|
+
"description": "A single panel that expands and collapses. The open height is measured rather than assumed, because height: auto cannot be transitioned.",
|
|
6
|
+
"category": "Navigation",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"primitive-collapsible",
|
|
14
|
+
"primitive-slot"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/collapsible.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import {\n createContext,\n use,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport { useCollapsibleHeight } from '@/components/primitives/collapsible'\nimport { Slot } from '@/components/primitives/slot'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/** A panel that expands and collapses, driven by its own trigger. */\ntype CollapsibleContextValue = {\n open: boolean\n setOpen: (open: boolean) => void\n ids: { trigger: string; content: string }\n}\n\nconst CollapsibleContext = createContext<CollapsibleContextValue | null>(null)\n\nfunction useCollapsible() {\n const context = use(CollapsibleContext)\n if (!context) throw new Error('Must be used inside <Collapsible>')\n return context\n}\n\nfunction Collapsible({\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n className,\n children,\n ...props\n}: ComponentProps<'div'> & {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n children: ReactNode\n}) {\n const controlled = openProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultOpen)\n const open = controlled ? openProp : uncontrolled\n const id = useId()\n\n const context = useMemo<CollapsibleContextValue>(\n () => ({\n open,\n ids: { trigger: `${id}-trigger`, content: `${id}-content` },\n setOpen: (next) => {\n if (!controlled) setUncontrolled(next)\n onOpenChange?.(next)\n },\n }),\n [open, id, controlled, onOpenChange],\n )\n\n return (\n <CollapsibleContext value={context}>\n <div\n data-slot=\"collapsible\"\n data-state={open ? 'open' : 'closed'}\n className={className}\n {...props}\n >\n {children}\n </div>\n </CollapsibleContext>\n )\n}\n\nfunction CollapsibleTrigger({\n className,\n asChild = false,\n showChevron = true,\n children,\n onClick,\n ...props\n}: ComponentProps<'button'> & { asChild?: boolean; showChevron?: boolean }) {\n const { open, setOpen, ids } = useCollapsible()\n const Comp = asChild ? Slot : 'button'\n\n return (\n <Comp\n type=\"button\"\n id={ids.trigger}\n data-slot=\"collapsible-trigger\"\n aria-expanded={open}\n aria-controls={ids.content}\n onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n onClick?.(event)\n if (!event.defaultPrevented) setOpen(!open)\n }}\n className={cn(\n 'flex w-full cursor-pointer items-center justify-between gap-2 text-start text-sm font-medium',\n radius.control,\n focusRing,\n className,\n )}\n {...props}\n >\n {children}\n {showChevron && !asChild && (\n <ChevronDown\n className={cn(\n 'text-muted-foreground size-4 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none',\n open && 'rotate-180',\n )}\n />\n )}\n </Comp>\n )\n}\n\nfunction CollapsibleContent({\n className,\n children,\n ...props\n}: ComponentProps<'div'>) {\n const { open, ids } = useCollapsible()\n const innerRef = useRef<HTMLDivElement>(null)\n const height = useCollapsibleHeight(innerRef, open)\n\n return (\n <div\n id={ids.content}\n role=\"region\"\n aria-labelledby={ids.trigger}\n data-slot=\"collapsible-content\"\n style={{ height }}\n className={cn(\n 'overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none',\n className,\n )}\n {...props}\n >\n {/* Measured separately, so the animating wrapper never affects the\n natural height being measured. */}\n <div ref={innerRef}>{children}</div>\n </div>\n )\n}\n\nexport { Collapsible, CollapsibleContent, CollapsibleTrigger }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "color-picker",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Color Picker",
|
|
5
|
+
"description": "A palette first, free entry second. Most product colour pickers choose a label or category colour, where an arbitrary hex is a liability — a gradient invites a colour nobody can read text on.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"label-picker",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"primitive-dismissable",
|
|
15
|
+
"primitive-popper"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/color-picker.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import { useRef, useState, type ComponentProps } from 'react'\nimport { Check, Pipette, X } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport { readableInk } from '@/components/ui/label-picker'\nimport {\n fieldBase,\n fieldInput,\n fieldSize,\n focusRing,\n menuSurface,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Pick a colour from a swatch set, or type one.\n *\n * A palette first, free entry second. Most product colour pickers exist to\n * choose a label or a category colour, where an arbitrary hex is a liability —\n * a saturation-value gradient invites a colour nobody can read text on.\n *\n * The native `input[type=color]` is offered as an escape hatch rather than the\n * main control: it opens the OS picker, which cannot be styled and looks\n * nothing like the rest of a form.\n *\n * Swatch ticks use `readableInk`, shared with `LabelPicker`, so the check mark\n * is visible on both a pale yellow and a navy.\n *\n * `clearable` exists because \"no colour\" is a real value, not an empty state —\n * a tint that falls back to a named colour set, a label with no colour yet. The\n * trigger says so in words rather than showing an empty swatch.\n */\nconst DEFAULT_SWATCHES = [\n '#ef4444', '#f97316', '#f59e0b', '#eab308',\n '#84cc16', '#22c55e', '#10b981', '#14b8a6',\n '#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1',\n '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',\n '#f43f5e', '#78716c', '#64748b', '#0f172a',\n]\n\nconst HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i\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_SWATCH_LABEL: (colour: string) => string = (colour) => `Colour ${colour}`\n\nfunction ColorPicker({\n value: valueProp,\n defaultValue = '#3b82f6',\n onValueChange,\n swatches = DEFAULT_SWATCHES,\n allowCustom = true,\n clearable = false,\n unsetLabel = 'Unset',\n chooseLabel = 'Choose a colour',\n clearLabel = 'Clear colour',\n hexLabel = 'Hex colour',\n systemPickerLabel = 'Open the system colour picker',\n swatchLabel = DEFAULT_SWATCH_LABEL,\n size = 'md',\n variant = 'default',\n disabled = false,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange'> & {\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n swatches?: string[]\n /** Show the hex field and the native picker. */\n allowCustom?: boolean\n /** Allow clearing back to no colour. */\n clearable?: boolean\n unsetLabel?: string\n /** Accessible name for the trigger when nothing is chosen. */\n chooseLabel?: string\n clearLabel?: string\n /** Accessible name for the hex field. */\n hexLabel?: string\n systemPickerLabel?: string\n /** Accessible name for a swatch, given its value. */\n swatchLabel?: (colour: string) => string\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n disabled?: boolean\n}) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n\n const [draft, setDraft] = useState(value)\n const [open, setOpen] = useState(false)\n const triggerRef = useRef<HTMLButtonElement>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n\n const { style } = usePopper({\n open,\n anchorRef: triggerRef,\n floatingRef: panelRef,\n side: 'bottom',\n align: 'start',\n offset: 4,\n })\n\n useDismissable({\n open,\n onDismiss: () => {\n setOpen(false)\n triggerRef.current?.focus()\n },\n refs: [triggerRef, panelRef],\n })\n\n function commit(next: string) {\n if (!controlled) setUncontrolled(next)\n setDraft(next)\n onValueChange?.(next)\n }\n\n const VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'hover:bg-accent border border-transparent bg-transparent',\n }[variant]\n\n return (\n <div data-slot=\"color-picker\" className={cn('relative w-full', className)} {...props}>\n <button\n ref={triggerRef}\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n // Named explicitly: with no value the trigger has no text of its own,\n // which would leave the button unnamed.\n aria-label={value ? swatchLabel(value) : chooseLabel}\n disabled={disabled}\n onClick={() => setOpen(!open)}\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT,\n 'justify-start text-start',\n 'disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n <span\n aria-hidden=\"true\"\n style={value ? { backgroundColor: value } : undefined}\n className={cn(\n 'border-border size-4 shrink-0 border',\n radius.xs,\n // No colour reads as a struck-through swatch, not a white one.\n !value && 'bg-secondary relative overflow-hidden',\n )}\n >\n {!value && (\n <span className=\"bg-border absolute inset-x-[-2px] top-1/2 h-px rotate-45\" />\n )}\n </span>\n\n <span\n className={cn(\n 'min-w-0 flex-1 truncate font-mono text-sm uppercase',\n !value && 'text-muted-foreground/70 normal-case',\n )}\n >\n {value || unsetLabel}\n </span>\n\n {clearable && value && (\n <span\n role=\"button\"\n tabIndex={-1}\n aria-label={clearLabel}\n onClick={(event) => {\n event.stopPropagation()\n commit('')\n }}\n className=\"text-muted-foreground hover:text-foreground flex size-4 shrink-0 items-center justify-center rounded-sm\"\n >\n <X className=\"size-3\" />\n </span>\n )}\n </button>\n\n {open && (\n <div\n ref={panelRef}\n style={style}\n role=\"dialog\"\n aria-label={chooseLabel}\n className={cn(menuSurface, radius.surface, 'w-60 p-2')}\n >\n <div className=\"grid grid-cols-5 gap-1.5\">\n {swatches.map((swatch) => {\n const selected = swatch.toLowerCase() === value.toLowerCase()\n return (\n <button\n key={swatch}\n type=\"button\"\n aria-label={swatch}\n aria-pressed={selected}\n style={{ backgroundColor: swatch }}\n onClick={() => commit(swatch)}\n className={cn(\n 'flex aspect-square items-center justify-center',\n radius.xs,\n focusRing,\n 'transition-transform duration-150 ease-out motion-reduce:transition-none',\n )}\n >\n {selected && (\n <Check className=\"size-3.5\" style={{ color: readableInk(swatch) }} />\n )}\n </button>\n )\n })}\n </div>\n\n {allowCustom && (\n <div className=\"border-border mt-2 flex items-center gap-1.5 border-t pt-2\">\n <span\n aria-hidden=\"true\"\n style={{ backgroundColor: HEX.test(draft) ? draft : value }}\n className={cn('border-border size-6 shrink-0 border', radius.xs)}\n />\n\n <input\n value={draft}\n aria-label={hexLabel}\n spellCheck={false}\n onChange={(event) => {\n const next = event.target.value\n setDraft(next)\n // Only commit a value that parses, so a half-typed hex does\n // not repaint everything mid-keystroke.\n if (HEX.test(next)) commit(next)\n }}\n className={cn(\n fieldInput,\n 'border-border h-7 min-w-0 flex-1 border px-2 font-mono text-xs uppercase',\n radius.xs,\n )}\n />\n\n <label\n className={cn(\n 'text-muted-foreground hover:text-foreground hover:bg-accent flex size-7 shrink-0 cursor-pointer items-center justify-center',\n radius.xs,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n )}\n >\n <Pipette className=\"size-3.5\" />\n <span className=\"sr-only\">{systemPickerLabel}</span>\n <input\n type=\"color\"\n value={HEX.test(draft) ? draft : value}\n onChange={(event) => commit(event.target.value)}\n className=\"sr-only\"\n />\n </label>\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport { ColorPicker, DEFAULT_SWATCHES }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "combobox",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Combobox",
|
|
5
|
+
"description": "A Select you can type into. The search box lives inside the panel rather than replacing the trigger, so the chosen value stays visible while you filter.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"primitive-dismissable",
|
|
14
|
+
"primitive-popper"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/combobox.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import {\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n} from 'react'\nimport { Check, ChevronsUpDown, Search } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport {\n fieldBase,\n fieldInput,\n fieldSize,\n menuItem,\n menuSurface,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/** The same three treatments Input and Select offer. */\nconst FIELD_VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'hover:bg-accent border border-transparent bg-transparent',\n} as const\n\nexport type ComboboxOption = {\n value: string\n label: string\n disabled?: boolean\n}\n\n/**\n * A Select you can type into.\n *\n * The trigger is a button until it opens; the search box lives inside the panel\n * rather than replacing the trigger, so the chosen value stays visible while you\n * filter. Highlight is tracked by index and reset on every keystroke, because\n * the list under it changes as you type.\n */\ntype ComboboxProps = Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'> & {\n options: ComboboxOption[]\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n placeholder?: string\n searchPlaceholder?: string\n emptyMessage?: string\n size?: keyof typeof fieldSize\n /** Matches Input and Select, so a form can be uniform. */\n variant?: 'default' | 'secondary' | 'ghost'\n disabled?: boolean\n error?: boolean\n}\n\nfunction Combobox({\n className,\n options,\n value: valueProp,\n defaultValue,\n onValueChange,\n placeholder = 'Select…',\n searchPlaceholder = 'Search…',\n emptyMessage = 'No results',\n size = 'md',\n variant = 'default',\n disabled = false,\n error = false,\n ...props\n}: ComboboxProps) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n\n const [open, setOpen] = useState(false)\n const [query, setQuery] = useState('')\n const [highlighted, setHighlighted] = useState(0)\n\n const triggerRef = useRef<HTMLButtonElement>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n const searchRef = useRef<HTMLInputElement>(null)\n\n const filtered = useMemo(() => {\n const needle = query.trim().toLowerCase()\n if (!needle) return options\n return options.filter((option) =>\n option.label.toLowerCase().includes(needle),\n )\n }, [options, query])\n\n const { style } = usePopper({\n open,\n anchorRef: triggerRef,\n floatingRef: panelRef,\n side: 'bottom',\n align: 'start',\n matchAnchorWidth: true,\n })\n\n useDismissable({\n open,\n onDismiss: () => setOpen(false),\n refs: [triggerRef, panelRef],\n })\n\n // Clearing the search belongs to the open/closed transition, not to a render\n // pass — adjusting during render is React's documented way to say that\n // without an extra effect and the throwaway render it causes.\n const [wasOpen, setWasOpen] = useState(open)\n if (wasOpen !== open) {\n setWasOpen(open)\n if (!open) {\n setQuery('')\n setHighlighted(0)\n }\n }\n\n // Focus is a real DOM side effect, so it stays in one.\n useEffect(() => {\n if (!open) return\n const frame = requestAnimationFrame(() => searchRef.current?.focus())\n return () => cancelAnimationFrame(frame)\n }, [open])\n\n const selected = options.find((option) => option.value === value)\n\n function choose(next: string) {\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n setOpen(false)\n triggerRef.current?.focus()\n }\n\n return (\n <div data-slot=\"combobox\" className={cn('relative w-full', className)} {...props}>\n <button\n ref={triggerRef}\n type=\"button\"\n role=\"combobox\"\n aria-expanded={open}\n aria-haspopup=\"listbox\"\n disabled={disabled}\n onClick={() => setOpen(!open)}\n className={cn(\n fieldBase,\n fieldSize[size],\n 'justify-between text-start',\n FIELD_VARIANT[variant],\n error && 'border-destructive',\n 'disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n <span className={cn('flex-1 truncate', !selected && 'text-muted-foreground/70')}>\n {selected?.label ?? placeholder}\n </span>\n <ChevronsUpDown className=\"text-muted-foreground shrink-0\" />\n </button>\n\n {open && (\n <div\n ref={panelRef}\n style={style}\n className={cn(menuSurface, radius.surface, 'p-0')}\n >\n <div className=\"border-border flex items-center gap-2 border-b px-3\">\n <Search className=\"text-muted-foreground size-3.5 shrink-0\" />\n <input\n ref={searchRef}\n value={query}\n placeholder={searchPlaceholder}\n onChange={(event) => {\n setQuery(event.target.value)\n // The list under the highlight just changed; anchoring to the\n // top is the only index guaranteed to still exist.\n setHighlighted(0)\n }}\n onKeyDown={(event) => {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setHighlighted((i) => Math.min(i + 1, filtered.length - 1))\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n setHighlighted((i) => Math.max(i - 1, 0))\n } else if (event.key === 'Enter') {\n event.preventDefault()\n const option = filtered[highlighted]\n if (option && !option.disabled) choose(option.value)\n } else if (event.key === 'Escape') {\n event.preventDefault()\n setOpen(false)\n triggerRef.current?.focus()\n }\n }}\n className={cn(fieldInput, 'h-9 text-sm')}\n />\n </div>\n\n <div role=\"listbox\" className=\"max-h-56 overflow-auto p-1\">\n {filtered.map((option, index) => (\n <div\n key={option.value}\n role=\"option\"\n aria-selected={option.value === value}\n data-highlighted={index === highlighted}\n data-disabled={option.disabled}\n onPointerEnter={() => setHighlighted(index)}\n onClick={() => !option.disabled && choose(option.value)}\n className={cn(menuItem, radius.control)}\n >\n <span className=\"flex-1 truncate\">{option.label}</span>\n {option.value === value && <Check className=\"size-3.5 shrink-0\" />}\n </div>\n ))}\n\n {filtered.length === 0 && (\n <p className=\"text-muted-foreground px-2.5 py-6 text-center text-sm\">\n {emptyMessage}\n </p>\n )}\n </div>\n </div>\n )}\n </div>\n )\n}\n\nexport { Combobox }\nexport type { ComboboxProps }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "command",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Command",
|
|
5
|
+
"description": "A command palette: type to filter, arrows to move, Enter to run. Items are data rather than children, because filtering has to reorder and hide them.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"kbd",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"primitive-dialog"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/command.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import {\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Search } from 'lucide-react'\nimport { DialogProvider, useDialog } from '@/components/primitives/dialog'\nimport { Kbd } from '@/components/ui/kbd'\nimport {\n fieldInput,\n menuItem,\n radius,\n surface,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A command palette: type to filter, arrows to move, Enter to run.\n *\n * Items are data rather than children, because filtering has to reorder and\n * hide them — doing that over arbitrary JSX means walking the tree and guessing\n * what is searchable. Groups collapse when every item in them is filtered out.\n */\nexport type CommandItem = {\n id: string\n label: string\n group?: string\n shortcut?: string\n icon?: ReactNode\n keywords?: string\n disabled?: boolean\n onSelect?: () => void\n}\n\nfunction score(item: CommandItem, query: string) {\n const needle = query.trim().toLowerCase()\n if (!needle) return 0\n const haystack = `${item.label} ${item.keywords ?? ''} ${item.group ?? ''}`.toLowerCase()\n const at = haystack.indexOf(needle)\n // A prefix match on the label ranks above a match anywhere else.\n if (item.label.toLowerCase().startsWith(needle)) return 0\n return at === -1 ? -1 : at + 1\n}\n\nfunction CommandList({\n items,\n emptyMessage = 'No results',\n onRun,\n className,\n autoFocus = true,\n placeholder = 'Type a command or search…',\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n items: CommandItem[]\n emptyMessage?: string\n placeholder?: string\n autoFocus?: boolean\n onRun?: (item: CommandItem) => void\n}) {\n const [query, setQuery] = useState('')\n const [active, setActive] = useState(0)\n const inputRef = useRef<HTMLInputElement>(null)\n const listRef = useRef<HTMLDivElement>(null)\n\n const results = useMemo(() => {\n const scored = items\n .map((item) => ({ item, rank: score(item, query) }))\n .filter((entry) => entry.rank !== -1)\n .sort((a, b) => a.rank - b.rank)\n return scored.map((entry) => entry.item)\n }, [items, query])\n\n // Group in result order, so a group's position follows its best match.\n const groups = useMemo(() => {\n const map = new Map<string, CommandItem[]>()\n for (const item of results) {\n const key = item.group ?? ''\n if (!map.has(key)) map.set(key, [])\n map.get(key)!.push(item)\n }\n return [...map.entries()]\n }, [results])\n\n const flat = groups.flatMap(([, group]) => group)\n\n useEffect(() => {\n if (!autoFocus) return\n const frame = requestAnimationFrame(() => inputRef.current?.focus())\n return () => cancelAnimationFrame(frame)\n }, [autoFocus])\n\n useEffect(() => {\n listRef.current\n ?.querySelector('[data-highlighted=\"true\"]')\n ?.scrollIntoView({ block: 'nearest' })\n }, [active])\n\n function run(item: CommandItem) {\n if (item.disabled) return\n item.onSelect?.()\n onRun?.(item)\n }\n\n return (\n <div data-slot=\"command\" className={cn('flex flex-col', className)} {...props}>\n <div className=\"border-border flex items-center gap-2 border-b px-4\">\n <Search className=\"text-muted-foreground size-4 shrink-0\" />\n <input\n ref={inputRef}\n value={query}\n placeholder={placeholder}\n aria-label={placeholder}\n onChange={(event) => {\n setQuery(event.target.value)\n // The filtered list just changed under the highlight; index 0 is\n // the only position guaranteed to still exist.\n setActive(0)\n }}\n onKeyDown={(event) => {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setActive((i) => Math.min(i + 1, flat.length - 1))\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n setActive((i) => Math.max(i - 1, 0))\n } else if (event.key === 'Enter') {\n event.preventDefault()\n const item = flat[active]\n if (item) run(item)\n }\n }}\n className={cn(fieldInput, 'h-12 text-sm')}\n />\n </div>\n\n <div ref={listRef} className=\"max-h-80 overflow-auto p-2\">\n {flat.length === 0 && (\n <p className=\"text-muted-foreground py-10 text-center text-sm\">\n {emptyMessage}\n </p>\n )}\n\n {groups.map(([group, groupItems]) => (\n <div key={group || 'ungrouped'} className=\"mb-1 last:mb-0\">\n {group && (\n <div className=\"text-muted-foreground px-2.5 py-1.5 text-[11px] font-medium tracking-wide uppercase\">\n {group}\n </div>\n )}\n {groupItems.map((item) => {\n const index = flat.indexOf(item)\n return (\n <div\n key={item.id}\n role=\"option\"\n aria-selected={index === active}\n data-highlighted={index === active}\n data-disabled={item.disabled}\n onPointerEnter={() => setActive(index)}\n onClick={() => run(item)}\n className={cn(menuItem, radius.control)}\n >\n {item.icon}\n <span className=\"flex-1 truncate\">{item.label}</span>\n {item.shortcut && <Kbd keys={item.shortcut} />}\n </div>\n )\n })}\n </div>\n ))}\n </div>\n </div>\n )\n}\n\n/** The palette in a modal, which is how it is normally used. */\nfunction CommandDialog({\n open,\n onOpenChange,\n items,\n ...props\n}: {\n open?: boolean\n onOpenChange?: (open: boolean) => void\n items: CommandItem[]\n} & ComponentProps<typeof CommandList>) {\n return (\n <DialogProvider open={open} onOpenChange={onOpenChange}>\n <CommandDialogContent items={items} {...props} />\n </DialogProvider>\n )\n}\n\nfunction CommandDialogContent({\n items,\n label = 'Command palette',\n ...props\n}: {\n items: CommandItem[]\n /** Accessible name for the palette dialog. */\n label?: string\n} & ComponentProps<typeof CommandList>) {\n const { dialogRef, setOpen } = useDialog()\n\n return (\n <dialog\n ref={dialogRef}\n data-slot=\"command-dialog\"\n aria-label={label}\n onClick={(event) => {\n if (event.target === event.currentTarget) setOpen(false)\n }}\n className={cn(\n surface,\n radius.panel,\n 'text-card-foreground mx-auto mt-[12vh] mb-auto w-[min(36rem,100vw-2rem)] p-0',\n 'backdrop:bg-transparent',\n )}\n >\n <CommandList items={items} onRun={() => setOpen(false)} {...props} />\n </dialog>\n )\n}\n\nexport { CommandDialog, CommandList }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "commit-graph",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Commit Graph",
|
|
5
|
+
"description": "The branch graph beside a commit list. Lanes are held by the commit each one is waiting for, which is what keeps a branch on a single vertical line instead of drifting sideways at every merge.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"avatar",
|
|
10
|
+
"fmt",
|
|
11
|
+
"lib-utils"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/commit-graph.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Fmt } from '@/components/ui/fmt'\nimport { cn } from '@/lib/utils'\n\n/**\n * The branch graph beside a commit list.\n *\n * Lanes are assigned by walking the list newest-first and keeping a set of\n * \"open\" lanes, each waiting for a specific commit. When a commit arrives it\n * takes the lane that was waiting for it — that is what keeps a branch on one\n * vertical line instead of drifting sideways every merge.\n *\n * A commit with two parents keeps its lane for the first and opens a new one\n * for the second, which is why a merge shows as a curve rejoining the trunk.\n *\n * Rendered as one SVG per row rather than a single tall SVG, so rows can carry\n * ordinary DOM content beside the graph and stay selectable.\n */\nexport type GraphCommit = {\n sha: string\n message: ReactNode\n parents: string[]\n author?: string\n date?: Date\n /** Branch or tag heads to show as chips. */\n refs?: ReactNode\n}\n\nconst LANE_COLORS = [\n 'var(--blue)',\n 'var(--violet)',\n 'var(--green)',\n 'var(--amber)',\n 'var(--cyan)',\n 'var(--rose)',\n]\n\ntype Row = {\n commit: GraphCommit\n lane: number\n /** Lanes occupied while this row is drawn, for the pass-through lines. */\n through: number[]\n /** Lane a merge edge curves into, if any. */\n merge?: number\n}\n\nfunction layout(commits: GraphCommit[]): { rows: Row[]; width: number } {\n // `open[i]` is the sha the lane at index i is waiting for.\n const open: (string | undefined)[] = []\n const rows: Row[] = []\n\n const claim = (sha: string) => {\n const existing = open.indexOf(sha)\n if (existing !== -1) return existing\n const free = open.indexOf(undefined)\n const index = free === -1 ? open.length : free\n open[index] = sha\n return index\n }\n\n for (const commit of commits) {\n const lane = claim(commit.sha)\n const through = open\n .map((value, index) => (value !== undefined ? index : -1))\n .filter((index) => index !== -1)\n\n // The first parent inherits this lane; the rest open their own.\n open[lane] = commit.parents[0]\n let merge: number | undefined\n for (const parent of commit.parents.slice(1)) {\n merge = claim(parent)\n }\n\n rows.push({ commit, lane, through, merge })\n }\n\n return { rows, width: Math.max(1, open.length) }\n}\n\nconst LANE_WIDTH = 14\n/**\n * Rows are a fixed height and the SVG is drawn to exactly that.\n *\n * The lanes are per-row SVGs, so a row that grows to fit its text leaves the\n * next one's lines starting below where this one's ended — a visible break in\n * every branch. Pinning the height is what makes the segments meet.\n */\nconst ROW_HEIGHT = 56\n\nfunction CommitGraph({\n commits,\n locale = 'en-GB',\n className,\n ...props\n}: ComponentProps<'div'> & {\n commits: GraphCommit[]\n locale?: string\n}) {\n const { rows, width } = useMemo(() => layout(commits), [commits])\n const graphWidth = width * LANE_WIDTH + LANE_WIDTH\n\n return (\n <div\n data-slot=\"commit-graph\"\n className={cn('flex flex-col', className)}\n {...props}\n >\n {rows.map((row, index) => {\n const x = (lane: number) => lane * LANE_WIDTH + LANE_WIDTH / 2\n const colour = LANE_COLORS[row.lane % LANE_COLORS.length]\n const last = index === rows.length - 1\n\n return (\n <div\n key={row.commit.sha}\n className=\"flex items-stretch gap-3\"\n style={{ height: ROW_HEIGHT }}\n >\n <svg\n width={graphWidth}\n height={ROW_HEIGHT}\n aria-hidden=\"true\"\n className=\"shrink-0 overflow-visible\"\n >\n {/* Lines for every lane still open through this row. */}\n {row.through.map((lane) => (\n <line\n key={lane}\n x1={x(lane)}\n y1={0}\n x2={x(lane)}\n y2={last && lane === row.lane ? ROW_HEIGHT / 2 : ROW_HEIGHT}\n stroke={LANE_COLORS[lane % LANE_COLORS.length]}\n strokeWidth={1.5}\n opacity={0.5}\n />\n ))}\n\n {row.merge !== undefined && (\n <path\n d={`M ${x(row.lane)} ${ROW_HEIGHT / 2} C ${x(row.lane)} ${ROW_HEIGHT * 0.8}, ${x(row.merge)} ${ROW_HEIGHT * 0.7}, ${x(row.merge)} ${ROW_HEIGHT}`}\n fill=\"none\"\n stroke={LANE_COLORS[row.merge % LANE_COLORS.length]}\n strokeWidth={1.5}\n opacity={0.5}\n />\n )}\n\n <circle\n cx={x(row.lane)}\n cy={ROW_HEIGHT / 2}\n r={row.commit.parents.length > 1 ? 5 : 4}\n fill={row.commit.parents.length > 1 ? 'var(--background)' : colour}\n stroke={colour}\n strokeWidth={2}\n />\n </svg>\n\n <div className=\"border-border/60 flex min-w-0 flex-1 items-center gap-2 overflow-hidden border-b\">\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-1.5\">\n <span className=\"truncate text-sm\">{row.commit.message}</span>\n {row.commit.refs}\n </div>\n <p className=\"text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs\">\n <code className=\"font-mono\">{row.commit.sha.slice(0, 7)}</code>\n {row.commit.author && <>· {row.commit.author}</>}\n {row.commit.date && (\n <>\n ·{' '}\n <Fmt type=\"relative\" value={row.commit.date} locale={locale} />\n </>\n )}\n </p>\n </div>\n {row.commit.author && (\n <Avatar size=\"xs\" name={row.commit.author} className=\"shrink-0\" />\n )}\n </div>\n </div>\n )\n })}\n </div>\n )\n}\n\nexport { CommitGraph }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "commit-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Commit List",
|
|
5
|
+
"description": "A history, grouped by day. The grouping is the point — a flat list of forty commits is unreadable, and \"when\" is the axis people actually scan along.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"avatar",
|
|
12
|
+
"badge",
|
|
13
|
+
"button",
|
|
14
|
+
"diff-stat",
|
|
15
|
+
"lib-styles",
|
|
16
|
+
"lib-utils",
|
|
17
|
+
"tooltip"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "components/ui/commit-list.tsx",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, CircleDot, GitCommitHorizontal, ShieldCheck, X } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { DiffStat } from '@/components/ui/diff-stat'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A list of commits, grouped by day.\n *\n * The grouping is the point: a flat list of forty commits is unreadable, and\n * \"when\" is the axis people actually scan a history along.\n */\nexport type Commit = {\n sha: string\n message: string\n author: string\n /** Any date-ish value; grouped by local day. */\n date: Date\n status?: 'passed' | 'failed' | 'running'\n verified?: boolean\n additions?: number\n deletions?: number\n body?: ReactNode\n}\n\nconst STATUS = {\n passed: { icon: Check, color: 'text-[var(--green-soft-foreground)]', label: 'Checks passed' },\n failed: { icon: X, color: 'text-[var(--destructive-soft-foreground)]', label: 'Checks failed' },\n running: { icon: CircleDot, color: 'text-[var(--blue-soft-foreground)]', label: 'Checks running' },\n} as const\n\nfunction dayKey(date: Date) {\n return date.toDateString()\n}\n\nfunction CommitList({\n verifiedLabel = 'Verified',\n className,\n commits,\n locale = 'en-GB',\n onSelect,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n commits: Commit[]\n locale?: string\n onSelect?: (sha: string) => void\n /** Badge on a signed commit. */\n verifiedLabel?: ReactNode\n}) {\n // Preserve the caller's order within each day; only group, never re-sort.\n const groups: [string, Commit[]][] = []\n for (const commit of commits) {\n const key = dayKey(commit.date)\n const last = groups.at(-1)\n if (last?.[0] === key) last[1].push(commit)\n else groups.push([key, [commit]])\n }\n\n const heading = new Intl.DateTimeFormat(locale, {\n weekday: 'long',\n day: 'numeric',\n month: 'long',\n })\n const time = new Intl.DateTimeFormat(locale, {\n hour: '2-digit',\n minute: '2-digit',\n })\n\n return (\n <div data-slot=\"commit-list\" className={cn('space-y-5', className)} {...props}>\n {groups.map(([key, group]) => (\n <section key={key} className=\"space-y-2\">\n <h3 className=\"text-muted-foreground flex items-center gap-2 text-xs font-medium\">\n <GitCommitHorizontal className=\"size-3.5\" />\n {heading.format(group[0].date)}\n </h3>\n\n {/* `overflow-hidden`: the rows run edge to edge, so any row\n background would otherwise square off the container's corners. */}\n <div\n className={cn(\n surface,\n radius.panel,\n 'divide-border divide-y overflow-hidden',\n )}\n >\n {group.map((commit) => {\n const status = commit.status ? STATUS[commit.status] : null\n const StatusIcon = status?.icon\n\n return (\n <div\n key={commit.sha}\n className=\"flex items-start gap-3 p-3.5\"\n data-slot=\"commit\"\n >\n <Avatar size=\"sm\" name={commit.author} />\n\n <div className=\"min-w-0 flex-1 space-y-1\">\n <button\n type=\"button\"\n onClick={() => onSelect?.(commit.sha)}\n className=\"block max-w-full truncate text-start text-sm font-medium hover:underline\"\n >\n {commit.message}\n </button>\n <p className=\"text-muted-foreground text-xs\">\n {commit.author} committed at {time.format(commit.date)}\n </p>\n {commit.body && (\n <p className=\"text-muted-foreground text-xs\">{commit.body}</p>\n )}\n </div>\n\n <div className=\"flex shrink-0 items-center gap-2\">\n {commit.additions !== undefined && (\n <DiffStat\n additions={commit.additions}\n deletions={commit.deletions ?? 0}\n showCounts={false}\n className=\"hidden sm:inline-flex\"\n />\n )}\n {commit.verified && (\n <Tooltip content=\"Signature verified\">\n <Badge size=\"sm\" color=\"green\" icon={<ShieldCheck />}>\n {verifiedLabel}\n </Badge>\n </Tooltip>\n )}\n {status && StatusIcon && (\n <Tooltip content={status.label}>\n <span className={cn('flex', status.color)}>\n <StatusIcon className=\"size-4\" />\n </span>\n </Tooltip>\n )}\n <Button\n variant=\"secondary\"\n size=\"xs\"\n className=\"font-mono\"\n onClick={() => onSelect?.(commit.sha)}\n >\n {commit.sha.slice(0, 7)}\n </Button>\n </div>\n </div>\n )\n })}\n </div>\n </section>\n ))}\n </div>\n )\n}\n\nexport { CommitList }\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|