@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.
- package/dist/index.d.ts +1195 -0
- package/dist/index.js +3628 -0
- package/package.json +66 -0
- package/src/catalog.ts +654 -0
- package/src/components/accordion.tsx +91 -0
- package/src/components/alert.tsx +58 -0
- package/src/components/autocomplete.tsx +174 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +37 -0
- package/src/components/breadcrumb.tsx +62 -0
- package/src/components/button-group.tsx +23 -0
- package/src/components/button.tsx +53 -0
- package/src/components/calendar.tsx +61 -0
- package/src/components/card.tsx +72 -0
- package/src/components/chart.tsx +130 -0
- package/src/components/checkbox.tsx +27 -0
- package/src/components/chip.tsx +75 -0
- package/src/components/code-block.tsx +126 -0
- package/src/components/command.tsx +139 -0
- package/src/components/data-table.tsx +194 -0
- package/src/components/date-picker.tsx +77 -0
- package/src/components/dialog.tsx +57 -0
- package/src/components/dropdown-menu.tsx +186 -0
- package/src/components/form-field.tsx +97 -0
- package/src/components/input.tsx +29 -0
- package/src/components/label.tsx +18 -0
- package/src/components/layout.tsx +179 -0
- package/src/components/link.tsx +37 -0
- package/src/components/modal.tsx +67 -0
- package/src/components/multi-select.tsx +175 -0
- package/src/components/pagination.tsx +72 -0
- package/src/components/popover.tsx +25 -0
- package/src/components/progress.tsx +31 -0
- package/src/components/radio-group.tsx +34 -0
- package/src/components/select.tsx +134 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +80 -0
- package/src/components/skeleton.tsx +11 -0
- package/src/components/slider.tsx +28 -0
- package/src/components/stepper.tsx +69 -0
- package/src/components/switch.tsx +33 -0
- package/src/components/table.tsx +121 -0
- package/src/components/tabs.tsx +90 -0
- package/src/components/text.tsx +109 -0
- package/src/components/textarea.tsx +27 -0
- package/src/components/toast.tsx +107 -0
- package/src/components/toggle-button.tsx +103 -0
- package/src/components/tooltip.tsx +26 -0
- package/src/icons/forge-icon.tsx +55 -0
- package/src/icons/icon-set.ts +60 -0
- package/src/icons/svg-icon.tsx +43 -0
- package/src/index.ts +80 -0
- package/src/layouts/app-bar.tsx +95 -0
- package/src/layouts/app-shell.tsx +80 -0
- package/src/layouts/side-nav.tsx +196 -0
- package/src/layouts/theme-provider.tsx +128 -0
- package/src/lib/recipes.ts +50 -0
- package/src/lib/types.ts +3 -0
- package/src/lib/use-media-query.ts +18 -0
- package/src/lib/utils.ts +10 -0
- 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'
|