@windforge/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.
Files changed (61) hide show
  1. package/dist/index.d.ts +1195 -0
  2. package/dist/index.js +3628 -0
  3. package/package.json +66 -0
  4. package/src/catalog.ts +654 -0
  5. package/src/components/accordion.tsx +91 -0
  6. package/src/components/alert.tsx +58 -0
  7. package/src/components/autocomplete.tsx +174 -0
  8. package/src/components/avatar.tsx +60 -0
  9. package/src/components/badge.tsx +37 -0
  10. package/src/components/breadcrumb.tsx +62 -0
  11. package/src/components/button-group.tsx +23 -0
  12. package/src/components/button.tsx +53 -0
  13. package/src/components/calendar.tsx +61 -0
  14. package/src/components/card.tsx +72 -0
  15. package/src/components/chart.tsx +130 -0
  16. package/src/components/checkbox.tsx +27 -0
  17. package/src/components/chip.tsx +75 -0
  18. package/src/components/code-block.tsx +126 -0
  19. package/src/components/command.tsx +139 -0
  20. package/src/components/data-table.tsx +194 -0
  21. package/src/components/date-picker.tsx +77 -0
  22. package/src/components/dialog.tsx +57 -0
  23. package/src/components/dropdown-menu.tsx +186 -0
  24. package/src/components/form-field.tsx +97 -0
  25. package/src/components/input.tsx +29 -0
  26. package/src/components/label.tsx +18 -0
  27. package/src/components/layout.tsx +179 -0
  28. package/src/components/link.tsx +37 -0
  29. package/src/components/modal.tsx +67 -0
  30. package/src/components/multi-select.tsx +175 -0
  31. package/src/components/pagination.tsx +72 -0
  32. package/src/components/popover.tsx +25 -0
  33. package/src/components/progress.tsx +31 -0
  34. package/src/components/radio-group.tsx +34 -0
  35. package/src/components/select.tsx +134 -0
  36. package/src/components/separator.tsx +21 -0
  37. package/src/components/sheet.tsx +80 -0
  38. package/src/components/skeleton.tsx +11 -0
  39. package/src/components/slider.tsx +28 -0
  40. package/src/components/stepper.tsx +69 -0
  41. package/src/components/switch.tsx +33 -0
  42. package/src/components/table.tsx +121 -0
  43. package/src/components/tabs.tsx +90 -0
  44. package/src/components/text.tsx +109 -0
  45. package/src/components/textarea.tsx +27 -0
  46. package/src/components/toast.tsx +107 -0
  47. package/src/components/toggle-button.tsx +103 -0
  48. package/src/components/tooltip.tsx +26 -0
  49. package/src/icons/forge-icon.tsx +55 -0
  50. package/src/icons/icon-set.ts +60 -0
  51. package/src/icons/svg-icon.tsx +43 -0
  52. package/src/index.ts +80 -0
  53. package/src/layouts/app-bar.tsx +95 -0
  54. package/src/layouts/app-shell.tsx +80 -0
  55. package/src/layouts/side-nav.tsx +196 -0
  56. package/src/layouts/theme-provider.tsx +128 -0
  57. package/src/lib/recipes.ts +50 -0
  58. package/src/lib/types.ts +3 -0
  59. package/src/lib/use-media-query.ts +18 -0
  60. package/src/lib/utils.ts +10 -0
  61. package/tailwind-preset.cjs +77 -0
@@ -0,0 +1,72 @@
1
+ import * as React from 'react'
2
+ import { Box, type BoxProps } from './layout'
3
+ import { cn } from '../lib/utils'
4
+ import type { NoStyle } from '../lib/types'
5
+
6
+ /**
7
+ * Card — a surface that groups related content. Pass `title`, `description`,
8
+ * an optional `headerAction` (e.g. a Badge opposite the title), `footer`
9
+ * actions, and the body as `children`; there are no sub-components to assemble.
10
+ * Under the hood it's a `Box` with the universal surface preset, so any Box prop
11
+ * (`boxShadow`, `borderRadius`, …) still applies.
12
+ *
13
+ * <Card title="Deploy preview" description="Pushed 4 minutes ago"
14
+ * footer={<Button>Visit preview</Button>}>
15
+ * Vercel finished building your branch.
16
+ * </Card>
17
+ */
18
+ // Card owns its padding (the header/body/footer sections carry it) so it can
19
+ // never double up against the outer surface Box. `padding` overrides the
20
+ // section inset on the spacing scale; default `lg` keeps the original 24px look.
21
+ type CardPadding = NonNullable<BoxProps['padding']>
22
+ const SECTION_PADDING: Record<CardPadding, string> = {
23
+ none: 'p-none', xs: 'p-xs', sm: 'p-sm', md: 'p-md', lg: 'p-lg', xl: 'p-xl', '2xl': 'p-2xl',
24
+ nbsp: 'p-nbsp', card: 'p-card', gutter: 'p-gutter', section: 'p-section', page: 'p-page',
25
+ }
26
+
27
+ export interface CardProps extends Omit<NoStyle<BoxProps>, 'title' | 'paddingX' | 'paddingY'> {
28
+ title?: React.ReactNode
29
+ description?: React.ReactNode
30
+ /** Trailing header content — sits opposite the title (e.g. a status Badge). */
31
+ headerAction?: React.ReactNode
32
+ /** Footer content — typically buttons. */
33
+ footer?: React.ReactNode
34
+ /** Inset for the header/body/footer sections. Overrides (never stacks on) the
35
+ * default; defaults to the `card` spacing token. */
36
+ padding?: CardPadding
37
+ }
38
+
39
+ export const Card = React.forwardRef<HTMLDivElement, CardProps>(
40
+ ({ title, description, headerAction, footer, children, padding = 'card', ...props }, ref) => {
41
+ const hasHeader = title != null || description != null || headerAction != null
42
+ const pad = SECTION_PADDING[padding]
43
+ return (
44
+ <Box
45
+ ref={ref}
46
+ background="surface"
47
+ border="default"
48
+ borderRadius="2xl"
49
+ boxShadow="sm"
50
+ className={cn('text-primary')}
51
+ {...props}
52
+ >
53
+ {hasHeader && (
54
+ <div className={cn('flex flex-col gap-1.5', pad)}>
55
+ {(title != null || headerAction != null) && (
56
+ <div className={cn('flex items-center justify-between gap-3')}>
57
+ {title != null && <div className={cn('text-lg font-semibold leading-snug tracking-tight')}>{title}</div>}
58
+ {headerAction}
59
+ </div>
60
+ )}
61
+ {description != null && <div className={cn('text-sm text-primary')}>{description}</div>}
62
+ </div>
63
+ )}
64
+ {children != null && <div className={cn(pad, hasHeader && 'pt-0')}>{children}</div>}
65
+ {footer != null && (
66
+ <div className={cn('flex items-center gap-3', pad, (hasHeader || children != null) && 'pt-0')}>{footer}</div>
67
+ )}
68
+ </Box>
69
+ )
70
+ },
71
+ )
72
+ Card.displayName = 'Card'
@@ -0,0 +1,130 @@
1
+ import * as React from 'react'
2
+ import * as echarts from 'echarts/core'
3
+ import type { EChartsCoreOption } from 'echarts/core'
4
+ import { LineChart, BarChart, PieChart, ScatterChart } from 'echarts/charts'
5
+ import {
6
+ TitleComponent, TooltipComponent, GridComponent, LegendComponent,
7
+ DataZoomComponent, MarkLineComponent, MarkAreaComponent,
8
+ } from 'echarts/components'
9
+ import { CanvasRenderer } from 'echarts/renderers'
10
+
11
+ echarts.use([
12
+ LineChart, BarChart, PieChart, ScatterChart,
13
+ TitleComponent, TooltipComponent, GridComponent, LegendComponent,
14
+ DataZoomComponent, MarkLineComponent, MarkAreaComponent,
15
+ CanvasRenderer,
16
+ ])
17
+
18
+ /**
19
+ * Chart — a token-driven ECharts surface. Pass a standard ECharts `option`; the
20
+ * series palette, axes, grid, tooltip, and fonts are themed from the live `--wf-*`
21
+ * tokens, so charts re-skin with the brand and flip with light/dark automatically
22
+ * (no ThemeProvider required — it watches the document root). Auto-resizes.
23
+ *
24
+ * <Chart option={{ xAxis: { type: 'category', data: months },
25
+ * yAxis: { type: 'value' },
26
+ * series: [{ type: 'line', data: revenue }] }} />
27
+ *
28
+ * ECharts is the one canvas dependency; everything else stays DOM + tokens.
29
+ */
30
+ export interface ChartProps {
31
+ /** A standard ECharts option object. */
32
+ option: EChartsCoreOption
33
+ /** Pixel or CSS height (width always fills the container). Default 320. */
34
+ height?: number | string
35
+ /** Replace the previous option wholesale instead of merging. Default true. */
36
+ notMerge?: boolean
37
+ /** ECharts events to bind, e.g. `{ click: (p) => … }`. */
38
+ onEvents?: Record<string, (params: unknown) => void>
39
+ }
40
+
41
+ const cssVar = (name: string) =>
42
+ getComputedStyle(document.documentElement).getPropertyValue(name).trim()
43
+
44
+ /** Build an ECharts theme object from the resolved Windforge tokens. */
45
+ function buildTheme(): object {
46
+ const text = cssVar('--wf-color-text-primary')
47
+ const muted = cssVar('--wf-color-text-secondary')
48
+ const faint = cssVar('--wf-color-text-tertiary')
49
+ const line = cssVar('--wf-color-border-default')
50
+ const subtle = cssVar('--wf-color-border-subtle')
51
+ const surface = cssVar('--wf-color-background-paper')
52
+ const font = cssVar('--wf-font-sans')
53
+ const palette = [
54
+ cssVar('--wf-color-brand-primary'),
55
+ cssVar('--wf-color-status-info-default'),
56
+ cssVar('--wf-color-status-success-default'),
57
+ cssVar('--wf-color-status-warning-default'),
58
+ cssVar('--wf-color-status-error-default'),
59
+ cssVar('--wf-color-brand-secondary'),
60
+ ].filter(Boolean)
61
+
62
+ const axis = {
63
+ axisLine: { lineStyle: { color: line } },
64
+ axisTick: { lineStyle: { color: line } },
65
+ axisLabel: { color: muted },
66
+ splitLine: { lineStyle: { color: subtle } },
67
+ }
68
+ return {
69
+ color: palette,
70
+ backgroundColor: 'transparent',
71
+ textStyle: { color: text, fontFamily: font },
72
+ title: { textStyle: { color: text }, subtextStyle: { color: faint } },
73
+ legend: { textStyle: { color: muted } },
74
+ tooltip: {
75
+ backgroundColor: surface,
76
+ borderColor: line,
77
+ textStyle: { color: text, fontFamily: font },
78
+ axisPointer: { lineStyle: { color: muted }, crossStyle: { color: muted } },
79
+ },
80
+ categoryAxis: axis,
81
+ valueAxis: axis,
82
+ logAxis: axis,
83
+ timeAxis: axis,
84
+ // Series labels (notably pie's outside labels) don't inherit the global
85
+ // textStyle — pin them to the foreground token so they stay readable in both
86
+ // modes, and tint the connector lines with the border token.
87
+ pie: {
88
+ label: { color: text, fontFamily: font },
89
+ labelLine: { lineStyle: { color: line } },
90
+ },
91
+ }
92
+ }
93
+
94
+ export function Chart({ option, height = 320, notMerge = true, onEvents }: ChartProps) {
95
+ const elRef = React.useRef<HTMLDivElement>(null)
96
+ const chartRef = React.useRef<echarts.ECharts | null>(null)
97
+ // Bump on any document-root attribute change (mode class / runtime token style),
98
+ // so the chart re-inits with the new theme — provider-independent reactivity.
99
+ const [themeKey, bump] = React.useReducer((x: number) => x + 1, 0)
100
+
101
+ React.useEffect(() => {
102
+ const mo = new MutationObserver(() => bump())
103
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] })
104
+ return () => mo.disconnect()
105
+ }, [])
106
+
107
+ // (Re)create the instance when the theme changes; ECharts bakes the theme at init.
108
+ React.useEffect(() => {
109
+ if (!elRef.current) return
110
+ const chart = echarts.init(elRef.current, buildTheme())
111
+ chartRef.current = chart
112
+ chart.setOption(option, notMerge)
113
+ if (onEvents) for (const [name, handler] of Object.entries(onEvents)) chart.on(name, handler)
114
+ const ro = new ResizeObserver(() => chart.resize())
115
+ ro.observe(elRef.current)
116
+ return () => {
117
+ ro.disconnect()
118
+ chart.dispose()
119
+ chartRef.current = null
120
+ }
121
+ // eslint-disable-next-line react-hooks/exhaustive-deps
122
+ }, [themeKey])
123
+
124
+ // Push option updates to the existing instance (no re-init needed).
125
+ React.useEffect(() => {
126
+ chartRef.current?.setOption(option, notMerge)
127
+ }, [option, notMerge])
128
+
129
+ return <div ref={elRef} role="img" style={{ height, width: '100%' }} />
130
+ }
@@ -0,0 +1,27 @@
1
+ import * as React from 'react'
2
+ import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
3
+ import { Check } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import type { NoStyle } from '../lib/types'
6
+ import { focusRing } from '../lib/recipes'
7
+
8
+ export const Checkbox = React.forwardRef<
9
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
10
+ NoStyle<React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>>
11
+ >(({ ...props }, ref) => (
12
+ <CheckboxPrimitive.Root
13
+ ref={ref}
14
+ className={cn(
15
+ 'peer h-5 w-5 shrink-0 rounded-sm border border-strong transition-colors',
16
+ focusRing,
17
+ 'disabled:cursor-not-allowed disabled:opacity-50',
18
+ 'data-[state=checked]:border-transparent data-[state=checked]:bg-surface-inverse data-[state=checked]:text-inverse',
19
+ )}
20
+ {...props}
21
+ >
22
+ <CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
23
+ <Check className="h-3.5 w-3.5" strokeWidth={3} />
24
+ </CheckboxPrimitive.Indicator>
25
+ </CheckboxPrimitive.Root>
26
+ ))
27
+ Checkbox.displayName = CheckboxPrimitive.Root.displayName
@@ -0,0 +1,75 @@
1
+ import * as React from 'react'
2
+ import { X } from 'lucide-react'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { cn } from '../lib/utils'
5
+ import type { NoStyle } from '../lib/types'
6
+ import { focusRingInset } from '../lib/recipes'
7
+
8
+ /**
9
+ * Chip — an interactive pill: clickable (filter/selection) and optionally
10
+ * deletable. Distinct from `Badge`, which is a static label. Set `selected` for
11
+ * the active state and pass `onDelete` to show a trailing ✕.
12
+ */
13
+ const chip = cva(
14
+ 'inline-flex items-center gap-1.5 rounded-full border text-sm font-medium transition-colors ' +
15
+ '[&_svg]:size-3.5 disabled:opacity-50 disabled:pointer-events-none',
16
+ {
17
+ variants: {
18
+ selected: {
19
+ true: 'border-transparent bg-surface-inverse text-inverse',
20
+ false: 'border-strong bg-surface text-primary hover:bg-surface-subtle',
21
+ },
22
+ clickable: { true: 'cursor-pointer ' + focusRingInset, false: '' },
23
+ size: {
24
+ sm: 'px-2 py-0.5',
25
+ md: 'px-3 py-1',
26
+ },
27
+ },
28
+ defaultVariants: { selected: false, clickable: false, size: 'md' },
29
+ },
30
+ )
31
+
32
+ export interface ChipProps
33
+ extends NoStyle<Omit<React.HTMLAttributes<HTMLElement>, 'onClick'>>,
34
+ VariantProps<typeof chip> {
35
+ onClick?: React.MouseEventHandler<HTMLButtonElement>
36
+ /** When provided, renders a trailing ✕ that calls this instead of selecting. */
37
+ onDelete?: () => void
38
+ disabled?: boolean
39
+ icon?: React.ReactNode
40
+ }
41
+
42
+ export const Chip = React.forwardRef<HTMLElement, ChipProps>(
43
+ ({ selected, size, onClick, onDelete, disabled, icon, children, ...props }, ref) => {
44
+ const clickable = !!onClick
45
+ const Comp: React.ElementType = clickable ? 'button' : 'span'
46
+ return (
47
+ <Comp
48
+ ref={ref as never}
49
+ className={cn(chip({ selected, clickable, size }))}
50
+ {...(clickable ? { type: 'button', onClick, disabled, 'aria-pressed': !!selected } : {})}
51
+ {...props}
52
+ >
53
+ {icon}
54
+ {children}
55
+ {onDelete && (
56
+ <button
57
+ type="button"
58
+ aria-label="Remove"
59
+ disabled={disabled}
60
+ onClick={(event) => {
61
+ event.stopPropagation()
62
+ onDelete()
63
+ }}
64
+ className={cn('-mr-1 ml-0.5 rounded-full p-0.5 opacity-70 hover:opacity-100', focusRingInset)}
65
+ >
66
+ <X />
67
+ </button>
68
+ )}
69
+ </Comp>
70
+ )
71
+ },
72
+ )
73
+ Chip.displayName = 'Chip'
74
+
75
+ export { chip as chipVariants }
@@ -0,0 +1,126 @@
1
+ import * as React from 'react'
2
+ import { Highlight, type PrismTheme } from 'prism-react-renderer'
3
+ import { Check, Copy } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import { focusRingInset } from '../lib/recipes'
6
+
7
+ /**
8
+ * CodeBlock — the on-system, syntax-highlighted code surface. One source of truth
9
+ * for every snippet in the system (docs, examples, anywhere). Highlighting runs
10
+ * locally via prism-react-renderer — no network, no API. The token theme below is
11
+ * built from `var(--wf-*)` references, so colors resolve live: the code re-skins
12
+ * with light/dark and with a brand swap automatically, no JS reactivity needed.
13
+ *
14
+ * Takes no className by design — its surface is fixed so every snippet in a product
15
+ * renders identically.
16
+ *
17
+ * <CodeBlock code={src} language="tsx" filename="button.tsx" showLineNumbers />
18
+ */
19
+ export interface CodeBlockProps {
20
+ code: string
21
+ /** Prism language id. Default 'tsx'. */
22
+ language?: string
23
+ /** Optional header filename; shown left of the language label. */
24
+ filename?: string
25
+ /** Render a left gutter with 1-based line numbers. */
26
+ showLineNumbers?: boolean
27
+ /** 1-based line numbers to emphasize. */
28
+ highlightLines?: number[]
29
+ /** Soft-wrap long lines instead of scrolling horizontally. */
30
+ wrap?: boolean
31
+ /** Max height before vertical scroll, e.g. '24rem'. */
32
+ maxHeight?: string
33
+ /** Show the copy button (default true). */
34
+ copyable?: boolean
35
+ }
36
+
37
+ // Token-driven Prism theme. Every color is a live CSS variable, so the panel
38
+ // follows the active mode and brand with zero JS — keywords ride the brand, the
39
+ // status ramp carries strings/numbers/props, neutrals carry text/comments.
40
+ const prismTheme: PrismTheme = {
41
+ plain: { color: 'var(--wf-color-text-primary)', backgroundColor: 'transparent' },
42
+ styles: [
43
+ { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--wf-color-text-tertiary)', fontStyle: 'italic' } },
44
+ { types: ['punctuation'], style: { color: 'var(--wf-color-text-secondary)' } },
45
+ { types: ['tag', 'keyword', 'selector', 'operator', 'builtin'], style: { color: 'var(--wf-color-brand-primary)' } },
46
+ { types: ['function', 'class-name', 'function-variable'], style: { color: 'var(--wf-color-status-info-default)' } },
47
+ { types: ['attr-name', 'property', 'variable', 'entity'], style: { color: 'var(--wf-color-status-error-default)' } },
48
+ { types: ['string', 'char', 'attr-value', 'inserted', 'regex'], style: { color: 'var(--wf-color-status-success-default)' } },
49
+ { types: ['number', 'boolean', 'constant', 'symbol'], style: { color: 'var(--wf-color-status-warning-default)' } },
50
+ ],
51
+ }
52
+
53
+ export function CodeBlock({
54
+ code, language = 'tsx', filename, showLineNumbers, highlightLines, wrap, maxHeight, copyable = true,
55
+ }: CodeBlockProps) {
56
+ const [copied, setCopied] = React.useState(false)
57
+ const text = code.replace(/\n+$/, '')
58
+ const highlightSet = React.useMemo(() => new Set(highlightLines ?? []), [highlightLines])
59
+
60
+ const copy = () => {
61
+ void navigator.clipboard?.writeText(text)
62
+ setCopied(true)
63
+ window.setTimeout(() => setCopied(false), 1400)
64
+ }
65
+
66
+ const copyButton = (
67
+ <button
68
+ type="button"
69
+ onClick={copy}
70
+ aria-label={copied ? 'Copied' : 'Copy code'}
71
+ className={cn(
72
+ 'inline-flex size-8 items-center justify-center rounded-md text-secondary opacity-70 transition-opacity',
73
+ 'hover:bg-surface-subtle hover:opacity-100 [&_svg]:size-4',
74
+ focusRingInset,
75
+ )}
76
+ >
77
+ {copied ? <Check className="text-success" /> : <Copy />}
78
+ </button>
79
+ )
80
+
81
+ return (
82
+ <div className="relative overflow-hidden rounded-xl border border-border bg-surface-inset text-sm">
83
+ {filename != null && (
84
+ <div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
85
+ <span className="truncate font-mono text-sm text-secondary">{filename}</span>
86
+ <div className="flex items-center gap-2">
87
+ <span className="select-none text-sm uppercase tracking-wide text-tertiary">{language}</span>
88
+ {copyable && copyButton}
89
+ </div>
90
+ </div>
91
+ )}
92
+
93
+ {filename == null && copyable && <div className="absolute right-2 top-2 z-10">{copyButton}</div>}
94
+
95
+ <Highlight code={text} language={language} theme={prismTheme}>
96
+ {({ style, tokens, getLineProps, getTokenProps }) => (
97
+ <pre
98
+ className={cn('m-0 overflow-auto py-3 font-mono text-sm leading-relaxed', filename == null && copyable && 'pr-12')}
99
+ style={{ ...style, background: 'transparent', maxHeight }}
100
+ >
101
+ {tokens.map((line, i) => {
102
+ const lineProps = getLineProps({ line })
103
+ const isHot = highlightSet.has(i + 1)
104
+ return (
105
+ <div
106
+ key={i}
107
+ {...lineProps}
108
+ className={cn(lineProps.className, 'flex px-4', wrap && 'whitespace-pre-wrap', isHot && 'bg-brand-subtle')}
109
+ >
110
+ {showLineNumbers && (
111
+ <span className="mr-4 inline-block w-6 shrink-0 select-none text-right text-tertiary">{i + 1}</span>
112
+ )}
113
+ <span className={cn('flex-1', wrap && 'break-words')}>
114
+ {line.map((token, key) => (
115
+ <span key={key} {...getTokenProps({ token })} />
116
+ ))}
117
+ </span>
118
+ </div>
119
+ )
120
+ })}
121
+ </pre>
122
+ )}
123
+ </Highlight>
124
+ </div>
125
+ )
126
+ }
@@ -0,0 +1,139 @@
1
+ import * as React from 'react'
2
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
3
+ import { Command as CommandPrimitive } from 'cmdk'
4
+ import { Search } from 'lucide-react'
5
+ import { cn } from '../lib/utils'
6
+ import type { NoStyle } from '../lib/types'
7
+ import { menuItem, overlayBackdrop } from '../lib/recipes'
8
+
9
+ /**
10
+ * Command — a fast, filterable command menu / palette (cmdk under the hood),
11
+ * token-styled to match the system. Compose `Command` > `CommandInput` +
12
+ * `CommandList` (`CommandEmpty`/`CommandGroup`/`CommandItem`/`CommandSeparator`),
13
+ * or drop the whole thing into `CommandDialog` for a ⌘K palette.
14
+ *
15
+ * <CommandDialog open={open} onOpenChange={setOpen}>
16
+ * <CommandInput placeholder="Type a command…" />
17
+ * <CommandList>
18
+ * <CommandEmpty>No results.</CommandEmpty>
19
+ * <CommandGroup heading="Actions">
20
+ * <CommandItem onSelect={…}>New file</CommandItem>
21
+ * </CommandGroup>
22
+ * </CommandList>
23
+ * </CommandDialog>
24
+ */
25
+ export const Command = React.forwardRef<
26
+ React.ElementRef<typeof CommandPrimitive>,
27
+ NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive>>
28
+ >(({ ...props }, ref) => (
29
+ <CommandPrimitive
30
+ ref={ref}
31
+ className={cn('flex h-full w-full flex-col overflow-hidden rounded-xl bg-surface text-primary')}
32
+ {...props}
33
+ />
34
+ ))
35
+ Command.displayName = 'Command'
36
+
37
+ export const CommandInput = React.forwardRef<
38
+ React.ElementRef<typeof CommandPrimitive.Input>,
39
+ NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>>
40
+ >(({ ...props }, ref) => (
41
+ <div className="flex items-center gap-2 border-b border-border px-3">
42
+ <Search className="size-4 shrink-0 text-tertiary" />
43
+ <CommandPrimitive.Input
44
+ ref={ref}
45
+ className={cn(
46
+ 'flex h-11 w-full bg-transparent py-3 text-sm text-primary outline-none placeholder:text-tertiary',
47
+ 'disabled:cursor-not-allowed disabled:opacity-50',
48
+ )}
49
+ {...props}
50
+ />
51
+ </div>
52
+ ))
53
+ CommandInput.displayName = 'CommandInput'
54
+
55
+ export const CommandList = React.forwardRef<
56
+ React.ElementRef<typeof CommandPrimitive.List>,
57
+ NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>>
58
+ >(({ ...props }, ref) => (
59
+ <CommandPrimitive.List ref={ref} className={cn('max-h-80 overflow-y-auto overflow-x-hidden p-1')} {...props} />
60
+ ))
61
+ CommandList.displayName = 'CommandList'
62
+
63
+ export const CommandEmpty = React.forwardRef<
64
+ React.ElementRef<typeof CommandPrimitive.Empty>,
65
+ NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>>
66
+ >(({ ...props }, ref) => (
67
+ <CommandPrimitive.Empty ref={ref} className={cn('py-6 text-center text-sm text-tertiary')} {...props} />
68
+ ))
69
+ CommandEmpty.displayName = 'CommandEmpty'
70
+
71
+ export const CommandGroup = React.forwardRef<
72
+ React.ElementRef<typeof CommandPrimitive.Group>,
73
+ NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>>
74
+ >(({ ...props }, ref) => (
75
+ <CommandPrimitive.Group
76
+ ref={ref}
77
+ className={cn(
78
+ 'overflow-hidden p-1 text-primary',
79
+ '[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-tertiary',
80
+ )}
81
+ {...props}
82
+ />
83
+ ))
84
+ CommandGroup.displayName = 'CommandGroup'
85
+
86
+ export const CommandSeparator = React.forwardRef<
87
+ React.ElementRef<typeof CommandPrimitive.Separator>,
88
+ NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>>
89
+ >(({ ...props }, ref) => (
90
+ <CommandPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border')} {...props} />
91
+ ))
92
+ CommandSeparator.displayName = 'CommandSeparator'
93
+
94
+ export const CommandItem = React.forwardRef<
95
+ React.ElementRef<typeof CommandPrimitive.Item>,
96
+ NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>>
97
+ >(({ ...props }, ref) => (
98
+ <CommandPrimitive.Item
99
+ ref={ref}
100
+ className={cn(
101
+ menuItem,
102
+ 'gap-2 px-2 py-2 text-primary [&_svg]:size-4 [&_svg]:text-secondary',
103
+ 'data-[selected=true]:bg-surface-inset data-[selected=true]:text-primary',
104
+ )}
105
+ {...props}
106
+ />
107
+ ))
108
+ CommandItem.displayName = 'CommandItem'
109
+
110
+ export function CommandShortcut({ ...props }: NoStyle<React.HTMLAttributes<HTMLSpanElement>>) {
111
+ return <span className={cn('ml-auto text-sm tracking-widest text-tertiary')} {...props} />
112
+ }
113
+
114
+ export interface CommandDialogProps {
115
+ open?: boolean
116
+ onOpenChange?: (open: boolean) => void
117
+ children: React.ReactNode
118
+ /** Accessible label for the palette dialog. */
119
+ label?: string
120
+ }
121
+
122
+ export function CommandDialog({ open, onOpenChange, children, label = 'Command palette' }: CommandDialogProps) {
123
+ return (
124
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
125
+ <DialogPrimitive.Portal>
126
+ <DialogPrimitive.Overlay className={cn(overlayBackdrop)} />
127
+ <DialogPrimitive.Content
128
+ className={cn(
129
+ 'fixed left-1/2 top-[15%] z-50 w-full max-w-2xl -translate-x-1/2 overflow-hidden',
130
+ 'rounded-2xl border border-border bg-surface shadow-xl animate-scale-in',
131
+ )}
132
+ >
133
+ <DialogPrimitive.Title className="sr-only">{label}</DialogPrimitive.Title>
134
+ <Command>{children}</Command>
135
+ </DialogPrimitive.Content>
136
+ </DialogPrimitive.Portal>
137
+ </DialogPrimitive.Root>
138
+ )
139
+ }