@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,179 @@
1
+ import * as React from 'react'
2
+ import { Slot } from '@radix-ui/react-slot'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { cn } from '../lib/utils'
5
+
6
+ /**
7
+ * DETERMINISTIC LAYOUT PRIMITIVES — `Box` and `Stack`.
8
+ *
9
+ * Layout is a CLOSED set of intent-named, conventionally-named props, not free-form
10
+ * CSS. An AI (or a human) picks from a finite vocabulary — `direction="row"`,
11
+ * `gap="card"`, `padding="lg"`, `borderRadius="xl"` — and `cva` maps each choice to
12
+ * exactly one on-scale Tailwind class at build time. There is no path to an
13
+ * off-scale value like `padding-[13px]` or a random hex, so output is always
14
+ * on-system and reviewable.
15
+ *
16
+ * No JS runtime: thin wrappers that concatenate class strings — no `sx`-style
17
+ * computation at render.
18
+ *
19
+ * Spacing accepts the generic steps (none·xs·sm·md·lg·xl·2xl) AND the practical
20
+ * Windforge spacing tokens (nbsp·card·gutter·section·page).
21
+ *
22
+ * <Box padding="lg" background="subtle" borderRadius="xl" border="default">…</Box>
23
+ * <Stack direction="row" gap="card" align="center" justify="between">…</Stack>
24
+ *
25
+ * `asChild` makes either polymorphic (the replacement for MUI `component=`):
26
+ * <Box asChild padding="md"><section/></Box>
27
+ */
28
+
29
+ // NOTE: every class below is a complete literal string — required so Tailwind's
30
+ // content scanner emits it. Do not build these with template strings.
31
+
32
+ // ── shared surface vocabulary (padding + the box look) ──────────────────────────
33
+ const surface = cva('', {
34
+ variants: {
35
+ padding: {
36
+ none: 'p-none', xs: 'p-xs', sm: 'p-sm', md: 'p-md', lg: 'p-lg', xl: 'p-xl', '2xl': 'p-2xl',
37
+ nbsp: 'p-nbsp', card: 'p-card', gutter: 'p-gutter', section: 'p-section', page: 'p-page',
38
+ },
39
+ paddingX: {
40
+ none: 'px-none', xs: 'px-xs', sm: 'px-sm', md: 'px-md', lg: 'px-lg', xl: 'px-xl', '2xl': 'px-2xl',
41
+ nbsp: 'px-nbsp', card: 'px-card', gutter: 'px-gutter', section: 'px-section', page: 'px-page',
42
+ },
43
+ paddingY: {
44
+ none: 'py-none', xs: 'py-xs', sm: 'py-sm', md: 'py-md', lg: 'py-lg', xl: 'py-xl', '2xl': 'py-2xl',
45
+ nbsp: 'py-nbsp', card: 'py-card', gutter: 'py-gutter', section: 'py-section', page: 'py-page',
46
+ },
47
+ background: {
48
+ none: '',
49
+ surface: 'bg-surface',
50
+ subtle: 'bg-surface-subtle',
51
+ inset: 'bg-surface-inset',
52
+ inverse: 'bg-surface-inverse text-inverse',
53
+ brand: 'bg-brand-subtle',
54
+ },
55
+ border: { none: '', default: 'border border-border', subtle: 'border border-subtle', strong: 'border border-strong' },
56
+ borderRadius: { none: 'rounded-none', sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', '2xl': 'rounded-2xl', full: 'rounded-full' },
57
+ boxShadow: { none: 'shadow-none', sm: 'shadow-sm', md: 'shadow-md', lg: 'shadow-lg', xl: 'shadow-xl' },
58
+ // Page-width constraint (absorbs the old Container). Pair with className="mx-auto" to center.
59
+ maxWidth: { sm: 'max-w-2xl', md: 'max-w-4xl', lg: 'max-w-6xl', xl: 'max-w-7xl', prose: 'max-w-prose', full: 'max-w-none' },
60
+ },
61
+ })
62
+
63
+ type SurfaceProps = VariantProps<typeof surface>
64
+
65
+ // ── flex vocabulary (Stack only) ────────────────────────────────────────────────
66
+ const flex = cva('flex', {
67
+ variants: {
68
+ direction: { row: 'flex-row', column: 'flex-col' },
69
+ gap: {
70
+ none: 'gap-none', xs: 'gap-xs', sm: 'gap-sm', md: 'gap-md', lg: 'gap-lg', xl: 'gap-xl', '2xl': 'gap-2xl',
71
+ nbsp: 'gap-nbsp', card: 'gap-card', gutter: 'gap-gutter', section: 'gap-section', page: 'gap-page',
72
+ },
73
+ align: { start: 'items-start', center: 'items-center', end: 'items-end', stretch: 'items-stretch', baseline: 'items-baseline' },
74
+ justify: { start: 'justify-start', center: 'justify-center', end: 'justify-end', between: 'justify-between', around: 'justify-around' },
75
+ wrap: { true: 'flex-wrap', false: 'flex-nowrap' },
76
+ },
77
+ defaultVariants: { direction: 'column', gap: 'md' },
78
+ })
79
+
80
+ type FlexProps = VariantProps<typeof flex>
81
+
82
+ // ── Box: a padded, optionally-surfaced container (the everyday <div>) ────────────
83
+ export interface BoxProps extends React.HTMLAttributes<HTMLDivElement>, SurfaceProps {
84
+ asChild?: boolean
85
+ }
86
+
87
+ export const Box = React.forwardRef<HTMLDivElement, BoxProps>(
88
+ ({ className, asChild, padding, paddingX, paddingY, background, border, borderRadius, boxShadow, maxWidth, ...props }, ref) => {
89
+ const Comp = asChild ? Slot : 'div'
90
+ return (
91
+ <Comp
92
+ ref={ref}
93
+ className={cn(surface({ padding, paddingX, paddingY, background, border, borderRadius, boxShadow, maxWidth }), className)}
94
+ {...props}
95
+ />
96
+ )
97
+ },
98
+ )
99
+ Box.displayName = 'Box'
100
+
101
+ // ── Stack: a flex Box — the deterministic way to arrange items ───────────────────
102
+ export interface StackProps extends React.HTMLAttributes<HTMLDivElement>, FlexProps, SurfaceProps {
103
+ asChild?: boolean
104
+ }
105
+
106
+ export const Stack = React.forwardRef<HTMLDivElement, StackProps>(
107
+ (
108
+ { className, asChild, direction, gap, align, justify, wrap,
109
+ padding, paddingX, paddingY, background, border, borderRadius, boxShadow, maxWidth, ...props },
110
+ ref,
111
+ ) => {
112
+ const Comp = asChild ? Slot : 'div'
113
+ return (
114
+ <Comp
115
+ ref={ref}
116
+ className={cn(
117
+ flex({ direction, gap, align, justify, wrap }),
118
+ surface({ padding, paddingX, paddingY, background, border, borderRadius, boxShadow, maxWidth }),
119
+ className,
120
+ )}
121
+ {...props}
122
+ />
123
+ )
124
+ },
125
+ )
126
+ Stack.displayName = 'Stack'
127
+
128
+ // ── Grid: a closed-vocabulary CSS grid (responsive cols + on-scale gap) ──────────
129
+ const grid = cva('grid', {
130
+ variants: {
131
+ cols: {
132
+ 1: 'grid-cols-1', 2: 'grid-cols-2', 3: 'grid-cols-3', 4: 'grid-cols-4',
133
+ 5: 'grid-cols-5', 6: 'grid-cols-6', 12: 'grid-cols-12',
134
+ },
135
+ // responsive breakpoint at md — the common "stack on mobile, grid on desktop"
136
+ mdCols: {
137
+ 1: 'md:grid-cols-1', 2: 'md:grid-cols-2', 3: 'md:grid-cols-3', 4: 'md:grid-cols-4',
138
+ 5: 'md:grid-cols-5', 6: 'md:grid-cols-6', 12: 'md:grid-cols-12',
139
+ },
140
+ gap: {
141
+ none: 'gap-none', xs: 'gap-xs', sm: 'gap-sm', md: 'gap-md', lg: 'gap-lg', xl: 'gap-xl', '2xl': 'gap-2xl',
142
+ nbsp: 'gap-nbsp', card: 'gap-card', gutter: 'gap-gutter', section: 'gap-section', page: 'gap-page',
143
+ },
144
+ align: { start: 'items-start', center: 'items-center', end: 'items-end', stretch: 'items-stretch' },
145
+ justify: { start: 'justify-items-start', center: 'justify-items-center', end: 'justify-items-end', stretch: 'justify-items-stretch' },
146
+ },
147
+ defaultVariants: { cols: 1, gap: 'md' },
148
+ })
149
+
150
+ export interface GridProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof grid> {
151
+ asChild?: boolean
152
+ }
153
+
154
+ export const Grid = React.forwardRef<HTMLDivElement, GridProps>(
155
+ ({ className, asChild, cols, mdCols, gap, align, justify, ...props }, ref) => {
156
+ const Comp = asChild ? Slot : 'div'
157
+ return <Comp ref={ref} className={cn(grid({ cols, mdCols, gap, align, justify }), className)} {...props} />
158
+ },
159
+ )
160
+ Grid.displayName = 'Grid'
161
+
162
+ /**
163
+ * The machine-readable layout contract. An AI/registry reads this to know the
164
+ * exact allowed values for every layout prop — so generated layouts are picked
165
+ * from a closed set, never invented. Spacing mixes generic steps with the
166
+ * practical Windforge spacing tokens.
167
+ */
168
+ export const layoutVocabulary = {
169
+ spacing: ['none', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', 'nbsp', 'card', 'gutter', 'section', 'page'],
170
+ direction: ['row', 'column'],
171
+ align: ['start', 'center', 'end', 'stretch', 'baseline'],
172
+ justify: ['start', 'center', 'end', 'between', 'around'],
173
+ background: ['none', 'surface', 'subtle', 'inset', 'inverse', 'brand'],
174
+ border: ['none', 'default', 'subtle', 'strong'],
175
+ borderRadius: ['none', 'sm', 'md', 'lg', 'xl', '2xl', 'full'],
176
+ boxShadow: ['none', 'sm', 'md', 'lg', 'xl'],
177
+ maxWidth: ['sm', 'md', 'lg', 'xl', 'prose', 'full'],
178
+ gridCols: [1, 2, 3, 4, 5, 6, 12],
179
+ } as const
@@ -0,0 +1,37 @@
1
+ import * as React from 'react'
2
+ import { Slot } from '@radix-ui/react-slot'
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
+ * Link — an inline anchor in the link color (blue). Use `asChild` to wrap a
10
+ * router link while keeping the styling: <Link asChild><RouterLink to="…"/></Link>
11
+ */
12
+ const link = cva(
13
+ 'text-link underline-offset-2 hover:text-link-hover hover:underline rounded-sm ' +
14
+ 'transition-colors ' + focusRingInset,
15
+ {
16
+ variants: {
17
+ underline: { hover: '', always: 'underline', none: 'no-underline hover:no-underline' },
18
+ },
19
+ defaultVariants: { underline: 'hover' },
20
+ },
21
+ )
22
+
23
+ export interface LinkProps
24
+ extends NoStyle<React.AnchorHTMLAttributes<HTMLAnchorElement>>,
25
+ VariantProps<typeof link> {
26
+ asChild?: boolean
27
+ }
28
+
29
+ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
30
+ ({ asChild, underline, ...props }, ref) => {
31
+ const Comp = asChild ? Slot : 'a'
32
+ return <Comp ref={ref} className={cn(link({ underline }))} {...props} />
33
+ },
34
+ )
35
+ Link.displayName = 'Link'
36
+
37
+ export { link as linkVariants }
@@ -0,0 +1,67 @@
1
+ import * as React from 'react'
2
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
3
+ import { X } from 'lucide-react'
4
+ import { cva, type VariantProps } from 'class-variance-authority'
5
+ import { cn } from '../lib/utils'
6
+ import type { NoStyle } from '../lib/types'
7
+ import { overlayBackdrop, dismissButton, focusRingInset } from '../lib/recipes'
8
+
9
+ /**
10
+ * Modal — the low-level, fully composable overlay primitive (Radix Dialog under
11
+ * the hood). Compose freely: `Modal > ModalTrigger + ModalContent`. For the
12
+ * common title/description/actions case, reach for `Dialog`, the props-based
13
+ * convenience built on this — there are no header/title/footer sub-components.
14
+ */
15
+ export const Modal = DialogPrimitive.Root
16
+ export const ModalTrigger = DialogPrimitive.Trigger
17
+ export const ModalClose = DialogPrimitive.Close
18
+ export const ModalPortal = DialogPrimitive.Portal
19
+
20
+ export const ModalOverlay = React.forwardRef<
21
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
22
+ NoStyle<React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>
23
+ >(({ ...props }, ref) => (
24
+ <DialogPrimitive.Overlay ref={ref} className={cn(overlayBackdrop)} {...props} />
25
+ ))
26
+ ModalOverlay.displayName = DialogPrimitive.Overlay.displayName
27
+
28
+ const modalContent = cva(
29
+ 'fixed left-1/2 top-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 ' +
30
+ 'rounded-2xl border border-border bg-surface p-6 shadow-xl animate-scale-in',
31
+ {
32
+ variants: {
33
+ size: { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' },
34
+ },
35
+ defaultVariants: { size: 'md' },
36
+ },
37
+ )
38
+
39
+ export interface ModalContentProps
40
+ extends NoStyle<React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>>,
41
+ VariantProps<typeof modalContent> {
42
+ /** Hide the default corner close button. */
43
+ hideClose?: boolean
44
+ }
45
+
46
+ export const ModalContent = React.forwardRef<
47
+ React.ElementRef<typeof DialogPrimitive.Content>,
48
+ ModalContentProps
49
+ >(({ children, size, hideClose, ...props }, ref) => (
50
+ <ModalPortal>
51
+ <ModalOverlay />
52
+ <DialogPrimitive.Content ref={ref} className={cn(modalContent({ size }))} {...props}>
53
+ {children}
54
+ {!hideClose && (
55
+ <DialogPrimitive.Close
56
+ className={cn(dismissButton, 'hover:bg-surface-inset focus:outline-none', focusRingInset)}
57
+ aria-label="Close"
58
+ >
59
+ <X className="h-4 w-4" />
60
+ </DialogPrimitive.Close>
61
+ )}
62
+ </DialogPrimitive.Content>
63
+ </ModalPortal>
64
+ ))
65
+ ModalContent.displayName = DialogPrimitive.Content.displayName
66
+
67
+ export { modalContent }
@@ -0,0 +1,175 @@
1
+ import * as React from 'react'
2
+ import * as Popover from '@radix-ui/react-popover'
3
+ import { Check, ChevronsUpDown, X } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import { floatingPanel, menuItem } from '../lib/recipes'
6
+
7
+ /**
8
+ * MultiSelect — a searchable multi-value combobox. Selected values render as
9
+ * removable tags in the field; the dropdown filters as you type and toggles
10
+ * options with the keyboard (↑/↓ move, Enter toggles, Backspace removes the last
11
+ * tag on an empty query). Controlled via `value`/`onValueChange`. Shares the Radix
12
+ * popper and floating-panel recipe with Select/Autocomplete.
13
+ */
14
+ export interface MultiSelectOption {
15
+ value: string
16
+ label: string
17
+ }
18
+
19
+ export interface MultiSelectProps {
20
+ options: MultiSelectOption[]
21
+ value: string[]
22
+ onValueChange: (value: string[]) => void
23
+ placeholder?: string
24
+ emptyText?: string
25
+ disabled?: boolean
26
+ invalid?: boolean
27
+ id?: string
28
+ 'aria-describedby'?: string
29
+ }
30
+
31
+ export function MultiSelect({
32
+ options, value, onValueChange, placeholder = 'Select…', emptyText = 'No results',
33
+ disabled, invalid, id, 'aria-describedby': ariaDescribedBy,
34
+ }: MultiSelectProps) {
35
+ const [open, setOpen] = React.useState(false)
36
+ const [query, setQuery] = React.useState('')
37
+ const [highlight, setHighlight] = React.useState(0)
38
+ const inputRef = React.useRef<HTMLInputElement>(null)
39
+ const anchorRef = React.useRef<HTMLDivElement>(null)
40
+ const listId = React.useId()
41
+
42
+ const selectedOptions = value
43
+ .map((v) => options.find((o) => o.value === v))
44
+ .filter((o): o is MultiSelectOption => !!o)
45
+ const filtered = React.useMemo(
46
+ () => (query ? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase())) : options),
47
+ [query, options],
48
+ )
49
+
50
+ React.useEffect(() => {
51
+ setHighlight((c) => Math.min(c, Math.max(0, filtered.length - 1)))
52
+ }, [filtered.length])
53
+
54
+ const toggle = (v: string) =>
55
+ onValueChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v])
56
+
57
+ const onKeyDown = (event: React.KeyboardEvent) => {
58
+ if (event.key === 'ArrowDown') {
59
+ event.preventDefault()
60
+ if (!open) setOpen(true)
61
+ setHighlight((c) => Math.min(c + 1, filtered.length - 1))
62
+ } else if (event.key === 'ArrowUp') {
63
+ event.preventDefault()
64
+ setHighlight((c) => Math.max(c - 1, 0))
65
+ } else if (event.key === 'Enter' && open && filtered[highlight]) {
66
+ event.preventDefault()
67
+ toggle(filtered[highlight].value)
68
+ } else if (event.key === 'Backspace' && !query && value.length) {
69
+ onValueChange(value.slice(0, -1))
70
+ } else if (event.key === 'Escape') {
71
+ setOpen(false)
72
+ }
73
+ }
74
+
75
+ const optionId = (i: number) => `${listId}-opt-${i}`
76
+
77
+ return (
78
+ <Popover.Root open={open} onOpenChange={setOpen}>
79
+ <Popover.Anchor asChild>
80
+ <div
81
+ ref={anchorRef}
82
+ className={cn(
83
+ 'flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-lg border border-strong bg-surface px-2 py-1.5 text-sm text-primary',
84
+ 'focus-within:border-focus focus-within:ring-2 focus-within:ring-ring',
85
+ disabled && 'pointer-events-none opacity-50',
86
+ invalid && 'border-error focus-within:border-error',
87
+ )}
88
+ onMouseDown={(e) => {
89
+ // Clicking the field (not a tag's ✕) focuses the input and opens.
90
+ if (e.target === e.currentTarget) inputRef.current?.focus()
91
+ }}
92
+ >
93
+ {selectedOptions.map((o) => (
94
+ <span
95
+ key={o.value}
96
+ className="inline-flex items-center gap-1 rounded-full border border-transparent bg-surface-inset px-2 py-0.5 text-sm text-primary"
97
+ >
98
+ {o.label}
99
+ <button
100
+ type="button"
101
+ aria-label={`Remove ${o.label}`}
102
+ onMouseDown={(e) => { e.preventDefault(); e.stopPropagation() }}
103
+ onClick={() => toggle(o.value)}
104
+ className="rounded-full opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-3"
105
+ >
106
+ <X />
107
+ </button>
108
+ </span>
109
+ ))}
110
+ <input
111
+ ref={inputRef}
112
+ id={id}
113
+ role="combobox"
114
+ aria-expanded={open}
115
+ aria-controls={listId}
116
+ aria-autocomplete="list"
117
+ aria-invalid={invalid || undefined}
118
+ aria-describedby={ariaDescribedBy}
119
+ aria-activedescendant={open && filtered[highlight] ? optionId(highlight) : undefined}
120
+ disabled={disabled}
121
+ value={query}
122
+ placeholder={selectedOptions.length ? '' : placeholder}
123
+ onChange={(e) => { setQuery(e.target.value); setHighlight(0); setOpen(true) }}
124
+ onFocus={() => setOpen(true)}
125
+ onKeyDown={onKeyDown}
126
+ className="min-w-16 flex-1 bg-transparent placeholder:text-tertiary outline-none"
127
+ />
128
+ <ChevronsUpDown className="size-4 shrink-0 text-tertiary" />
129
+ </div>
130
+ </Popover.Anchor>
131
+
132
+ <Popover.Portal>
133
+ <Popover.Content
134
+ role="listbox"
135
+ aria-multiselectable
136
+ id={listId}
137
+ align="start"
138
+ sideOffset={4}
139
+ onOpenAutoFocus={(e) => e.preventDefault()}
140
+ onCloseAutoFocus={(e) => e.preventDefault()}
141
+ // Clicking/typing in the field itself isn't "outside" — only a real
142
+ // outside interaction should dismiss. Without this the open-on-focus and
143
+ // the click's pointer-down race, so the list flashes open then closes.
144
+ onInteractOutside={(event) => {
145
+ const target = event.detail.originalEvent.target as Node | null
146
+ if (target && anchorRef.current?.contains(target)) event.preventDefault()
147
+ }}
148
+ className={cn(floatingPanel, 'max-h-60 w-[var(--radix-popover-trigger-width)] overflow-auto p-1 shadow-lg animate-scale-in')}
149
+ >
150
+ {filtered.length === 0 ? (
151
+ <div className="px-2 py-1.5 text-sm text-tertiary">{emptyText}</div>
152
+ ) : (
153
+ filtered.map((option, index) => {
154
+ const isSelected = value.includes(option.value)
155
+ return (
156
+ <div
157
+ key={option.value}
158
+ id={optionId(index)}
159
+ role="option"
160
+ aria-selected={isSelected}
161
+ onMouseEnter={() => setHighlight(index)}
162
+ onMouseDown={(e) => { e.preventDefault(); toggle(option.value) }}
163
+ className={cn(menuItem, 'gap-2 px-2 py-1.5', index === highlight ? 'bg-surface-inset text-primary' : 'text-secondary')}
164
+ >
165
+ <Check className={cn('size-4 shrink-0', isSelected ? 'opacity-100 text-primary' : 'opacity-0')} />
166
+ {option.label}
167
+ </div>
168
+ )
169
+ })
170
+ )}
171
+ </Popover.Content>
172
+ </Popover.Portal>
173
+ </Popover.Root>
174
+ )
175
+ }
@@ -0,0 +1,72 @@
1
+ import * as React from 'react'
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
3
+ import { cn } from '../lib/utils'
4
+ import type { NoStyle } from '../lib/types'
5
+ import { focusRingInset } from '../lib/recipes'
6
+
7
+ /**
8
+ * Pagination — controlled page navigation. `page` is 1-based; `count` is the
9
+ * total number of pages. Renders first/last, a window of siblings around the
10
+ * current page, and ellipses. Emits `onPageChange`.
11
+ */
12
+ export interface PaginationProps extends NoStyle<Omit<React.HTMLAttributes<HTMLElement>, 'onChange'>> {
13
+ page: number
14
+ count: number
15
+ onPageChange: (page: number) => void
16
+ siblingCount?: number
17
+ }
18
+
19
+ const DOTS = 'dots'
20
+
21
+ function range(start: number, end: number) {
22
+ return Array.from({ length: end - start + 1 }, (_, i) => start + i)
23
+ }
24
+
25
+ function usePageItems(page: number, count: number, siblingCount: number): (number | typeof DOTS)[] {
26
+ return React.useMemo(() => {
27
+ const total = siblingCount * 2 + 5 // first, last, current, 2 dots
28
+ if (count <= total) return range(1, count)
29
+ const left = Math.max(page - siblingCount, 1)
30
+ const right = Math.min(page + siblingCount, count)
31
+ const showLeftDots = left > 2
32
+ const showRightDots = right < count - 1
33
+ if (!showLeftDots && showRightDots) return [...range(1, 3 + siblingCount * 2), DOTS, count]
34
+ if (showLeftDots && !showRightDots) return [1, DOTS, ...range(count - (2 + siblingCount * 2), count)]
35
+ return [1, DOTS, ...range(left, right), DOTS, count]
36
+ }, [page, count, siblingCount])
37
+ }
38
+
39
+ const cell =
40
+ 'inline-flex h-9 min-w-9 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors ' +
41
+ 'disabled:opacity-40 disabled:pointer-events-none [&_svg]:size-4 ' + focusRingInset
42
+
43
+ export function Pagination({ page, count, onPageChange, siblingCount = 1, ...props }: PaginationProps) {
44
+ const pageItems = usePageItems(page, count, siblingCount)
45
+ const goToPage = (targetPage: number) => onPageChange(Math.min(count, Math.max(1, targetPage)))
46
+
47
+ return (
48
+ <nav role="navigation" aria-label="Pagination" className={cn('flex items-center gap-1')} {...props}>
49
+ <button type="button" className={cn(cell, 'text-secondary hover:bg-surface-inset hover:text-primary')} onClick={() => goToPage(page - 1)} disabled={page <= 1} aria-label="Previous page">
50
+ <ChevronLeft />
51
+ </button>
52
+ {pageItems.map((pageItem, index) =>
53
+ pageItem === DOTS ? (
54
+ <span key={`dots-${index}`} className="inline-flex h-9 min-w-9 items-center justify-center text-tertiary">…</span>
55
+ ) : (
56
+ <button
57
+ key={pageItem}
58
+ type="button"
59
+ aria-current={pageItem === page ? 'page' : undefined}
60
+ onClick={() => goToPage(pageItem)}
61
+ className={cn(cell, pageItem === page ? 'bg-surface-inverse text-inverse' : 'text-secondary hover:bg-surface-inset hover:text-primary')}
62
+ >
63
+ {pageItem}
64
+ </button>
65
+ ),
66
+ )}
67
+ <button type="button" className={cn(cell, 'text-secondary hover:bg-surface-inset hover:text-primary')} onClick={() => goToPage(page + 1)} disabled={page >= count} aria-label="Next page">
68
+ <ChevronRight />
69
+ </button>
70
+ </nav>
71
+ )
72
+ }
@@ -0,0 +1,25 @@
1
+ import * as React from 'react'
2
+ import * as PopoverPrimitive from '@radix-ui/react-popover'
3
+ import { cn } from '../lib/utils'
4
+ import type { NoStyle } from '../lib/types'
5
+ import { floatingPanel } from '../lib/recipes'
6
+
7
+ export const Popover = PopoverPrimitive.Root
8
+ export const PopoverTrigger = PopoverPrimitive.Trigger
9
+ export const PopoverAnchor = PopoverPrimitive.Anchor
10
+
11
+ export const PopoverContent = React.forwardRef<
12
+ React.ElementRef<typeof PopoverPrimitive.Content>,
13
+ NoStyle<React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>>
14
+ >(({ align = 'center', sideOffset = 6, ...props }, ref) => (
15
+ <PopoverPrimitive.Portal>
16
+ <PopoverPrimitive.Content
17
+ ref={ref}
18
+ align={align}
19
+ sideOffset={sideOffset}
20
+ className={cn(floatingPanel, 'w-72 rounded-xl p-4 shadow-lg animate-scale-in')}
21
+ {...props}
22
+ />
23
+ </PopoverPrimitive.Portal>
24
+ ))
25
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName
@@ -0,0 +1,31 @@
1
+ import * as React from 'react'
2
+ import * as ProgressPrimitive from '@radix-ui/react-progress'
3
+ import { cn } from '../lib/utils'
4
+ import type { NoStyle } from '../lib/types'
5
+
6
+ export const Progress = React.forwardRef<
7
+ React.ElementRef<typeof ProgressPrimitive.Root>,
8
+ NoStyle<React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>> & {
9
+ value?: number
10
+ /** Looping animation for work of unknown duration; ignores `value`. */
11
+ indeterminate?: boolean
12
+ }
13
+ >(({ value = 0, indeterminate, ...props }, ref) => (
14
+ <ProgressPrimitive.Root
15
+ ref={ref}
16
+ value={indeterminate ? null : value}
17
+ className={cn('relative h-2 w-full overflow-hidden rounded-full bg-surface-track')}
18
+ {...props}
19
+ >
20
+ <ProgressPrimitive.Indicator
21
+ className={cn(
22
+ 'h-full bg-surface-inverse',
23
+ indeterminate
24
+ ? 'w-2/5 rounded-full animate-progress-indeterminate'
25
+ : 'w-full flex-1 transition-transform duration-slow',
26
+ )}
27
+ style={indeterminate ? undefined : { transform: `translateX(-${100 - (value || 0)}%)` }}
28
+ />
29
+ </ProgressPrimitive.Root>
30
+ ))
31
+ Progress.displayName = ProgressPrimitive.Root.displayName
@@ -0,0 +1,34 @@
1
+ import * as React from 'react'
2
+ import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
3
+ import { Circle } 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 RadioGroup = React.forwardRef<
9
+ React.ElementRef<typeof RadioGroupPrimitive.Root>,
10
+ NoStyle<React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>>
11
+ >(({ ...props }, ref) => (
12
+ <RadioGroupPrimitive.Root ref={ref} className={cn('grid gap-2.5')} {...props} />
13
+ ))
14
+ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
15
+
16
+ export const RadioGroupItem = React.forwardRef<
17
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
18
+ NoStyle<React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>>
19
+ >(({ ...props }, ref) => (
20
+ <RadioGroupPrimitive.Item
21
+ ref={ref}
22
+ className={cn(
23
+ 'aspect-square h-5 w-5 rounded-full border border-strong text-primary transition-colors',
24
+ focusRing,
25
+ 'disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-strong',
26
+ )}
27
+ {...props}
28
+ >
29
+ <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
30
+ <Circle className="h-2.5 w-2.5 fill-primary text-primary" />
31
+ </RadioGroupPrimitive.Indicator>
32
+ </RadioGroupPrimitive.Item>
33
+ ))
34
+ RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName