@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,91 @@
1
+ import * as React from 'react'
2
+ import * as AccordionPrimitive from '@radix-ui/react-accordion'
3
+ import { ChevronDown } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import type { NoStyle } from '../lib/types'
6
+ import { focusRingInset } from '../lib/recipes'
7
+
8
+ /**
9
+ * Accordion — pass an `items` array for the common case (the component renders
10
+ * each item, trigger, and panel), or compose `AccordionItem`/`AccordionTrigger`/
11
+ * `AccordionContent` by hand. `type`/`collapsible`/`defaultValue` pass through
12
+ * to the underlying Radix root.
13
+ *
14
+ * <Accordion type="single" collapsible items={[
15
+ * { value: 'a', trigger: 'What is Windforge?', content: <p>…</p> },
16
+ * ]} />
17
+ */
18
+ export interface AccordionItemData {
19
+ value: string
20
+ trigger: React.ReactNode
21
+ content: React.ReactNode
22
+ disabled?: boolean
23
+ }
24
+
25
+ // Keep the raw Radix union (single | multiple) and intersect — intersection
26
+ // distributes over the union, so the `type`/`collapsible` correlation survives.
27
+ export type AccordionProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root> & {
28
+ /** Declarative items. Omit to compose the primitives as children instead. */
29
+ items?: AccordionItemData[]
30
+ }
31
+
32
+ export const Accordion = React.forwardRef<
33
+ React.ElementRef<typeof AccordionPrimitive.Root>,
34
+ AccordionProps
35
+ >(({ items, children, ...props }, ref) => {
36
+ if (!items) return <AccordionPrimitive.Root ref={ref} {...props}>{children}</AccordionPrimitive.Root>
37
+ return (
38
+ <AccordionPrimitive.Root ref={ref} {...props}>
39
+ {items.map((item) => (
40
+ <AccordionItem key={item.value} value={item.value} disabled={item.disabled}>
41
+ <AccordionTrigger>{item.trigger}</AccordionTrigger>
42
+ <AccordionContent>{item.content}</AccordionContent>
43
+ </AccordionItem>
44
+ ))}
45
+ </AccordionPrimitive.Root>
46
+ )
47
+ })
48
+ Accordion.displayName = 'Accordion'
49
+
50
+ export const AccordionItem = React.forwardRef<
51
+ React.ElementRef<typeof AccordionPrimitive.Item>,
52
+ NoStyle<React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>>
53
+ >(({ ...props }, ref) => (
54
+ <AccordionPrimitive.Item ref={ref} className={cn('border-b border-border')} {...props} />
55
+ ))
56
+ AccordionItem.displayName = 'AccordionItem'
57
+
58
+ export const AccordionTrigger = React.forwardRef<
59
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
60
+ NoStyle<React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>>
61
+ >(({ children, ...props }, ref) => (
62
+ <AccordionPrimitive.Header className="flex">
63
+ <AccordionPrimitive.Trigger
64
+ ref={ref}
65
+ className={cn(
66
+ 'flex flex-1 items-center justify-between py-4 text-sm font-medium text-primary transition-all',
67
+ 'rounded-sm hover:text-link', focusRingInset,
68
+ '[&[data-state=open]>svg]:rotate-180',
69
+ )}
70
+ {...props}
71
+ >
72
+ {children}
73
+ <ChevronDown className="h-4 w-4 shrink-0 text-secondary transition-transform duration-200" />
74
+ </AccordionPrimitive.Trigger>
75
+ </AccordionPrimitive.Header>
76
+ ))
77
+ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
78
+
79
+ export const AccordionContent = React.forwardRef<
80
+ React.ElementRef<typeof AccordionPrimitive.Content>,
81
+ NoStyle<React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>>
82
+ >(({ children, ...props }, ref) => (
83
+ <AccordionPrimitive.Content
84
+ ref={ref}
85
+ className="overflow-hidden text-sm text-primary data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
86
+ {...props}
87
+ >
88
+ <div className={cn('pb-4 pt-0')}>{children}</div>
89
+ </AccordionPrimitive.Content>
90
+ ))
91
+ AccordionContent.displayName = AccordionPrimitive.Content.displayName
@@ -0,0 +1,58 @@
1
+ import * as React from 'react'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import { cn } from '../lib/utils'
4
+ import type { NoStyle } from '../lib/types'
5
+
6
+ // Text stays the normal foreground in every variant — only the icon carries the
7
+ // status hue. The fill is a faint status tint (the `-subtle` token), paired with
8
+ // a status-colored outline in both modes so the alert reads as a distinct
9
+ // surface — light over white, dark over the near-black background.
10
+ const alertVariants = cva(
11
+ 'relative w-full rounded-xl border p-4 flex gap-3 text-primary [&_svg]:size-5 [&_svg]:shrink-0 [&_svg]:mt-0.5',
12
+ {
13
+ variants: {
14
+ variant: {
15
+ neutral: 'border-border bg-surface [&_svg]:text-secondary',
16
+ info: 'border-info bg-info-subtle [&_svg]:text-info',
17
+ success: 'border-success bg-success-subtle [&_svg]:text-success',
18
+ warning: 'border-warning bg-warning-subtle [&_svg]:text-warning',
19
+ error: 'border-error bg-error-subtle [&_svg]:text-error',
20
+ },
21
+ },
22
+ defaultVariants: { variant: 'neutral' },
23
+ },
24
+ )
25
+
26
+ /**
27
+ * Alert — an inline status message. Pass `title`, `description`, an optional
28
+ * leading `icon`, and optional `actions`; there are no sub-components to
29
+ * assemble. For a fully custom body, use `children` instead of `description`.
30
+ *
31
+ * <Alert variant="info" icon={<Info />} title="Heads up"
32
+ * description="Your trial ends in 5 days." />
33
+ */
34
+ export interface AlertProps
35
+ extends Omit<NoStyle<React.HTMLAttributes<HTMLDivElement>>, 'title'>,
36
+ VariantProps<typeof alertVariants> {
37
+ title?: React.ReactNode
38
+ description?: React.ReactNode
39
+ /** Leading status icon (a lucide element). Inherits the variant hue. */
40
+ icon?: React.ReactNode
41
+ /** Trailing actions — typically buttons or links. */
42
+ actions?: React.ReactNode
43
+ }
44
+
45
+ export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
46
+ ({ variant, title, description, icon, actions, children, ...props }, ref) => (
47
+ <div ref={ref} role="alert" className={cn(alertVariants({ variant }))} {...props}>
48
+ {icon}
49
+ <div className="min-w-0 flex-1">
50
+ {title && <h5 className={cn('mb-0.5 font-semibold leading-snug')}>{title}</h5>}
51
+ {description && <div className={cn('text-sm text-primary')}>{description}</div>}
52
+ {children}
53
+ {actions && <div className="mt-3 flex flex-wrap items-center gap-3">{actions}</div>}
54
+ </div>
55
+ </div>
56
+ ),
57
+ )
58
+ Alert.displayName = 'Alert'
@@ -0,0 +1,174 @@
1
+ import * as React from 'react'
2
+ import * as Popover from '@radix-ui/react-popover'
3
+ import { Check, ChevronsUpDown } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import { floatingPanel, menuItem } from '../lib/recipes'
6
+
7
+ /**
8
+ * Autocomplete — a searchable single-select combobox. Type to filter; arrow keys
9
+ * move the highlight, Enter selects, Escape closes. Controlled via
10
+ * `value`/`onValueChange`. Positioning/portalling come from the shared Radix
11
+ * popper (same engine as Select/Tooltip/Popover); the combobox logic and
12
+ * `listbox`/`option` ARIA are ours.
13
+ */
14
+ export interface AutocompleteOption {
15
+ value: string
16
+ label: string
17
+ }
18
+ export interface AutocompleteProps {
19
+ options: AutocompleteOption[]
20
+ value?: string | null
21
+ onValueChange?: (value: string | null) => void
22
+ placeholder?: string
23
+ emptyText?: string
24
+ disabled?: boolean
25
+ id?: string
26
+ /** Error state — red field outline + aria-invalid. Usually set by FormField. */
27
+ invalid?: boolean
28
+ 'aria-describedby'?: string
29
+ }
30
+
31
+ export function Autocomplete({
32
+ options, value, onValueChange, placeholder = 'Search…', emptyText = 'No results', disabled, id,
33
+ invalid, 'aria-describedby': ariaDescribedBy,
34
+ }: AutocompleteProps) {
35
+ const [open, setOpen] = React.useState(false)
36
+ const [query, setQuery] = React.useState('')
37
+ const [highlight, setHighlight] = React.useState(0)
38
+ const anchorRef = React.useRef<HTMLDivElement>(null)
39
+ const listId = React.useId()
40
+
41
+ const selected = options.find((option) => option.value === value) ?? null
42
+ // When closed, the field shows the selected label; when open, the live query.
43
+ const display = open ? query : selected?.label ?? ''
44
+ const filtered = React.useMemo(
45
+ () =>
46
+ open && query
47
+ ? options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase()))
48
+ : options,
49
+ [open, query, options],
50
+ )
51
+ const optionId = (index: number) => `${listId}-opt-${index}`
52
+
53
+ // Keep the highlight in range when the filtered set shrinks, so Enter never
54
+ // selects a stale index and the highlighted row stays valid.
55
+ React.useEffect(() => {
56
+ setHighlight((current) => Math.min(current, Math.max(0, filtered.length - 1)))
57
+ }, [filtered.length])
58
+
59
+ const choose = (opt: AutocompleteOption) => {
60
+ onValueChange?.(opt.value)
61
+ setQuery('')
62
+ setOpen(false)
63
+ }
64
+
65
+ const onKeyDown = (event: React.KeyboardEvent) => {
66
+ if (event.key === 'ArrowDown') {
67
+ event.preventDefault()
68
+ if (!open) setOpen(true)
69
+ setHighlight((current) => Math.min(current + 1, filtered.length - 1))
70
+ } else if (event.key === 'ArrowUp') {
71
+ event.preventDefault()
72
+ setHighlight((current) => Math.max(current - 1, 0))
73
+ } else if (event.key === 'Enter') {
74
+ if (open && filtered[highlight]) {
75
+ event.preventDefault()
76
+ choose(filtered[highlight])
77
+ }
78
+ } else if (event.key === 'Escape') {
79
+ setOpen(false)
80
+ }
81
+ }
82
+
83
+ return (
84
+ <Popover.Root open={open} onOpenChange={setOpen}>
85
+ <Popover.Anchor asChild>
86
+ <div
87
+ ref={anchorRef}
88
+ className={cn(
89
+ 'flex h-10 items-center gap-2 rounded-lg border border-strong bg-surface px-3 text-sm text-primary',
90
+ 'focus-within:border-focus focus-within:ring-2 focus-within:ring-ring',
91
+ disabled && 'opacity-50 pointer-events-none',
92
+ invalid && 'border-error focus-within:border-error',
93
+ )}
94
+ >
95
+ <input
96
+ id={id}
97
+ role="combobox"
98
+ aria-expanded={open}
99
+ aria-controls={listId}
100
+ aria-invalid={invalid || undefined}
101
+ aria-describedby={ariaDescribedBy}
102
+ aria-autocomplete="list"
103
+ aria-activedescendant={open && filtered[highlight] ? optionId(highlight) : undefined}
104
+ disabled={disabled}
105
+ value={display}
106
+ placeholder={placeholder}
107
+ onChange={(event) => {
108
+ setQuery(event.target.value)
109
+ setHighlight(0)
110
+ setOpen(true)
111
+ }}
112
+ onFocus={() => setOpen(true)}
113
+ onKeyDown={onKeyDown}
114
+ className="w-full bg-transparent placeholder:text-tertiary outline-none"
115
+ />
116
+ <ChevronsUpDown className="size-4 shrink-0 text-tertiary" />
117
+ </div>
118
+ </Popover.Anchor>
119
+
120
+ <Popover.Portal>
121
+ <Popover.Content
122
+ // Override Radix's default role="dialog" — this is a combobox listbox.
123
+ role="listbox"
124
+ id={listId}
125
+ align="start"
126
+ sideOffset={4}
127
+ // Keep focus on the input (it's the combobox); don't let the popover grab it.
128
+ onOpenAutoFocus={(event) => event.preventDefault()}
129
+ onCloseAutoFocus={(event) => event.preventDefault()}
130
+ // Clicking/focusing the field itself isn't "outside" — only real outside
131
+ // interaction should dismiss.
132
+ onInteractOutside={(event) => {
133
+ const target = event.detail.originalEvent.target as Node | null
134
+ if (target && anchorRef.current?.contains(target)) event.preventDefault()
135
+ }}
136
+ className={cn(
137
+ floatingPanel,
138
+ 'max-h-60 w-[var(--radix-popover-trigger-width)] overflow-auto p-1 shadow-lg animate-scale-in',
139
+ )}
140
+ >
141
+ {filtered.length === 0 ? (
142
+ <div className="px-2 py-1.5 text-sm text-tertiary">{emptyText}</div>
143
+ ) : (
144
+ filtered.map((option, index) => {
145
+ const isSelected = option.value === value
146
+ return (
147
+ <div
148
+ key={option.value}
149
+ id={optionId(index)}
150
+ role="option"
151
+ aria-selected={isSelected}
152
+ onMouseEnter={() => setHighlight(index)}
153
+ // preventDefault keeps focus on the input through the click.
154
+ onMouseDown={(event) => {
155
+ event.preventDefault()
156
+ choose(option)
157
+ }}
158
+ className={cn(
159
+ menuItem,
160
+ 'gap-2 px-2 py-1.5',
161
+ index === highlight ? 'bg-surface-inset text-primary' : 'text-secondary',
162
+ )}
163
+ >
164
+ <Check className={cn('size-4 shrink-0', isSelected ? 'opacity-100 text-primary' : 'opacity-0')} />
165
+ {option.label}
166
+ </div>
167
+ )
168
+ })
169
+ )}
170
+ </Popover.Content>
171
+ </Popover.Portal>
172
+ </Popover.Root>
173
+ )
174
+ }
@@ -0,0 +1,60 @@
1
+ import * as React from 'react'
2
+ import * as AvatarPrimitive from '@radix-ui/react-avatar'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { cn } from '../lib/utils'
5
+ import type { NoStyle } from '../lib/types'
6
+
7
+ /**
8
+ * Avatar — a circular image or initials fallback. `size` is a closed scale
9
+ * (sm·md·lg·xl); the fallback's text scales with it automatically.
10
+ */
11
+ const avatar = cva('relative flex shrink-0 overflow-hidden rounded-full', {
12
+ variants: {
13
+ size: {
14
+ sm: 'h-8 w-8',
15
+ md: 'h-10 w-10',
16
+ lg: 'h-12 w-12',
17
+ xl: 'h-16 w-16',
18
+ },
19
+ },
20
+ defaultVariants: { size: 'md' },
21
+ })
22
+
23
+ type AvatarSize = NonNullable<VariantProps<typeof avatar>['size']>
24
+ const FALLBACK_TEXT: Record<AvatarSize, string> = {
25
+ sm: 'text-sm', md: 'text-sm', lg: 'text-base', xl: 'text-lg',
26
+ }
27
+ const AvatarSizeContext = React.createContext<AvatarSize>('md')
28
+
29
+ export const Avatar = React.forwardRef<
30
+ React.ElementRef<typeof AvatarPrimitive.Root>,
31
+ NoStyle<React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>> & VariantProps<typeof avatar>
32
+ >(({ size, ...props }, ref) => (
33
+ <AvatarSizeContext.Provider value={size ?? 'md'}>
34
+ <AvatarPrimitive.Root ref={ref} className={cn(avatar({ size }))} {...props} />
35
+ </AvatarSizeContext.Provider>
36
+ ))
37
+ Avatar.displayName = AvatarPrimitive.Root.displayName
38
+
39
+ export const AvatarImage = React.forwardRef<
40
+ React.ElementRef<typeof AvatarPrimitive.Image>,
41
+ NoStyle<React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>>
42
+ >(({ ...props }, ref) => (
43
+ <AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full object-cover')} {...props} />
44
+ ))
45
+ AvatarImage.displayName = AvatarPrimitive.Image.displayName
46
+
47
+ export const AvatarFallback = React.forwardRef<
48
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
49
+ NoStyle<React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>>
50
+ >(({ ...props }, ref) => {
51
+ const size = React.useContext(AvatarSizeContext)
52
+ return (
53
+ <AvatarPrimitive.Fallback
54
+ ref={ref}
55
+ className={cn('flex h-full w-full items-center justify-center rounded-full bg-surface-inset font-medium text-primary', FALLBACK_TEXT[size])}
56
+ {...props}
57
+ />
58
+ )
59
+ })
60
+ AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
@@ -0,0 +1,37 @@
1
+ import * as React from 'react'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import { cn } from '../lib/utils'
4
+ import type { NoStyle } from '../lib/types'
5
+
6
+ const badgeVariants = cva(
7
+ 'inline-flex items-center gap-1 rounded-lg border font-medium transition-colors [&_svg]:size-3',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ brand: 'border-transparent bg-brand text-contrast',
12
+ subtle: 'border-transparent bg-brand-subtle text-brand',
13
+ neutral: 'border-border bg-surface-subtle text-secondary',
14
+ outline: 'border-strong text-primary',
15
+ success: 'border-transparent bg-success-subtle text-primary',
16
+ warning: 'border-transparent bg-warning-subtle text-primary',
17
+ error: 'border-transparent bg-error-subtle text-primary',
18
+ info: 'border-transparent bg-info-subtle text-primary',
19
+ },
20
+ size: {
21
+ sm: 'px-2 py-0.5 text-sm',
22
+ md: 'px-2.5 py-0.5 text-sm',
23
+ },
24
+ },
25
+ defaultVariants: { variant: 'neutral', size: 'md' },
26
+ },
27
+ )
28
+
29
+ export interface BadgeProps
30
+ extends NoStyle<React.HTMLAttributes<HTMLSpanElement>>,
31
+ VariantProps<typeof badgeVariants> {}
32
+
33
+ export function Badge({ variant, size, ...props }: BadgeProps) {
34
+ return <span className={cn(badgeVariants({ variant, size }))} {...props} />
35
+ }
36
+
37
+ export { badgeVariants }
@@ -0,0 +1,62 @@
1
+ import * as React from 'react'
2
+ import { ChevronRight } from 'lucide-react'
3
+ import { cn } from '../lib/utils'
4
+ import type { NoStyle } from '../lib/types'
5
+ import { Text } from './text'
6
+
7
+ /**
8
+ * Breadcrumb — the path to the current page. Pass an `items` array; the last
9
+ * item renders as the (non-link) current page, the rest as links. `label` is a
10
+ * ReactNode, so a crumb can carry an icon. Override `separator` to change the
11
+ * glyph between crumbs.
12
+ *
13
+ * <Breadcrumb items={[
14
+ * { label: 'Home', href: '/' },
15
+ * { label: 'Projects', href: '/projects' },
16
+ * { label: 'Windforge' },
17
+ * ]} />
18
+ */
19
+ export interface BreadcrumbItemData {
20
+ label: React.ReactNode
21
+ /** Link target. Omit on the final crumb (the current page). */
22
+ href?: string
23
+ }
24
+
25
+ export interface BreadcrumbProps extends NoStyle<React.ComponentPropsWithoutRef<'nav'>> {
26
+ items: BreadcrumbItemData[]
27
+ /** Separator between crumbs; defaults to a chevron. */
28
+ separator?: React.ReactNode
29
+ }
30
+
31
+ export const Breadcrumb = React.forwardRef<HTMLElement, BreadcrumbProps>(
32
+ ({ items, separator, ...props }, ref) => (
33
+ <nav ref={ref} aria-label="breadcrumb" {...props}>
34
+ <ol className={cn('flex flex-wrap items-center gap-1.5 text-sm text-secondary')}>
35
+ {items.map((item, index) => {
36
+ const isLast = index === items.length - 1
37
+ return (
38
+ <React.Fragment key={index}>
39
+ <li className={cn('inline-flex items-center gap-1.5')}>
40
+ {isLast || item.href == null ? (
41
+ <Text span size="sm" weight="medium" role="link" aria-disabled="true" aria-current="page">
42
+ {item.label}
43
+ </Text>
44
+ ) : (
45
+ <a href={item.href} className={cn('transition-colors hover:text-link cursor-pointer')}>
46
+ {item.label}
47
+ </a>
48
+ )}
49
+ </li>
50
+ {!isLast && (
51
+ <li role="presentation" aria-hidden="true" className={cn('[&_svg]:size-3.5 text-tertiary')}>
52
+ {separator ?? <ChevronRight />}
53
+ </li>
54
+ )}
55
+ </React.Fragment>
56
+ )
57
+ })}
58
+ </ol>
59
+ </nav>
60
+ ),
61
+ )
62
+ Breadcrumb.displayName = 'Breadcrumb'
@@ -0,0 +1,23 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../lib/utils'
3
+ import type { NoStyle } from '../lib/types'
4
+
5
+ /**
6
+ * ButtonGroup — joins a row of Buttons into one segmented control: shared edges,
7
+ * single outer radius, collapsed seams. Put `<Button>`s (any variant) inside.
8
+ */
9
+ export const ButtonGroup = React.forwardRef<HTMLDivElement, NoStyle<React.HTMLAttributes<HTMLDivElement>>>(
10
+ ({ ...props }, ref) => (
11
+ <div
12
+ ref={ref}
13
+ role="group"
14
+ className={cn(
15
+ 'inline-flex isolate',
16
+ '[&>*]:rounded-none [&>*:first-child]:rounded-l-lg [&>*:last-child]:rounded-r-lg',
17
+ '[&>*:not(:first-child)]:-ml-px hover:[&>*]:z-10 focus-visible:[&>*]:z-10',
18
+ )}
19
+ {...props}
20
+ />
21
+ ),
22
+ )
23
+ ButtonGroup.displayName = 'ButtonGroup'
@@ -0,0 +1,53 @@
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 { focusRing } from '../lib/recipes'
7
+
8
+ /**
9
+ * Button — emphasis comes from a three-tier hierarchy (primary / secondary /
10
+ * tertiary), never from hue. `destructive` is the one semantic exception.
11
+ * Every colour is a token; nothing here is a raw value.
12
+ */
13
+ const buttonVariants = cva(
14
+ 'inline-flex items-center justify-center gap-nbsp whitespace-nowrap rounded-lg font-medium ' +
15
+ 'transition-colors duration-normal ' + focusRing + ' ' +
16
+ 'disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
17
+ {
18
+ variants: {
19
+ variant: {
20
+ primary: 'bg-brand text-contrast hover:bg-brand-hover active:bg-brand-active shadow-xs',
21
+ secondary: 'border-[length:var(--wf-border-width-control)] border-strong bg-surface text-primary hover:bg-surface-subtle active:bg-surface-inset',
22
+ tertiary: 'text-primary hover:bg-surface-inset active:bg-surface-inset',
23
+ link: 'text-link underline-offset-4 hover:underline hover:text-link-hover',
24
+ destructive: 'border-[length:var(--wf-border-width-control)] border-error bg-surface text-error hover:bg-error-subtle active:bg-error-muted',
25
+ },
26
+ size: {
27
+ sm: 'h-8 px-3 text-sm',
28
+ md: 'h-10 px-4 text-sm',
29
+ lg: 'h-12 px-6 text-base',
30
+ icon: 'h-10 w-10',
31
+ },
32
+ },
33
+ defaultVariants: { variant: 'primary', size: 'md' },
34
+ },
35
+ )
36
+
37
+ export interface ButtonProps
38
+ extends NoStyle<React.ButtonHTMLAttributes<HTMLButtonElement>>,
39
+ VariantProps<typeof buttonVariants> {
40
+ asChild?: boolean
41
+ }
42
+
43
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
44
+ ({ variant, size, asChild = false, ...props }, ref) => {
45
+ const Comp = asChild ? Slot : 'button'
46
+ return (
47
+ <Comp ref={ref} className={cn(buttonVariants({ variant, size }))} {...props} />
48
+ )
49
+ },
50
+ )
51
+ Button.displayName = 'Button'
52
+
53
+ export { buttonVariants }
@@ -0,0 +1,61 @@
1
+ import * as React from 'react'
2
+ import { DayPicker } from 'react-day-picker'
3
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import { focusRingInset } from '../lib/recipes'
6
+
7
+ /**
8
+ * Calendar — the date grid (react-day-picker), styled entirely from tokens (no
9
+ * vendor CSS imported). Use it directly for inline pickers and ranges, or via the
10
+ * DatePicker (which pairs it with a Popover + field). Pass any react-day-picker
11
+ * prop — `mode="single" | "range" | "multiple"`, `selected`, `onSelect`, etc.
12
+ */
13
+ export type CalendarProps = React.ComponentProps<typeof DayPicker>
14
+
15
+ const navButton = cn(
16
+ 'inline-flex h-7 w-7 items-center justify-center rounded-md border border-strong bg-surface text-primary',
17
+ 'transition-colors hover:bg-surface-subtle disabled:opacity-40 disabled:pointer-events-none',
18
+ focusRingInset,
19
+ )
20
+
21
+ export function Calendar({ showOutsideDays = true, classNames, ...props }: CalendarProps) {
22
+ return (
23
+ <DayPicker
24
+ showOutsideDays={showOutsideDays}
25
+ className="p-3"
26
+ classNames={{
27
+ months: 'flex flex-col gap-4',
28
+ month: 'flex flex-col gap-3',
29
+ month_caption: 'relative flex h-7 items-center justify-center',
30
+ caption_label: 'text-sm font-medium text-primary',
31
+ nav: 'absolute inset-x-0 flex items-center justify-between',
32
+ button_previous: navButton,
33
+ button_next: navButton,
34
+ month_grid: 'w-full border-collapse',
35
+ weekdays: 'flex',
36
+ weekday: 'w-9 text-sm font-normal text-tertiary',
37
+ week: 'mt-1 flex w-full',
38
+ day: 'relative h-9 w-9 p-0 text-center text-sm text-primary',
39
+ day_button: cn(
40
+ 'inline-flex h-9 w-9 items-center justify-center rounded-md transition-colors',
41
+ 'hover:bg-surface-inset', focusRingInset,
42
+ ),
43
+ today: 'font-semibold underline underline-offset-4',
44
+ selected: '[&_button]:bg-surface-inverse [&_button]:text-inverse [&_button:hover]:bg-surface-inverse',
45
+ outside: 'text-disabled',
46
+ disabled: 'text-disabled opacity-50',
47
+ range_start: 'rounded-l-md bg-surface-inset [&_button]:bg-surface-inverse [&_button]:text-inverse',
48
+ range_end: 'rounded-r-md bg-surface-inset [&_button]:bg-surface-inverse [&_button]:text-inverse',
49
+ range_middle: 'rounded-none bg-surface-inset [&_button]:bg-transparent [&_button]:text-primary',
50
+ hidden: 'invisible',
51
+ ...classNames,
52
+ }}
53
+ components={{
54
+ Chevron: ({ orientation }) =>
55
+ orientation === 'left' ? <ChevronLeft className="size-4" /> : <ChevronRight className="size-4" />,
56
+ }}
57
+ {...props}
58
+ />
59
+ )
60
+ }
61
+ Calendar.displayName = 'Calendar'