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,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seed-phrase",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Seed Phrase",
|
|
5
|
+
"description": "A recovery phrase, blurred until revealed. Deliberately hostile to convenience: no copy button and no selection, because a seed phrase on the clipboard is readable by every page visited afterwards.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/seed-phrase.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Eye, EyeOff, ShieldAlert } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A recovery phrase, blurred until revealed.\n *\n * Deliberately hostile to the convenient thing. There is no copy button and no\n * `select-all`: a seed phrase on the clipboard is readable by every page the\n * user visits afterwards, and clipboard managers persist it to disk. Every\n * wallet that got this wrong has a corresponding class of drainer.\n *\n * Blurred by default with an explicit reveal, because these are read in cafés,\n * on video calls and over shoulders. Revealing is a decision, not the default\n * state.\n *\n * `confirm` mode asks for specific words back — the only way to establish the\n * phrase was actually written down rather than clicked past.\n */\nfunction SeedPhrase({\n words,\n revealed: revealedProp,\n onRevealedChange,\n columns = 3,\n warning,\n confirmIndices,\n onConfirm,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n words: string[]\n revealed?: boolean\n onRevealedChange?: (revealed: boolean) => void\n columns?: 2 | 3 | 4\n warning?: ReactNode\n /** 0-based word positions to ask back. Switches to confirm mode. */\n confirmIndices?: number[]\n onConfirm?: (correct: boolean) => void\n}) {\n const controlled = revealedProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(false)\n const revealed = controlled ? revealedProp : uncontrolled\n\n const [answers, setAnswers] = useState<Record<number, string>>({})\n\n function setRevealed(next: boolean) {\n if (!controlled) setUncontrolled(next)\n onRevealedChange?.(next)\n }\n\n const confirming = confirmIndices && confirmIndices.length > 0\n const allCorrect =\n confirming &&\n confirmIndices.every((i) => (answers[i] ?? '').trim().toLowerCase() === words[i]?.toLowerCase())\n\n const grid = { 2: 'grid-cols-2', 3: 'grid-cols-2 sm:grid-cols-3', 4: 'grid-cols-2 sm:grid-cols-4' }[columns]\n\n return (\n <div\n data-slot=\"seed-phrase\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex items-start gap-2\">\n <ShieldAlert\n className=\"mt-0.5 size-4 shrink-0 text-[var(--amber-soft-foreground)]\"\n aria-hidden=\"true\"\n />\n <p className=\"text-muted-foreground min-w-0 flex-1 text-xs\">\n {warning ??\n 'Write these words down and store them offline. Anyone with this phrase controls the wallet — no support team will ever ask for it.'}\n </p>\n </div>\n\n {confirming ? (\n <div className=\"flex flex-col gap-2\">\n {confirmIndices.map((index) => (\n <label key={index} className=\"flex items-center gap-2 text-sm\">\n <span className=\"text-muted-foreground w-16 shrink-0 text-xs tabular-nums\">\n Word {index + 1}\n </span>\n <input\n value={answers[index] ?? ''}\n autoComplete=\"off\"\n spellCheck={false}\n onChange={(event) =>\n setAnswers((current) => ({ ...current, [index]: event.target.value }))\n }\n className={cn(\n 'border-border bg-background h-8 min-w-0 flex-1 border px-2.5 font-mono text-sm outline-none',\n radius.control,\n 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n )}\n />\n </label>\n ))}\n\n <Button\n className=\"mt-1\"\n disabled={!allCorrect}\n onClick={() => onConfirm?.(Boolean(allCorrect))}\n >\n {allCorrect ? 'Confirm' : 'Enter the words above'}\n </Button>\n </div>\n ) : (\n <>\n <ol\n className={cn('grid list-none gap-2', grid)}\n // No copy affordance and no selection: the clipboard is not a\n // safe place for a recovery phrase.\n style={{ userSelect: revealed ? 'text' : 'none' }}\n >\n {words.map((word, index) => (\n <li\n key={index}\n className={cn(\n 'bg-secondary/60 flex items-baseline gap-2 px-2.5 py-1.5',\n radius.control,\n )}\n >\n <span className=\"text-muted-foreground/60 w-5 shrink-0 text-end text-xs tabular-nums\">\n {index + 1}\n </span>\n <span\n className={cn(\n 'min-w-0 flex-1 truncate font-mono text-sm transition-[filter] duration-150 ease-out motion-reduce:transition-none',\n !revealed && 'blur-sm',\n )}\n >\n {revealed ? word : '••••••'}\n </span>\n </li>\n ))}\n </ol>\n\n <Button variant=\"secondary\" onClick={() => setRevealed(!revealed)}>\n {revealed ? <EyeOff /> : <Eye />}\n {revealed ? 'Hide phrase' : 'Reveal phrase'}\n </Button>\n </>\n )}\n </div>\n )\n}\n\nexport { SeedPhrase }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Select",
|
|
5
|
+
"description": "A custom dropdown, not a native select — the trigger matches Input exactly and the panel is real markup. Full keyboard support, type-ahead, and dismissal on Escape or outside click.",
|
|
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
|
+
"primitive-dismissable",
|
|
15
|
+
"primitive-popper"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/select.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactNode,\n} from 'react'\nimport { Check, ChevronDown, ChevronRight } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper, type Side } from '@/components/primitives/popper'\nimport {\n fieldBase,\n fieldSize,\n menuItem,\n menuSurface,\n radius,\n} from '@/lib/styles'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@/lib/utils'\n\n/**\n * A custom dropdown, not a native `<select>`: the trigger matches Input exactly,\n * and the panel is real markup that can nest.\n *\n * An option with `children` becomes a submenu, and those nest without limit.\n * Each level owns its own open state and highlight rather than flattening into\n * one index — a flat model cannot express \"the third item of the second\n * submenu is focused while its parent row stays highlighted\".\n */\nexport type SelectOption = {\n value: string\n label: string\n disabled?: boolean\n /** Present makes this a submenu rather than a selectable value. */\n children?: SelectOption[]\n}\n\n/** Submenus prefer the right, then the left, then above. */\nconst SUBMENU_SIDES: Side[] = ['left', 'top']\n\nconst triggerVariants = cva([fieldBase, 'justify-between text-start'].join(' '), {\n variants: {\n 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 },\n size: {\n xs: fieldSize.xs,\n sm: fieldSize.sm,\n default: fieldSize.md,\n lg: fieldSize.lg,\n xl: fieldSize.xl,\n },\n error: {\n true: 'border-destructive focus-within:border-destructive',\n false: '',\n },\n },\n defaultVariants: { variant: 'default', size: 'default', error: false },\n})\n\n/** Depth-first walk, so a nested value can be labelled in the trigger. */\nfunction findOption(\n options: SelectOption[],\n value: string | undefined,\n): SelectOption | undefined {\n if (value === undefined) return undefined\n for (const option of options) {\n if (option.value === value && !option.children) return option\n if (option.children) {\n const found = findOption(option.children, value)\n if (found) return found\n }\n }\n return undefined\n}\n\ntype SelectProps = Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'> &\n VariantProps<typeof triggerVariants> & {\n options: SelectOption[]\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n placeholder?: string\n disabled?: boolean\n icon?: ReactNode\n triggerClassName?: string\n /** Shown when `options` is empty. */\n emptyMessage?: ReactNode\n }\n\nfunction Select({\n emptyMessage = 'No options',\n className,\n triggerClassName,\n options,\n value: valueProp,\n defaultValue,\n onValueChange,\n placeholder = 'Select…',\n disabled = false,\n variant,\n size,\n error,\n icon,\n ...props\n}: SelectProps) {\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 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: () => {\n setOpen(false)\n triggerRef.current?.focus()\n },\n refs: [triggerRef, panelRef],\n })\n\n const selected = findOption(options, 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=\"select\" className={cn('relative w-full', 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 onKeyDown={(event) => {\n if (['ArrowDown', 'Enter', ' '].includes(event.key)) {\n event.preventDefault()\n setOpen(true)\n }\n }}\n className={cn(\n triggerVariants({ variant, size, error }),\n 'disabled:pointer-events-none disabled:opacity-50',\n triggerClassName,\n )}\n >\n {icon}\n <span\n className={cn(\n 'min-w-0 flex-1 truncate',\n !selected && 'text-muted-foreground/70',\n )}\n >\n {selected?.label ?? placeholder}\n </span>\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 <OptionList\n emptyMessage={emptyMessage}\n ref={panelRef}\n options={options}\n value={value}\n onChoose={choose}\n onClose={() => {\n setOpen(false)\n triggerRef.current?.focus()\n }}\n style={style}\n autoFocus\n />\n )}\n </div>\n )\n}\n\n/**\n * One level of the menu. Renders its own submenu, recursively.\n *\n * The open submenu is tracked by value rather than index so it survives the\n * list changing underneath it.\n */\nfunction OptionList({\n ref,\n options,\n value,\n onChoose,\n onClose,\n style,\n autoFocus = false,\n labelledBy,\n emptyMessage,\n}: {\n ref?: React.Ref<HTMLDivElement>\n options: SelectOption[]\n value: string | undefined\n onChoose: (value: string) => void\n onClose: () => void\n style?: React.CSSProperties\n autoFocus?: boolean\n labelledBy?: string\n emptyMessage?: ReactNode\n}) {\n const [highlighted, setHighlighted] = useState(0)\n const [openSub, setOpenSub] = useState<string | null>(null)\n const listRef = useRef<HTMLDivElement>(null)\n const rowRefs = useRef<(HTMLDivElement | null)[]>([])\n // Type-ahead is per level: typing \"s\" inside a submenu should search that\n // submenu, not the root list.\n const search = useRef({ query: '', at: 0 })\n\n useEffect(() => {\n if (!autoFocus) return\n // Cancelled on unmount: an uncancelled frame fires against a torn-down\n // ref, and a re-render while one is pending stacks a second.\n const frame = requestAnimationFrame(() => listRef.current?.focus())\n return () => cancelAnimationFrame(frame)\n }, [autoFocus])\n\n useEffect(() => {\n rowRefs.current[highlighted]?.scrollIntoView({ block: 'nearest' })\n }, [highlighted])\n\n const step = (from: number, delta: number) => {\n for (let i = 1; i <= options.length; i++) {\n const next = (from + delta * i + options.length * i) % options.length\n if (!options[next]?.disabled) return next\n }\n return from\n }\n\n function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n const option = options[highlighted]\n\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault()\n setHighlighted(step(highlighted, 1))\n break\n case 'ArrowUp':\n event.preventDefault()\n setHighlighted(step(highlighted, -1))\n break\n case 'ArrowRight':\n if (option?.children) {\n event.preventDefault()\n event.stopPropagation()\n setOpenSub(option.value)\n }\n break\n case 'ArrowLeft':\n event.preventDefault()\n // Closing this level returns focus to the row that opened it.\n onClose()\n break\n case 'Enter':\n case ' ':\n event.preventDefault()\n if (!option || option.disabled) break\n if (option.children) setOpenSub(option.value)\n else onChoose(option.value)\n break\n case 'Escape':\n event.preventDefault()\n onClose()\n break\n default: {\n if (event.key.length !== 1 || event.metaKey || event.ctrlKey) break\n const now = Date.now()\n const state = search.current\n // A pause resets the buffer, so \"ba\" finds Banana but a later \"b\"\n // starts again rather than searching for \"bab\".\n state.query = now - state.at > 600 ? event.key : state.query + event.key\n state.at = now\n\n const match = options.findIndex(\n (o) =>\n !o.disabled &&\n o.label.toLowerCase().startsWith(state.query.toLowerCase()),\n )\n if (match !== -1) {\n event.preventDefault()\n setHighlighted(match)\n }\n }\n }\n }\n\n return (\n <div\n ref={(node) => {\n listRef.current = node\n if (typeof ref === 'function') ref(node)\n else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = node\n }}\n role=\"listbox\"\n tabIndex={-1}\n aria-labelledby={labelledBy}\n onKeyDown={onKeyDown}\n style={style}\n className={cn(menuSurface, radius.surface, 'max-h-60 min-w-44 outline-none')}\n >\n {options.map((option, index) => (\n <OptionRow\n key={option.value}\n ref={(node) => {\n rowRefs.current[index] = node\n }}\n option={option}\n selected={option.value === value}\n highlighted={index === highlighted}\n subOpen={openSub === option.value}\n onHighlight={() => {\n setHighlighted(index)\n // Hovering a leaf closes any open submenu, which is what makes a\n // cascading menu feel like one surface rather than several.\n setOpenSub(option.children ? option.value : null)\n }}\n onActivate={() =>\n option.children ? setOpenSub(option.value) : onChoose(option.value)\n }\n onCloseSub={() => {\n setOpenSub(null)\n listRef.current?.focus()\n }}\n value={value}\n onChoose={onChoose}\n />\n ))}\n\n {options.length === 0 && (\n <div className=\"text-muted-foreground px-2.5 py-1.5 text-sm\">\n {emptyMessage}\n </div>\n )}\n </div>\n )\n}\n\nfunction OptionRow({\n ref,\n option,\n selected,\n highlighted,\n subOpen,\n onHighlight,\n onActivate,\n onCloseSub,\n value,\n onChoose,\n}: {\n ref?: React.Ref<HTMLDivElement>\n option: SelectOption\n selected: boolean\n highlighted: boolean\n subOpen: boolean\n onHighlight: () => void\n onActivate: () => void\n onCloseSub: () => void\n value: string | undefined\n onChoose: (value: string) => void\n}) {\n const rowRef = useRef<HTMLDivElement>(null)\n const subRef = useRef<HTMLDivElement>(null)\n\n const { style } = usePopper({\n open: subOpen,\n anchorRef: rowRef,\n floatingRef: subRef,\n side: 'right',\n align: 'start',\n offset: 2,\n fallbackSides: SUBMENU_SIDES,\n })\n\n return (\n <>\n <div\n ref={(node) => {\n rowRef.current = node\n if (typeof ref === 'function') ref(node)\n else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = node\n }}\n role=\"option\"\n aria-selected={selected}\n aria-haspopup={option.children ? 'listbox' : undefined}\n aria-expanded={option.children ? subOpen : undefined}\n data-highlighted={highlighted}\n data-disabled={option.disabled}\n onPointerEnter={() => !option.disabled && onHighlight()}\n onClick={() => !option.disabled && onActivate()}\n className={cn(menuItem, radius.control)}\n >\n <span className=\"min-w-0 flex-1 truncate\">{option.label}</span>\n {option.children ? (\n <ChevronRight className=\"size-3.5 shrink-0\" />\n ) : (\n selected && <Check className=\"size-3.5 shrink-0\" />\n )}\n </div>\n\n {option.children && subOpen && (\n <OptionList\n ref={subRef}\n options={option.children}\n value={value}\n onChoose={onChoose}\n onClose={onCloseSub}\n style={style}\n autoFocus\n />\n )}\n </>\n )\n}\n\nexport { Select, triggerVariants as selectVariants }\nexport type { SelectProps }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "separator",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Separator",
|
|
5
|
+
"description": "A rule between sections, horizontal or vertical, optionally with a label set into it.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/separator.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\n\ntype SeparatorProps = Omit<ComponentProps<'div'>, 'children'> & {\n orientation?: 'horizontal' | 'vertical'\n /** Text set into the rule, e.g. \"or\". Horizontal only. */\n label?: ReactNode\n /**\n * Purely visual, with no structural meaning. Hidden from assistive tech —\n * which is the right default for a rule between two halves of one thing.\n */\n decorative?: boolean\n}\n\nfunction Separator({\n className,\n orientation = 'horizontal',\n label,\n decorative = true,\n ...props\n}: SeparatorProps) {\n const a11y = decorative\n ? { role: 'none' as const }\n : { role: 'separator' as const, 'aria-orientation': orientation }\n\n if (label && orientation === 'horizontal') {\n return (\n <div\n data-slot=\"separator\"\n className={cn('flex w-full items-center gap-3', className)}\n {...a11y}\n {...props}\n >\n <span className=\"bg-border h-px flex-1\" />\n <span className=\"text-muted-foreground shrink-0 text-xs\">{label}</span>\n <span className=\"bg-border h-px flex-1\" />\n </div>\n )\n }\n\n return (\n <div\n data-slot=\"separator\"\n className={cn(\n 'bg-border shrink-0',\n // Vertical relies on `self-stretch`, not `h-full`. In a flex row the\n // parent's height is usually auto, so `height: 100%` resolves against\n // nothing and the rule collapses to a zero-height line. `align-self:\n // stretch` fills the cross axis regardless, and still yields to an\n // explicit height passed through className.\n orientation === 'horizontal' ? 'h-px w-full' : 'w-px self-stretch',\n className,\n )}\n {...a11y}\n {...props}\n />\n )\n}\n\nexport { Separator }\nexport type { SeparatorProps }\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "service-status",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Service Status",
|
|
5
|
+
"description": "The rows of a status page. The headline banner is derived from the services — a status page whose headline can disagree with its own rows is worse than none.",
|
|
6
|
+
"category": "Operations",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"badge",
|
|
10
|
+
"lib-styles",
|
|
11
|
+
"lib-utils",
|
|
12
|
+
"uptime-strip"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/service-status.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Badge } from '@/components/ui/badge'\nimport { UptimeStrip, type UptimeBucket } from '@/components/ui/uptime-strip'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The rows of a status page: one service, its current state, its history.\n *\n * The overall banner is derived from the services rather than passed in. A\n * status page whose headline can disagree with its own rows is worse than no\n * status page — that is precisely the failure people remember.\n */\nexport type ServiceState = 'operational' | 'degraded' | 'outage' | 'maintenance'\n\nexport type Service = {\n id: string\n name: string\n state: ServiceState\n description?: ReactNode\n /** Usually 90 days. */\n history?: UptimeBucket[]\n uptime?: string\n}\n\nconst STATE = {\n operational: { label: 'Operational', color: 'green', dot: 'bg-[var(--green)]' },\n degraded: { label: 'Degraded', color: 'amber', dot: 'bg-[var(--amber)]' },\n outage: { label: 'Outage', color: 'destructive', dot: 'bg-[var(--destructive)]' },\n maintenance: { label: 'Maintenance', color: 'blue', dot: 'bg-[var(--blue)]' },\n} as const\n\n/** Worst state wins: one outage means the system is not \"all operational\". */\nfunction overallState(services: Service[]): ServiceState {\n if (services.some((s) => s.state === 'outage')) return 'outage'\n if (services.some((s) => s.state === 'degraded')) return 'degraded'\n if (services.some((s) => s.state === 'maintenance')) return 'maintenance'\n return 'operational'\n}\n\nfunction ServiceStatus({\n services,\n showBanner = true,\n className,\n ...props\n}: ComponentProps<'div'> & {\n services: Service[]\n showBanner?: boolean\n}) {\n const overall = overallState(services)\n const banner = STATE[overall]\n\n return (\n <div\n data-slot=\"service-status\"\n className={cn('flex flex-col gap-3', className)}\n {...props}\n >\n {showBanner && (\n <div\n className={cn(\n surface,\n radius.surface,\n 'flex items-center gap-2.5 p-4',\n )}\n >\n <span\n className={cn('size-2.5 shrink-0 rounded-full [corner-shape:round]', banner.dot)}\n aria-hidden=\"true\"\n />\n <p className=\"text-sm font-medium\">\n {overall === 'operational'\n ? 'All systems operational'\n : `${banner.label} — ${services.filter((s) => s.state !== 'operational').length} of ${services.length} services affected`}\n </p>\n </div>\n )}\n\n <ul className={cn(surface, radius.surface, 'list-none divide-y divide-[var(--border)]')}>\n {services.map((service) => {\n const state = STATE[service.state]\n return (\n <li key={service.id} className=\"flex flex-col gap-2 p-4\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span\n className={cn('size-2 shrink-0 rounded-full [corner-shape:round]', state.dot)}\n aria-hidden=\"true\"\n />\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {service.name}\n </span>\n <Badge size=\"sm\" color={state.color}>\n {state.label}\n </Badge>\n </div>\n\n {service.description && (\n <p className=\"text-muted-foreground text-xs\">{service.description}</p>\n )}\n\n {service.history && (\n <UptimeStrip\n buckets={service.history}\n height={20}\n summary={service.uptime}\n aria-label={`${service.name} uptime history`}\n />\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { ServiceStatus, overallState }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "session-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Session List",
|
|
5
|
+
"description": "Active sign-ins, with the current one pinned and unrevokable from here. Signing yourself out of the session you are using is a footgun, so it is simply not offered.",
|
|
6
|
+
"category": "Auth",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"fmt",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/session-list.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Globe, Laptop, Smartphone, Tablet, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Active sessions, revocable.\n *\n * The current session is marked and cannot be revoked from this list —\n * revoking it logs you out mid-action, which reads as a crash rather than a\n * choice. \"Sign out everywhere else\" is the safe form of that intent and is\n * offered separately.\n *\n * Location is labelled as approximate. It comes from IP geolocation, which is\n * routinely a different city and sometimes a different country; presenting it\n * as fact makes people hunt for a breach that is actually a VPN endpoint.\n */\nexport type Session = {\n id: string\n device: 'desktop' | 'mobile' | 'tablet' | 'unknown'\n browser?: ReactNode\n os?: ReactNode\n ip?: string\n /** Approximate, from IP geolocation. */\n location?: ReactNode\n lastActive?: Date\n createdAt?: Date\n current?: boolean\n suspicious?: boolean\n}\n\nconst ICON = {\n desktop: Laptop,\n mobile: Smartphone,\n tablet: Tablet,\n unknown: Globe,\n} as const\n\n/**\n * Default formatters at module scope — an inline arrow default is a value\n * the React Compiler cannot reorder, and it bails on the whole component.\n */\nconst DEFAULT_SIGN_OUT_OTHERS_LABEL = (count: number) =>\n `Sign out ${count} other ${count === 1 ? 'session' : 'sessions'}`\n\nfunction SessionList({\n sessions,\n onRevoke,\n onRevokeOthers,\n now,\n locale = 'en-GB',\n title = 'Active sessions',\n currentLabel = 'This device',\n suspiciousLabel = 'Unusual',\n unknownBrowserLabel = 'Unknown browser',\n onLabel = 'on',\n approxLabel = '(approx.)',\n activeLabel = 'active',\n signOutLabel = 'Sign out',\n signOutOthersLabel = DEFAULT_SIGN_OUT_OTHERS_LABEL,\n confirmNote = 'This device will need to sign in again.',\n confirmLabel = 'Sign out',\n cancelLabel = 'Cancel',\n className,\n ...props\n}: ComponentProps<'div'> & {\n sessions: Session[]\n onRevoke?: (id: string) => void\n onRevokeOthers?: () => void\n now?: Date\n locale?: string\n title?: ReactNode\n currentLabel?: ReactNode\n suspiciousLabel?: ReactNode\n /** Stand-in when a session reports no browser. */\n unknownBrowserLabel?: ReactNode\n /** Joins browser and OS — \"Chrome 141 on macOS\". */\n onLabel?: ReactNode\n /** Qualifies the IP-derived location. */\n approxLabel?: ReactNode\n /** Precedes the last-active time. */\n activeLabel?: ReactNode\n signOutLabel?: ReactNode\n /** Takes the count, so the plural is yours to form. */\n signOutOthersLabel?: (count: number) => ReactNode\n confirmNote?: ReactNode\n confirmLabel?: ReactNode\n cancelLabel?: ReactNode\n}) {\n const [confirming, setConfirming] = useState<string | null>(null)\n const others = sessions.filter((session) => !session.current).length\n\n return (\n <div\n data-slot=\"session-list\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-wrap items-center gap-2 border-b p-3\">\n <span className=\"text-sm font-medium\">{title}</span>\n <Badge size=\"sm\">{sessions.length}</Badge>\n {onRevokeOthers && others > 0 && (\n <Button variant=\"secondary\" size=\"xs\" className=\"ms-auto\" onClick={onRevokeOthers}>\n {signOutOthersLabel(others)}\n </Button>\n )}\n </div>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {sessions.map((session) => {\n const Icon = ICON[session.device]\n return (\n <li key={session.id} className=\"flex flex-col gap-2 p-3\">\n <div className=\"flex items-start gap-3\">\n <Icon className=\"text-muted-foreground mt-0.5 size-5 shrink-0\" aria-hidden=\"true\" />\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex flex-wrap items-center gap-2 text-sm font-medium\">\n {session.browser ?? unknownBrowserLabel}\n {session.os && (\n <span className=\"text-muted-foreground font-normal\">\n {onLabel} {session.os}\n </span>\n )}\n {session.current && (\n <Badge size=\"sm\" color=\"green\">\n {currentLabel}\n </Badge>\n )}\n {session.suspicious && (\n <Badge size=\"sm\" color=\"amber\">\n <TriangleAlert />\n {suspiciousLabel}\n </Badge>\n )}\n </p>\n\n <p className=\"text-muted-foreground mt-0.5 flex flex-wrap gap-x-3 text-xs\">\n {session.location && (\n // Approximate by construction — IP geolocation is often\n // a different city, sometimes a different country.\n <span>\n {session.location} {approxLabel}\n </span>\n )}\n {session.ip && <span className=\"font-mono\">{session.ip}</span>}\n {session.lastActive && (\n <span>\n {activeLabel} <Fmt type=\"relative\" value={session.lastActive} now={now} locale={locale} />\n </span>\n )}\n </p>\n </div>\n\n {/* Never offered for the current session. */}\n {onRevoke && !session.current && (\n <Button\n variant=\"ghost\"\n size=\"xs\"\n className=\"shrink-0 text-[var(--destructive-soft-foreground)]\"\n onClick={() => setConfirming(session.id)}\n >\n {signOutLabel}\n </Button>\n )}\n </div>\n\n {confirming === session.id && (\n <div className=\"border-border bg-muted/40 flex flex-wrap items-center gap-2 border p-2 text-xs\">\n <span className=\"text-muted-foreground min-w-0 flex-1\">\n {confirmNote}\n </span>\n <Button\n size=\"xs\"\n color=\"destructive\"\n onClick={() => {\n onRevoke?.(session.id)\n setConfirming(null)\n }}\n >\n {confirmLabel}\n </Button>\n <Button size=\"xs\" variant=\"secondary\" onClick={() => setConfirming(null)}>\n {cancelLabel}\n </Button>\n </div>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { SessionList }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sheet",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Sheet",
|
|
5
|
+
"description": "A panel that slides in from an edge. Same native <dialog> machinery as Dialog, pinned to one side — so it inherits the focus trap and Escape handling.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"primitive-dialog",
|
|
15
|
+
"primitive-slot"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/sheet.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { X } from 'lucide-react'\nimport { DialogProvider, useDialog } from '@/components/primitives/dialog'\nimport { Slot } from '@/components/primitives/slot'\nimport { Button } from '@/components/ui/button'\nimport { cardPadding } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A panel that slides in from an edge — the same native `<dialog>` machinery as\n * Dialog, pinned to one side instead of centred.\n *\n * The entrance translates, which the kit's motion rule otherwise forbids. That\n * rule is about hover and press feedback; a panel arriving from an edge needs to\n * show where it came from, or it reads as a flash. It is disabled entirely under\n * `prefers-reduced-motion`.\n */\ntype Side = 'right' | 'left' | 'top' | 'bottom'\n\nfunction Sheet({\n open,\n defaultOpen,\n onOpenChange,\n children,\n}: {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n children: ReactNode\n}) {\n return (\n <DialogProvider\n open={open}\n defaultOpen={defaultOpen}\n onOpenChange={onOpenChange}\n >\n {children}\n </DialogProvider>\n )\n}\n\nfunction SheetTrigger({\n asChild = false,\n onClick,\n ...props\n}: ComponentProps<'button'> & { asChild?: boolean }) {\n const { setOpen } = useDialog()\n const Comp = asChild ? Slot : 'button'\n\n return (\n <Comp\n data-slot=\"sheet-trigger\"\n onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n onClick?.(event)\n if (!event.defaultPrevented) setOpen(true)\n }}\n {...props}\n />\n )\n}\n\n/*\n * Each side pins two edges and explicitly releases the opposite one.\n *\n * The `auto` is not redundant: the user-agent stylesheet for `<dialog>` sets\n * both `inset-inline-start` and `inset-inline-end` to 0. Setting only `end-0`\n * leaves the start inset in place, and with a fixed width the box resolves\n * against the start edge — a right sheet silently opens on the left.\n */\nconst SIDE = {\n right: 'inset-y-0 end-0 start-auto h-dvh max-h-dvh rounded-s-3xl',\n left: 'inset-y-0 start-0 end-auto h-dvh max-h-dvh rounded-e-3xl',\n top: 'inset-x-0 top-0 bottom-auto max-h-[80dvh] w-full rounded-b-3xl',\n bottom: 'inset-x-0 bottom-0 top-auto max-h-[80dvh] w-full rounded-t-3xl',\n}\n\n/** Sides that take a width; the other two take a height. */\nconst HORIZONTAL = new Set<Side>(['right', 'left'])\n\nfunction SheetContent({\n className,\n children,\n side = 'right',\n showClose = true,\n dismissable = true,\n width,\n height,\n style,\n closeLabel = 'Close',\n ...props\n}: ComponentProps<'dialog'> & {\n side?: Side\n showClose?: boolean\n dismissable?: boolean\n /** Accessible name for the close button. */\n closeLabel?: string\n /** Any CSS length. Applies to a left or right sheet. */\n width?: string\n /** Any CSS length. Applies to a top or bottom sheet. */\n height?: string\n}) {\n const { dialogRef, setOpen, ids } = useDialog()\n const horizontal = HORIZONTAL.has(side)\n\n const size = horizontal\n ? { width: width ?? 'min(24rem, 100vw - 2rem)' }\n : height\n ? { height }\n : undefined\n\n return (\n <dialog\n ref={dialogRef}\n data-slot=\"sheet\"\n data-side={side}\n aria-labelledby={ids.title}\n onClick={(event) => {\n if (!dismissable || event.target !== event.currentTarget) return\n const rect = event.currentTarget.getBoundingClientRect()\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (!inside) setOpen(false)\n }}\n // `m-0` overrides the UA's centring margin; the SIDE classes pin it.\n // No border: the sheet meets the viewport edge on three sides, so an\n // outline would only draw a line down the middle of the screen.\n style={{ ...size, ...style }}\n className={cn(\n 'bg-card text-card-foreground fixed m-0 max-w-none border-0 p-0',\n 'backdrop:bg-transparent',\n SIDE[side],\n className,\n )}\n {...props}\n >\n <div className=\"relative flex h-full flex-col overflow-auto\">\n {children}\n {showClose && (\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={closeLabel}\n onClick={() => setOpen(false)}\n className=\"absolute end-3 top-3\"\n >\n <X />\n </Button>\n )}\n </div>\n </dialog>\n )\n}\n\nfunction SheetHeader({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sheet-header\"\n className={cn(\n 'border-border flex flex-col gap-1.5 border-b pe-10',\n cardPadding.lg,\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction SheetTitle({ className, ...props }: ComponentProps<'h2'>) {\n const { ids } = useDialog()\n\n return (\n <h2\n id={ids.title}\n data-slot=\"sheet-title\"\n className={cn('text-base font-semibold', className)}\n {...props}\n />\n )\n}\n\nfunction SheetDescription({ className, ...props }: ComponentProps<'p'>) {\n return (\n <p\n data-slot=\"sheet-description\"\n className={cn('text-muted-foreground text-sm', className)}\n {...props}\n />\n )\n}\n\nfunction SheetBody({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sheet-body\"\n className={cn('flex-1 overflow-auto p-6 text-sm', className)}\n {...props}\n />\n )\n}\n\nfunction SheetFooter({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sheet-footer\"\n className={cn(\n 'border-border flex flex-col-reverse gap-2 border-t sm:flex-row sm:justify-end',\n cardPadding.lg,\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction SheetClose({\n asChild = false,\n onClick,\n ...props\n}: ComponentProps<'button'> & { asChild?: boolean }) {\n const { setOpen } = useDialog()\n const Comp = asChild ? Slot : 'button'\n\n return (\n <Comp\n data-slot=\"sheet-close\"\n onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n onClick?.(event)\n if (!event.defaultPrevented) setOpen(false)\n }}\n {...props}\n />\n )\n}\n\nexport {\n Sheet,\n SheetBody,\n SheetClose,\n SheetContent,\n SheetDescription,\n SheetFooter,\n SheetHeader,\n SheetTitle,\n SheetTrigger,\n}\nexport type { Side as SheetSide }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shortcut-sheet",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Shortcut Sheet",
|
|
5
|
+
"description": "A keyboard reference grouped by area. Presentational by design — it never binds the keys, because the component that shows a cheat sheet is not the one that owns the handlers.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"kbd",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/shortcut-sheet.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Kbd } from '@/components/ui/kbd'\nimport { cn } from '@/lib/utils'\n\n/**\n * A keyboard reference, grouped by area.\n *\n * Presentational only — it renders a map of shortcuts and nothing else. It does\n * not bind them, because the component that displays a cheat sheet is never the\n * one that owns the handlers, and a sheet that silently registered global keys\n * would fight whatever actually implements them.\n *\n * Keys are given as arrays so the modifier chain stays data: `['Meta', 'K']`\n * renders two caps, and the same array can drive a matcher elsewhere.\n */\nexport type Shortcut = {\n keys: string[]\n label: ReactNode\n}\n\nexport type ShortcutGroup = {\n label: string\n shortcuts: Shortcut[]\n}\n\nfunction ShortcutSheet({\n groups,\n columns = true,\n className,\n ...props\n}: ComponentProps<'div'> & {\n groups: ShortcutGroup[]\n /** Two columns from `sm` up. */\n columns?: boolean\n}) {\n return (\n <div\n data-slot=\"shortcut-sheet\"\n className={cn(\n 'grid gap-x-8 gap-y-6',\n columns && 'sm:grid-cols-2',\n className,\n )}\n {...props}\n >\n {groups.map((group) => (\n <section key={group.label} className=\"flex flex-col gap-1\">\n <h3 className=\"text-muted-foreground mb-1 text-xs font-medium tracking-wide uppercase\">\n {group.label}\n </h3>\n\n <dl className=\"flex flex-col\">\n {group.shortcuts.map((shortcut, index) => (\n <div\n key={index}\n className=\"border-border/50 flex items-center justify-between gap-4 border-b py-1.5 last:border-b-0\"\n >\n <dt className=\"min-w-0 truncate text-sm\">{shortcut.label}</dt>\n <dd className=\"flex shrink-0 items-center gap-1\">\n {shortcut.keys.map((key) => (\n <Kbd key={key}>{key}</Kbd>\n ))}\n </dd>\n </div>\n ))}\n </dl>\n </section>\n ))}\n </div>\n )\n}\n\nexport { ShortcutSheet }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sidebar",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Sidebar",
|
|
5
|
+
"description": "An inset application frame: a transparent rail that collapses to a 52px icon strip, beside a rounded content panel. Inset is the only layout and icon is the only collapsed state, which is what keeps the geometry exact.",
|
|
6
|
+
"category": "Navigation",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"primitive-media-query",
|
|
14
|
+
"primitive-slot",
|
|
15
|
+
"tooltip"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/sidebar.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import {\n createContext,\n use,\n useCallback,\n useEffect,\n useId,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { PanelLeft } from 'lucide-react'\nimport { Slot } from '@/components/primitives/slot'\nimport { useBreakpoint } from '@/components/primitives/media-query'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport {\n disabledState,\n focusRing,\n iconChild,\n interactive,\n radius,\n sidebarInk,\n sidebarSurface,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An inset application frame: a transparent rail beside a rounded content\n * panel, both floating on the page background.\n *\n * Inset is the only layout, and icon is the only collapsed state. Those are\n * decisions, not omissions — an off-canvas sidebar needs an overlay, a scrim\n * and focus containment, and a flush sidebar needs a border that the inset\n * panel already provides by being a separate surface. Supporting one shape\n * well is what keeps the geometry exact.\n *\n * The grid, in one place:\n *\n * gutter 8px `p-2` on the frame, `gap-2` between rail and panel, and\n * `px-2` inside the rail — one value everywhere\n * rail 52px 36 + 2×8: one control unit plus that same gutter either\n * side. The gutter is not decoration — a menu row's focus\n * ring is a 3px box-shadow, and `SidebarContent` scrolls,\n * so a rail sized to the button exactly would clip the ring\n * against its padding box on both edges\n * expanded 256px `w-64`\n * inset 10px `px-2.5` on a menu row — (36 − 16) / 2, which holds a 16px\n * icon on the rail's centre line whether open or collapsed,\n * so nothing shifts sideways as the label appears\n */\nconst RAIL = 'w-13' // 52px — a 36px control unit plus an 8px gutter either side\nconst EXPANDED = 'w-64' // 256px\n\ntype SidebarContext = {\n open: boolean\n setOpen: (open: boolean) => void\n toggle: () => void\n /** Below `md` the rail is forced closed and the trigger is inert. */\n locked: boolean\n id: string\n}\n\nconst SidebarCtx = createContext<SidebarContext | null>(null)\n\nfunction useSidebar() {\n const context = use(SidebarCtx)\n if (!context) {\n throw new Error('useSidebar must be used inside a <SidebarProvider>')\n }\n return context\n}\n\n/**\n * The frame. Owns the open state and paints the page background the rail and\n * panel float on.\n *\n * Narrow viewports pin it collapsed rather than offering an overlay: the rail\n * is 36px, which is already the mobile layout. Nothing to open, nothing to\n * trap focus in, and the content panel keeps the full remaining width.\n */\nfunction SidebarProvider({\n children,\n className,\n open: openProp,\n defaultOpen = true,\n onOpenChange,\n ...props\n}: ComponentProps<'div'> & {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n}) {\n const controlled = openProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultOpen)\n const wide = useBreakpoint('md')\n const id = useId()\n\n const open = (controlled ? openProp : uncontrolled) && wide\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!controlled) setUncontrolled(next)\n onOpenChange?.(next)\n },\n [controlled, onOpenChange],\n )\n\n const toggle = useCallback(() => setOpen(!open), [open, setOpen])\n\n // Ctrl/Cmd-B, the conventional shortcut. Bound on the frame rather than the\n // document body would miss it when focus is inside the content panel.\n useEffect(() => {\n if (!wide) return\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'b' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n toggle()\n }\n }\n document.addEventListener('keydown', onKeyDown)\n return () => document.removeEventListener('keydown', onKeyDown)\n }, [toggle, wide])\n\n return (\n <SidebarCtx value={{ open, setOpen, toggle, locked: !wide, id }}>\n <div\n data-slot=\"sidebar-provider\"\n data-state={open ? 'expanded' : 'collapsed'}\n className={cn(\n sidebarSurface,\n 'flex min-h-svh w-full gap-2 p-2',\n className,\n )}\n {...props}\n >\n {children}\n </div>\n </SidebarCtx>\n )\n}\n\n/**\n * The rail itself — transparent by design, so the frame's background reads as\n * one continuous surface behind it and only the content panel looks raised.\n *\n * Width is the one thing in the kit that animates on a size, and only here:\n * collapsing *is* the interaction, not a reaction to a pointer passing over\n * something. Hover and press stay colour-only everywhere, this component\n * included.\n */\nfunction Sidebar({ className, children, ...props }: ComponentProps<'aside'>) {\n const { open, id } = useSidebar()\n\n return (\n <aside\n id={id}\n data-slot=\"sidebar\"\n data-state={open ? 'expanded' : 'collapsed'}\n className={cn(\n 'group/sidebar flex shrink-0 flex-col gap-2 bg-transparent',\n 'transition-[width] duration-200 ease-out motion-reduce:transition-none',\n open ? EXPANDED : RAIL,\n className,\n )}\n {...props}\n >\n {children}\n </aside>\n )\n}\n\n/** Fixed top section — a product mark, a workspace switcher. */\nfunction SidebarHeader({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-header\"\n className={cn('flex min-h-9 shrink-0 flex-col gap-2 px-2', className)}\n {...props}\n />\n )\n}\n\n/**\n * The scrolling middle.\n *\n * Its `px-2` is what keeps a focus ring visible: overflow clips at the padding\n * box, so the gutter has to be padding on the scroller itself, not margin on\n * the rows or width on the rail.\n *\n * Nothing overflows horizontally during the width animation either — a label\n * is `sr-only` the moment the rail collapses, and `truncate` clips it while\n * the rail is still growing.\n */\nfunction SidebarContent({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-content\"\n className={cn(\n 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-2',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** Fixed bottom section — an account row, a sign-out. */\nfunction SidebarFooter({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-footer\"\n className={cn('flex shrink-0 flex-col gap-2 px-2', className)}\n {...props}\n />\n )\n}\n\n/** A titled run of menu rows. */\nfunction SidebarGroup({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-group\"\n className={cn('flex flex-col gap-1', className)}\n {...props}\n />\n )\n}\n\n/**\n * A section heading. It collapses to nothing rather than to an icon, since a\n * heading has no icon to fall back to — but it keeps its height, so the rows\n * either side of it do not close up as the rail narrows.\n */\nfunction SidebarGroupLabel({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-group-label\"\n className={cn(\n // Same type as DropdownMenuLabel, so a section heading reads the\n // same wherever it appears. Height is on the control grid.\n 'flex h-7 shrink-0 items-center px-2.5 text-xs font-medium',\n sidebarInk.label,\n 'truncate whitespace-nowrap',\n 'group-data-[state=collapsed]/sidebar:invisible',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarMenu({ className, ...props }: ComponentProps<'ul'>) {\n return (\n <ul\n data-slot=\"sidebar-menu\"\n className={cn('flex list-none flex-col gap-1', className)}\n {...props}\n />\n )\n}\n\nfunction SidebarMenuItem({ className, ...props }: ComponentProps<'li'>) {\n return <li data-slot=\"sidebar-menu-item\" className={className} {...props} />\n}\n\n/**\n * One row. Renders a button by default, or whatever it is given via `asChild`\n * — a router link, usually.\n *\n * The label is hidden rather than unmounted so the accessible name is stable:\n * a collapsed rail of icon buttons that lose their names to a CSS state is a\n * screen-reader dead end. `sr-only` keeps the text, and the tooltip covers the\n * sighted case.\n */\nfunction SidebarMenuButton({\n className,\n asChild = false,\n isActive = false,\n icon,\n trailing,\n tooltip,\n children,\n ...props\n}: ComponentProps<'button'> & {\n asChild?: boolean\n isActive?: boolean\n icon?: ReactNode\n /** A count or status, hidden with the label when the rail collapses. */\n trailing?: ReactNode\n /** Shown on hover while collapsed. Defaults to the row's own label. */\n tooltip?: ReactNode\n}) {\n const { open } = useSidebar()\n const Comp = asChild ? Slot : 'button'\n\n const row = (\n <Comp\n data-slot=\"sidebar-menu-button\"\n data-active={isActive}\n // `aria-current` is the part a screen reader announces; `data-active`\n // only styles.\n aria-current={isActive ? 'page' : undefined}\n // `px-2.5` is (rail − icon) / 2, so the icon sits on the rail's centre\n // line in both states and the label grows out to its right.\n className={cn(\n 'flex h-9 w-full items-center gap-2 px-2.5 text-sm font-medium',\n radius.control,\n iconChild,\n interactive,\n sidebarInk.ring,\n disabledState,\n // Ink comes from the fixed sidebar token, not the theme, so the row\n // stays legible on the black ground in both themes.\n isActive ? sidebarInk.active : cn(sidebarInk.row, sidebarInk.hover),\n className,\n )}\n {...props}\n >\n {icon}\n <span className=\"min-w-0 flex-1 truncate whitespace-nowrap text-start group-data-[state=collapsed]/sidebar:sr-only\">\n {children}\n </span>\n {trailing && (\n <span className=\"shrink-0 group-data-[state=collapsed]/sidebar:hidden\">\n {trailing}\n </span>\n )}\n </Comp>\n )\n\n if (open) return row\n\n return (\n <Tooltip content={tooltip ?? children} side=\"right\">\n {row}\n </Tooltip>\n )\n}\n\n/** Toggles the rail. Inert below `md`, where the rail is already the layout. */\nfunction SidebarTrigger({\n className,\n onClick,\n ...props\n}: ComponentProps<'button'>) {\n const { open, toggle, locked, id } = useSidebar()\n if (locked) return null\n\n return (\n <button\n type=\"button\"\n data-slot=\"sidebar-trigger\"\n aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}\n aria-expanded={open}\n aria-controls={id}\n onClick={(event) => {\n onClick?.(event)\n toggle()\n }}\n className={cn(\n 'text-muted-foreground hover:bg-secondary hover:text-foreground inline-flex size-9 shrink-0 items-center justify-center',\n radius.control,\n iconChild,\n interactive,\n focusRing,\n className,\n )}\n {...props}\n >\n <PanelLeft />\n </button>\n )\n}\n\n/**\n * The content panel — an ordinary themed surface on the rail's fixed black\n * ground, which is what makes the layout read as inset.\n *\n * The border is not decoration. In the dark theme the panel is `--card` at 13%\n * lightness sitting on an 8% ground: a five-point difference, which is not\n * enough of an edge to see where the page stops and the rail begins. Contrast\n * alone only carries the light theme.\n *\n * `overflow-hidden` is what earns the radius: a table or a list that runs to\n * the panel's edge has to be clipped by the curve, not drawn over it.\n */\nfunction SidebarInset({ className, ...props }: ComponentProps<'main'>) {\n return (\n <main\n data-slot=\"sidebar-inset\"\n className={cn(\n 'bg-card text-card-foreground border-border flex min-w-0 flex-1 flex-col overflow-hidden border',\n radius.surface,\n className,\n )}\n {...props}\n />\n )\n}\n\nexport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInset,\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarProvider,\n SidebarTrigger,\n useSidebar,\n}\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skeleton",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Skeleton",
|
|
5
|
+
"description": "A placeholder shape for content that has not arrived. The pulse animates opacity only, so it stays inside the kit motion rule, and stops under prefers-reduced-motion.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"class-variance-authority@^0.7.1"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/skeleton.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A placeholder shape for content that has not arrived.\n *\n * The pulse animates opacity only — no size or position — so it sits inside the\n * kit's motion rule, and it stops entirely under `prefers-reduced-motion`.\n */\nconst skeletonVariants = cva(\n 'bg-secondary animate-pulse motion-reduce:animate-none',\n {\n variants: {\n shape: {\n block: radius.control,\n /** A line of text: height follows the line, corners stay soft. */\n text: 'h-4 rounded-md',\n circle: 'rounded-full [corner-shape:round]',\n },\n },\n defaultVariants: { shape: 'block' },\n },\n)\n\ntype SkeletonProps = ComponentProps<'div'> &\n VariantProps<typeof skeletonVariants> & {\n /** Render this many stacked lines. Only meaningful with `shape=\"text\"`. */\n lines?: number\n }\n\nfunction Skeleton({ className, shape, lines, ...props }: SkeletonProps) {\n if (lines && lines > 1) {\n return (\n <div data-slot=\"skeleton-group\" className=\"grid w-full gap-2\" {...props}>\n {Array.from({ length: lines }, (_, index) => (\n <div\n key={index}\n data-slot=\"skeleton\"\n className={cn(\n skeletonVariants({ shape: shape ?? 'text' }),\n // A ragged last line reads as a paragraph rather than a block.\n index === lines - 1 ? 'w-3/5' : 'w-full',\n className,\n )}\n />\n ))}\n </div>\n )\n }\n\n return (\n <div\n data-slot=\"skeleton\"\n className={cn(skeletonVariants({ shape }), className)}\n {...props}\n />\n )\n}\n\nexport { Skeleton, skeletonVariants }\nexport type { SkeletonProps }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skip-link",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Skip Link",
|
|
5
|
+
"description": "The first tab stop: jump past the navigation to the content. Invisible until focused, which is the whole design.",
|
|
6
|
+
"category": "Utility",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/skip-link.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import type { ComponentProps } from 'react'\nimport { radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The first tab stop: jump straight to the main content.\n *\n * Visible only while focused, which is the whole design — it is there for\n * someone tabbing through, and invisible to everyone else.\n *\n * `sr-only` plus `focus:not-sr-only` rather than moving it off-screen with a\n * transform: an element positioned off-screen and animated back in is a\n * position change, and this kit does not move things. Appearing on focus is a\n * state change, not motion.\n *\n * The target needs `tabindex=\"-1\"`, or clicking the link scrolls the page but\n * leaves focus on the link and the next Tab starts from the top again.\n */\nfunction SkipLink({\n href = '#main',\n children = 'Skip to content',\n className,\n ...props\n}: ComponentProps<'a'>) {\n return (\n <a\n href={href}\n data-slot=\"skip-link\"\n className={cn(\n 'sr-only',\n 'focus:not-sr-only focus:bg-primary focus:text-primary-foreground focus:fixed focus:start-4 focus:top-4 focus:z-[100] focus:px-4 focus:py-2 focus:text-sm focus:font-medium',\n radius.control,\n 'focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]',\n className,\n )}\n {...props}\n >\n {children}\n </a>\n )\n}\n\nexport { SkipLink }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slider",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Slider",
|
|
5
|
+
"description": "Pick a value along a range. A restyled native range input, so arrow keys, Home/End, touch dragging and form submission all work without JavaScript.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"class-variance-authority@^0.7.1"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/slider.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import { useState, type ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { focusRing, sliderSize } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A native `<input type=\"range\">`, restyled.\n *\n * Track and thumb can only be reached through vendor pseudo-elements, so their\n * rules live in `index.css` under `.slider`; this component supplies the size\n * variables and the fill percentage they read. Doing it natively keeps arrow\n * keys, Home/End, touch dragging and form submission working with no JS.\n */\nconst sliderVariants = cva(['slider w-full', focusRing].join(' '), {\n variants: {\n size: {\n sm: sliderSize.sm,\n default: sliderSize.default,\n lg: sliderSize.lg,\n },\n },\n defaultVariants: { size: 'default' },\n})\n\ntype SliderProps = Omit<ComponentProps<'input'>, 'size' | 'type'> &\n VariantProps<typeof sliderVariants> & {\n /**\n * Accessible name. A range input with no name is announced as just\n * \"slider\", so this is the one prop that should never be skipped — pass\n * `aria-label`/`aria-labelledby` directly if you already have a visible\n * label to point at.\n */\n label?: string\n /** Render the current value beside the track. */\n showValue?: boolean\n /** Format the displayed value. */\n formatValue?: (value: number) => string\n }\n\nfunction Slider({\n className,\n size,\n label,\n showValue = false,\n formatValue = String,\n min = 0,\n max = 100,\n step,\n value,\n defaultValue,\n onChange,\n style,\n ...props\n}: SliderProps) {\n const controlled = value !== undefined\n const [uncontrolled, setUncontrolled] = useState(\n () => Number(defaultValue ?? (Number(min) + Number(max)) / 2),\n )\n const current = controlled ? Number(value) : uncontrolled\n\n // The webkit track is painted with a gradient, so it needs a percentage.\n const span = Number(max) - Number(min)\n const progress = span === 0 ? 0 : ((current - Number(min)) / span) * 100\n\n const input = (\n <input\n type=\"range\"\n data-slot=\"slider\"\n aria-label={label}\n min={min}\n max={max}\n step={step}\n value={controlled ? value : uncontrolled}\n onChange={(event) => {\n if (!controlled) setUncontrolled(Number(event.target.value))\n onChange?.(event)\n }}\n className={cn(sliderVariants({ size }), className)}\n style={{ '--slider-progress': `${progress}%`, ...style } as React.CSSProperties}\n {...props}\n />\n )\n\n if (!showValue) return input\n\n return (\n <div className=\"flex w-full items-center gap-3\">\n {input}\n <span className=\"text-muted-foreground w-10 shrink-0 text-right font-mono text-xs tabular-nums\">\n {formatValue(current)}\n </span>\n </div>\n )\n}\n\nexport { Slider, sliderVariants }\nexport type { SliderProps }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slo-budget",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "SLO Budget",
|
|
5
|
+
"description": "An error budget and how fast it is burning. Shows the budget remaining rather than availability achieved — 99.4% against a 99.9% target sounds like a near miss and is a six-times overspend.",
|
|
6
|
+
"category": "AIOps",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"fmt",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/slo-budget.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Fmt } from '@/components/ui/fmt'\nimport { cn } from '@/lib/utils'\n\n/**\n * An error budget and how fast it is being spent.\n *\n * Shows the budget *remaining*, not the availability achieved. 99.4% against a\n * 99.9% target sounds like a near miss and is in fact a six-times overspend —\n * the budget framing is what makes that legible, and it is the number an\n * on-call decision is actually made on.\n *\n * The burn rate is given as a multiple of the sustainable pace, since \"2.4×\"\n * answers \"will we make it\" and a raw percentage does not.\n */\nfunction SloBudget({\n target,\n actual,\n window: windowLabel = '30 days',\n burnRate,\n label,\n hint,\n targetLabel = 'target',\n remainingLabel = 'remaining',\n meterLabel = 'Error budget remaining',\n achievedLabel = 'Achieved',\n spentLabel = 'spent',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n /** Objective as a ratio, e.g. 0.999. */\n target: number\n /** Achieved availability as a ratio. */\n actual: number\n window?: string\n /** Multiple of the sustainable burn rate. Above 1 will exhaust the budget. */\n burnRate?: number\n label?: ReactNode\n hint?: ReactNode\n /** Precedes the objective in the subheading. */\n targetLabel?: ReactNode\n /** Follows the remaining percentage. */\n remainingLabel?: ReactNode\n /** Accessible name for the budget meter. */\n meterLabel?: string\n /** Precedes the achieved availability. */\n achievedLabel?: ReactNode\n /** Precedes the consumed share. */\n spentLabel?: ReactNode\n}) {\n const allowed = 1 - target\n const used = Math.max(0, 1 - actual)\n const consumed = allowed > 0 ? used / allowed : 0\n const remaining = Math.max(0, 1 - consumed)\n const exhausted = consumed >= 1\n\n const tone = exhausted\n ? 'var(--destructive)'\n : consumed >= 0.75\n ? 'var(--amber)'\n : 'var(--green)'\n\n return (\n <div\n data-slot=\"slo-budget\"\n className={cn('flex min-w-0 flex-col gap-2', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5\">\n <span className=\"text-sm font-medium\">{label ?? 'Error budget'}</span>\n <span className=\"text-muted-foreground text-xs\">\n {targetLabel} <Fmt type=\"percent\" value={target} decimals={2} /> · {windowLabel}\n </span>\n </div>\n\n <div className=\"flex items-baseline gap-2\">\n <span\n className=\"text-2xl font-semibold tabular-nums\"\n style={{ color: exhausted ? tone : undefined }}\n >\n <Fmt type=\"percent\" value={remaining} decimals={0} />\n </span>\n <span className=\"text-muted-foreground text-xs whitespace-nowrap\">{remainingLabel}</span>\n\n {burnRate !== undefined && (\n <span\n className={cn(\n 'ms-auto text-xs font-medium tabular-nums whitespace-nowrap',\n burnRate > 1 ? 'text-[var(--destructive-soft-foreground)]' : 'text-muted-foreground',\n )}\n >\n {burnRate.toFixed(1)}× burn\n </span>\n )}\n </div>\n\n <div\n role=\"progressbar\"\n aria-valuenow={Math.round(remaining * 100)}\n aria-valuemin={0}\n aria-valuemax={100}\n aria-label={meterLabel}\n className=\"bg-secondary h-2 w-full overflow-hidden rounded-full [corner-shape:round]\"\n >\n <div\n className=\"h-full rounded-full transition-[width,background-color] duration-300 ease-out [corner-shape:round] motion-reduce:transition-none\"\n style={{ width: `${remaining * 100}%`, backgroundColor: tone }}\n />\n </div>\n\n <p className=\"text-muted-foreground/80 text-xs\">\n {hint ?? (\n <>\n {achievedLabel} <Fmt type=\"percent\" value={actual} decimals={3} /> — {spentLabel}{' '}\n <Fmt type=\"percent\" value={Math.min(consumed, 1)} decimals={0} /> of the\n allowance\n {exhausted && ', budget exhausted'}\n </>\n )}\n </p>\n </div>\n )\n}\n\nexport { SloBudget }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sparkline",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Sparkline",
|
|
5
|
+
"description": "A microchart with no axes, legend or dependency — plain SVG over a normalised range, sized by whatever box you put it in.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/sparkline.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import type { ComponentProps } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * A microchart: enough shape to read a trend, no axes, no legend, no library.\n *\n * Plain SVG rather than a charting dependency. A sparkline is a polyline over\n * a normalised range — the whole implementation is the `points` calculation\n * below, and pulling in a chart library to draw it would cost more than every\n * other component in the kit combined.\n *\n * The viewBox is a fixed 100-unit grid stretched by `preserveAspectRatio:none`,\n * so the chart fills whatever width it is given without measuring anything.\n * `vector-effect: non-scaling-stroke` is what keeps the line an even weight\n * once that stretch is applied — without it the stroke skews with the aspect\n * ratio and a wide sparkline draws visibly thicker verticals than horizontals.\n */\ntype SparklineProps = Omit<ComponentProps<'svg'>, 'values'> & {\n values: number[]\n variant?: 'line' | 'area' | 'bar'\n /** Any CSS colour. Defaults to the inherited text colour. */\n color?: string\n /** Baseline for the fill and bars. Defaults to the lowest value. */\n baseline?: number\n strokeWidth?: number\n}\n\nconst VIEW = { width: 100, height: 32 } as const\n\nfunction Sparkline({\n values,\n variant = 'line',\n color = 'currentColor',\n baseline,\n strokeWidth = 1.5,\n className,\n ...props\n}: SparklineProps) {\n if (values.length === 0) return null\n\n const min = Math.min(...values, baseline ?? Infinity)\n const max = Math.max(...values, baseline ?? -Infinity)\n // A flat series has no range to divide by; centre it instead of dividing by\n // zero and rendering NaN into the path.\n const span = max - min || 1\n const pad = strokeWidth\n\n const x = (index: number) =>\n values.length === 1\n ? VIEW.width / 2\n : (index / (values.length - 1)) * VIEW.width\n const y = (value: number) =>\n pad + (1 - (value - min) / span) * (VIEW.height - pad * 2)\n\n const points = values.map((value, index) => `${x(index)},${y(value)}`)\n\n return (\n <svg\n viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}\n preserveAspectRatio=\"none\"\n role=\"img\"\n aria-hidden=\"true\"\n data-slot=\"sparkline\"\n className={cn('h-8 w-full', className)}\n {...props}\n >\n {variant === 'bar' ? (\n values.map((value, index) => {\n const width = VIEW.width / values.length\n const top = y(value)\n return (\n <rect\n key={index}\n x={index * width + width * 0.15}\n y={top}\n width={width * 0.7}\n height={Math.max(VIEW.height - pad - top, 0.5)}\n fill={color}\n />\n )\n })\n ) : (\n <>\n {variant === 'area' && (\n <polygon\n points={[\n `0,${VIEW.height}`,\n ...points,\n `${VIEW.width},${VIEW.height}`,\n ].join(' ')}\n fill={color}\n opacity={0.14}\n />\n )}\n <polyline\n points={points.join(' ')}\n fill=\"none\"\n stroke={color}\n strokeWidth={strokeWidth}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n vectorEffect=\"non-scaling-stroke\"\n />\n </>\n )}\n </svg>\n )\n}\n\nexport { Sparkline }\nexport type { SparklineProps }\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spinner",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Spinner",
|
|
5
|
+
"description": "An indeterminate busy indicator. Rotation is the one exception the motion rule has to allow — a still spinner says nothing. It stops under prefers-reduced-motion.",
|
|
6
|
+
"category": "Feedback",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"class-variance-authority@^0.7.1"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-utils"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/spinner.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import type { ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@/lib/utils'\n\n/**\n * An indeterminate busy indicator.\n *\n * Rotation is the exception the motion rule has to allow — a still spinner says\n * nothing at all. It stops under `prefers-reduced-motion`, where the label and\n * `role=\"status\"` still carry the meaning.\n */\nconst spinnerVariants = cva(\n 'inline-block animate-spin rounded-full border-current border-e-transparent motion-reduce:animate-none',\n {\n variants: {\n size: {\n xs: 'size-3 border-[1.5px]',\n sm: 'size-4 border-2',\n default: 'size-5 border-2',\n lg: 'size-6 border-2',\n xl: 'size-8 border-[3px]',\n },\n },\n defaultVariants: { size: 'default' },\n },\n)\n\nfunction Spinner({\n className,\n size,\n label = 'Loading',\n ...props\n}: ComponentProps<'span'> & VariantProps<typeof spinnerVariants> & {\n label?: string\n}) {\n return (\n <span\n role=\"status\"\n data-slot=\"spinner\"\n className={cn('inline-flex items-center gap-2', className)}\n {...props}\n >\n <span aria-hidden=\"true\" className={cn(spinnerVariants({ size }))} />\n <span className=\"sr-only\">{label}</span>\n </span>\n )\n}\n\nexport { Spinner, spinnerVariants }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stack-trace",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Stack Trace",
|
|
5
|
+
"description": "A parsed exception with its frames. Library frames collapse behind a count — a stack is typically three frames of your code buried in forty of the framework’s.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"copy-button",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/stack-trace.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, ChevronRight } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A parsed exception with its frames.\n *\n * Vendor frames are collapsed behind a count by default. A stack is typically\n * three frames of your code buried in forty of the framework's, and every\n * debugger that gets this right hides the noise until asked.\n *\n * Frames carry a source excerpt when one is available, with the failing line\n * marked — the line number alone means opening an editor to learn anything.\n */\nexport type StackFrame = {\n fn: string\n file: string\n line?: number\n column?: number\n /** False marks a library frame, which is collapsed by default. */\n app?: boolean\n source?: { line: number; content: string }[]\n}\n\nfunction StackTrace({\n name,\n message,\n frames,\n raw,\n vendorLabel = 'vendor',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n name: string\n message: string\n frames: StackFrame[]\n /** The original text, for the copy button. */\n raw?: string\n /** Badge on a frame outside application code. */\n vendorLabel?: ReactNode\n}) {\n const [showVendor, setShowVendor] = useState(false)\n const [expanded, setExpanded] = useState<number[]>(() =>\n // The first app frame opens with its source: it is where you look first.\n frames.findIndex((frame) => frame.app) === -1\n ? []\n : [frames.findIndex((frame) => frame.app)],\n )\n\n const vendorCount = frames.filter((frame) => !frame.app).length\n const visible = showVendor ? frames : frames.filter((frame) => frame.app)\n\n return (\n <div\n data-slot=\"stack-trace\"\n className={cn('border-border overflow-hidden border', radius.surface, className)}\n {...props}\n >\n <div className=\"border-border bg-[color-mix(in_oklab,var(--destructive),transparent_94%)] flex items-start gap-2 border-b p-3\">\n <div className=\"min-w-0 flex-1\">\n <p className=\"font-mono text-sm font-semibold text-[var(--destructive-soft-foreground)]\">\n {name}\n </p>\n <p className=\"text-muted-foreground mt-0.5 font-mono text-xs break-words\">\n {message}\n </p>\n </div>\n <CopyButton value={raw ?? `${name}: ${message}`} label=\"Copy stack trace\" />\n </div>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {visible.map((frame) => {\n const index = frames.indexOf(frame)\n const open = expanded.includes(index)\n\n return (\n <li key={index}>\n <button\n type=\"button\"\n aria-expanded={frame.source ? open : undefined}\n disabled={!frame.source}\n onClick={() =>\n setExpanded((current) =>\n open ? current.filter((i) => i !== index) : [...current, index],\n )\n }\n className={cn(\n 'flex w-full items-center gap-2 px-3 py-2 text-start',\n frame.source && 'hover:bg-accent/40',\n interactive,\n focusRing,\n )}\n >\n {frame.source ? (\n open ? (\n <ChevronDown className=\"text-muted-foreground size-3.5 shrink-0\" />\n ) : (\n <ChevronRight className=\"text-muted-foreground size-3.5 shrink-0\" />\n )\n ) : (\n <span className=\"w-3.5 shrink-0\" />\n )}\n\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate font-mono text-xs font-medium\">\n {frame.fn}\n </span>\n <span className=\"text-muted-foreground block truncate font-mono text-xs\">\n {frame.file}\n {frame.line !== undefined && `:${frame.line}`}\n {frame.column !== undefined && `:${frame.column}`}\n </span>\n </span>\n\n {!frame.app && <Badge size=\"sm\">{vendorLabel}</Badge>}\n </button>\n\n {open && frame.source && (\n <div className=\"bg-muted/30 border-border/60 overflow-x-auto border-t py-1 font-mono text-xs\">\n {frame.source.map((row) => (\n <div\n key={row.line}\n className={cn(\n 'flex min-w-max',\n row.line === frame.line &&\n 'bg-[color-mix(in_oklab,var(--destructive),transparent_88%)]',\n )}\n >\n <span className=\"text-muted-foreground/50 w-12 shrink-0 px-2 text-end select-none\">\n {row.line}\n </span>\n <code className=\"flex-1 pe-3 whitespace-pre\">{row.content}</code>\n </div>\n ))}\n </div>\n )}\n </li>\n )\n })}\n </ul>\n\n {vendorCount > 0 && (\n <button\n type=\"button\"\n onClick={() => setShowVendor((current) => !current)}\n className={cn(\n 'border-border text-muted-foreground hover:text-foreground w-full border-t py-2 text-xs',\n interactive,\n focusRing,\n )}\n >\n {showVendor\n ? `Hide ${vendorCount} library frames`\n : `Show ${vendorCount} library frames`}\n </button>\n )}\n </div>\n )\n}\n\nexport { StackTrace }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "staking-panel",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Staking Panel",
|
|
5
|
+
"description": "A staking position: stake, rewards, APY and unlock. APY is labelled variable unless told otherwise, because it almost always is — a bare percentage reads as a fixed-term rate.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils",
|
|
15
|
+
"progress"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/staking-panel.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Clock, TrendingUp } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Progress } from '@/components/ui/progress'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A staking position: what is staked, what it earns, when it unlocks.\n *\n * APY is labelled as variable unless told otherwise, because it almost always\n * is — it moves with total stake and emissions. A bare \"12.4% APY\" reads as a\n * fixed-term rate, which it is not, and that misreading is the entire business\n * model of some protocols.\n *\n * The unbonding period is given as much weight as the yield. It is the part\n * that actually constrains the user, and it is routinely buried.\n */\nfunction StakingPanel({\n token,\n staked,\n rewards,\n apy,\n apyFixed = false,\n unbonding,\n unlockProgress,\n unlockLabel,\n onStake,\n onUnstake,\n onClaim,\n claimDisabled = false,\n stakedLabel = 'Staked',\n rewardsLabel = 'Rewards',\n variableRateNote = 'Rate is variable — it moves with total stake and emissions.',\n unbondingLabel = 'Unbonding period',\n stakeLabel = 'Stake',\n unstakeLabel = 'Unstake',\n claimLabel = 'Claim',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n token: ReactNode\n staked: ReactNode\n rewards?: ReactNode\n /** Percentage. */\n apy?: number\n /** Only set this when the rate genuinely cannot move. */\n apyFixed?: boolean\n /** e.g. \"21 days\" */\n unbonding?: ReactNode\n /** 0–1 through an in-progress unbonding. */\n unlockProgress?: number\n unlockLabel?: ReactNode\n onStake?: () => void\n onUnstake?: () => void\n onClaim?: () => void\n claimDisabled?: boolean\n stakedLabel?: ReactNode\n rewardsLabel?: ReactNode\n /** Shown unless `apyFixed`. Pass `null` to drop it. */\n variableRateNote?: ReactNode\n unbondingLabel?: ReactNode\n stakeLabel?: ReactNode\n unstakeLabel?: ReactNode\n claimLabel?: ReactNode\n}) {\n return (\n <div\n data-slot=\"staking-panel\"\n className={cn(surface, radius.surface, 'flex flex-col gap-4 p-4', className)}\n {...props}\n >\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm font-medium\">{token}</span>\n {apy !== undefined && (\n <Badge size=\"sm\" color=\"green\" className=\"ms-auto\">\n <TrendingUp />\n {apy.toFixed(2)}% APY\n </Badge>\n )}\n </div>\n\n <dl className=\"grid grid-cols-2 gap-3\">\n <div className={cn('bg-secondary/60 p-3', radius.control)}>\n <dt className=\"text-muted-foreground text-xs\">{stakedLabel}</dt>\n <dd className=\"mt-0.5 text-lg font-semibold tabular-nums\">{staked}</dd>\n </div>\n <div className={cn('bg-secondary/60 p-3', radius.control)}>\n <dt className=\"text-muted-foreground text-xs\">{rewardsLabel}</dt>\n <dd className=\"mt-0.5 text-lg font-semibold tabular-nums text-[var(--green-soft-foreground)]\">\n {rewards ?? '—'}\n </dd>\n </div>\n </dl>\n\n {/* Rate honesty: variable unless the protocol truly fixes it. */}\n {apy !== undefined && !apyFixed && (\n <p className=\"text-muted-foreground/80 text-xs\">\n {variableRateNote}\n </p>\n )}\n\n {unbonding && (\n <div className=\"border-border flex flex-col gap-2 border-t pt-3\">\n <div className=\"text-muted-foreground flex items-center gap-1.5 text-xs\">\n <Clock className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n <span>\n {unbondingLabel} <span className=\"text-foreground font-medium\">{unbonding}</span>\n </span>\n </div>\n\n {unlockProgress !== undefined && (\n <div className=\"flex flex-col gap-1\">\n <Progress value={unlockProgress * 100} className=\"h-1.5\" />\n {unlockLabel && (\n <span className=\"text-muted-foreground/70 text-xs\">{unlockLabel}</span>\n )}\n </div>\n )}\n </div>\n )}\n\n <div className=\"flex flex-wrap gap-2\">\n {onStake && (\n <Button className=\"flex-1\" onClick={onStake}>\n {stakeLabel}\n </Button>\n )}\n {onUnstake && (\n <Button variant=\"secondary\" className=\"flex-1\" onClick={onUnstake}>\n {unstakeLabel}\n </Button>\n )}\n {onClaim && (\n <Button variant=\"secondary\" disabled={claimDisabled} onClick={onClaim}>\n {claimLabel}\n </Button>\n )}\n </div>\n </div>\n )\n}\n\nexport { StakingPanel }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stat",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Stat",
|
|
5
|
+
"description": "One measurement: label, value and how it moved. Which direction counts as healthy is a prop, because a rise in incidents is not a rise in deployments.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/stat.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { ArrowDownRight, ArrowRight, ArrowUpRight } from 'lucide-react'\nimport { cardPadding, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A single measurement: label, value, and how it moved.\n *\n * The direction of a change and whether that change is *good* are different\n * questions, which is why `goodDirection` exists. Deployments rising is\n * healthy; open incidents rising is not, and a component that colours every\n * increase green would say the opposite of what the number means.\n */\ntype StatProps = Omit<ComponentProps<'div'>, 'title'> & {\n label: ReactNode\n value: ReactNode\n /** Signed change. Sign picks the arrow; `goodDirection` picks the colour. */\n delta?: number\n /** How the delta is written. Defaults to a percentage. */\n deltaSuffix?: string\n /**\n * Which direction counts as healthy. `'none'` keeps the delta neutral, for\n * measures where neither direction is good or bad.\n */\n goodDirection?: 'up' | 'down' | 'none'\n icon?: ReactNode\n /** A Sparkline, usually. Sits under the value. */\n chart?: ReactNode\n hint?: ReactNode\n size?: 'sm' | 'default' | 'lg'\n /** Draw the card. Off when the caller supplies its own container. */\n bordered?: boolean\n}\n\nfunction Stat({\n label,\n value,\n delta,\n deltaSuffix = '%',\n goodDirection = 'up',\n icon,\n chart,\n hint,\n size = 'default',\n bordered = true,\n className,\n ...props\n}: StatProps) {\n const rising = delta !== undefined && delta > 0\n const flat = delta === undefined || delta === 0\n const good =\n goodDirection === 'none' || flat\n ? undefined\n : (rising && goodDirection === 'up') || (!rising && goodDirection === 'down')\n\n const Arrow = flat ? ArrowRight : rising ? ArrowUpRight : ArrowDownRight\n\n return (\n <div\n data-slot=\"stat\"\n className={cn(\n 'flex min-w-0 flex-col gap-1',\n bordered && [surface, radius.surface, cardPadding[size]],\n className,\n )}\n {...props}\n >\n <div className=\"text-muted-foreground flex items-center gap-1.5 text-xs font-medium\">\n {icon && (\n <span className=\"[&_svg]:size-3.5 [&_svg]:shrink-0\">{icon}</span>\n )}\n <span className=\"truncate\">{label}</span>\n </div>\n\n <div className=\"flex flex-wrap items-baseline gap-x-2 gap-y-1\">\n <span\n className={cn(\n 'font-semibold tabular-nums',\n size === 'sm' ? 'text-xl' : size === 'lg' ? 'text-3xl' : 'text-2xl',\n )}\n >\n {value}\n </span>\n\n {delta !== undefined && (\n <span\n className={cn(\n 'inline-flex items-center gap-0.5 text-xs font-medium tabular-nums',\n good === undefined && 'text-muted-foreground',\n good === true && 'text-[var(--green-soft-foreground)]',\n good === false && 'text-[var(--destructive-soft-foreground)]',\n )}\n >\n <Arrow className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n {/* The sign is already carried by the arrow. */}\n {Math.abs(delta)}\n {deltaSuffix}\n </span>\n )}\n </div>\n\n {chart && <div className=\"mt-1 min-w-0\">{chart}</div>}\n {hint && <p className=\"text-muted-foreground/70 text-xs\">{hint}</p>}\n </div>\n )\n}\n\nexport { Stat }\nexport type { StatProps }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "status-checks",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Status Checks",
|
|
5
|
+
"description": "The CI check list on a pull request. The summary states the blocking outcome rather than the raw tally, because \"3 failing\" says nothing about whether you can merge.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"spinner"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/status-checks.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport {\n Check,\n ChevronDown,\n ChevronRight,\n CircleDot,\n CircleSlash,\n X,\n} from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Spinner } from '@/components/ui/spinner'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The CI check list on a pull request.\n *\n * The summary line states the *blocking* outcome, not the raw tally: a failing\n * optional check and a failing required one produce very different sentences,\n * and \"3 failing\" tells you nothing about whether you can merge. `required` is\n * what decides that, so the rollup reads it rather than counting statuses.\n */\nexport type CheckStatus = 'success' | 'failure' | 'pending' | 'running' | 'skipped'\n\nexport type StatusCheck = {\n id: string\n name: string\n status: CheckStatus\n description?: string\n duration?: string\n required?: boolean\n detail?: ReactNode\n}\n\nconst ICON = {\n success: { Icon: Check, tone: 'text-[var(--green-soft-foreground)]' },\n failure: { Icon: X, tone: 'text-[var(--destructive-soft-foreground)]' },\n pending: { Icon: CircleDot, tone: 'text-[var(--amber-soft-foreground)]' },\n // Spinner has no entry: it wraps its ring in a span, so it does not share\n // the lucide call signature and is rendered separately below.\n running: { Icon: null, tone: 'text-[var(--blue-soft-foreground)]' },\n skipped: { Icon: CircleSlash, tone: 'text-muted-foreground' },\n} as const\n\nfunction StatusChecks({\n checks,\n defaultExpanded = [],\n optionalLabel = 'optional',\n className,\n ...props\n}: ComponentProps<'div'> & {\n checks: StatusCheck[]\n defaultExpanded?: string[]\n /** Marks a non-blocking check. */\n optionalLabel?: ReactNode\n}) {\n const [expanded, setExpanded] = useState<string[]>(defaultExpanded)\n\n const blocking = checks.filter(\n (check) => check.required !== false && check.status === 'failure',\n )\n const running = checks.filter(\n (check) => check.status === 'running' || check.status === 'pending',\n )\n const passed = checks.filter((check) => check.status === 'success')\n\n const summary = blocking.length\n ? {\n text: `${blocking.length} required ${blocking.length === 1 ? 'check has' : 'checks have'} failed`,\n tone: 'text-[var(--destructive-soft-foreground)]',\n }\n : running.length\n ? {\n text: `${running.length} ${running.length === 1 ? 'check is' : 'checks are'} still running`,\n tone: 'text-[var(--amber-soft-foreground)]',\n }\n : {\n text: `All ${passed.length} checks have passed`,\n tone: 'text-[var(--green-soft-foreground)]',\n }\n\n return (\n <div\n data-slot=\"status-checks\"\n className={cn('border-border overflow-hidden border', radius.surface, className)}\n {...props}\n >\n <div className=\"border-border bg-muted/40 flex items-center gap-2 border-b p-3\">\n <span className={cn('text-sm font-medium', summary.tone)}>\n {summary.text}\n </span>\n <Badge size=\"sm\" className=\"ms-auto\">\n {checks.length}\n </Badge>\n </div>\n\n <ul className=\"divide-border/60 flex list-none flex-col divide-y\">\n {checks.map((check) => {\n const { Icon, tone } = ICON[check.status]\n const open = expanded.includes(check.id)\n\n return (\n <li key={check.id}>\n <div className=\"flex items-center gap-2.5 px-3 py-2\">\n {check.detail ? (\n <button\n type=\"button\"\n aria-expanded={open}\n aria-label={`${open ? 'Hide' : 'Show'} ${check.name} detail`}\n onClick={() =>\n setExpanded((current) =>\n open\n ? current.filter((id) => id !== check.id)\n : [...current, check.id],\n )\n }\n className={cn(\n 'text-muted-foreground hover:text-foreground -m-1 shrink-0 p-1',\n radius.xs,\n interactive,\n focusRing,\n )}\n >\n {open ? (\n <ChevronDown className=\"size-3.5\" />\n ) : (\n <ChevronRight className=\"size-3.5\" />\n )}\n </button>\n ) : (\n <span className=\"w-3.5 shrink-0\" />\n )}\n\n {Icon ? (\n <Icon className={cn('size-4 shrink-0', tone)} aria-hidden=\"true\" />\n ) : (\n <Spinner size=\"sm\" className={cn('shrink-0', tone)} label=\"Running\" />\n )}\n\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {check.name}\n {check.description && (\n <span className=\"text-muted-foreground ms-2 font-normal\">\n {check.description}\n </span>\n )}\n </span>\n\n {check.required === false && (\n <Badge size=\"sm\" className=\"hidden sm:inline-flex\">\n {optionalLabel}\n </Badge>\n )}\n {check.duration && (\n <span className=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n {check.duration}\n </span>\n )}\n </div>\n\n {open && check.detail && (\n <div className=\"bg-muted/30 border-border/60 border-t p-3\">\n {check.detail}\n </div>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { StatusChecks }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|