@glyphui/ui 0.1.1 → 0.3.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/README.md +1 -1
- package/dist/chart.d.ts +202 -0
- package/dist/chart.js +457 -0
- package/dist/data-table.js +3 -2
- package/dist/{field-DXZD0l8w.js → field-CWZ_FdkD.js} +2 -1
- package/dist/{field-context-B8cYheXz.js → field-context-anGPYXbW.js} +89 -143
- package/dist/form.js +3 -2
- package/dist/index.d.ts +4960 -266
- package/dist/index.js +5336 -146
- package/dist/{states-BxPjCUxz.js → states-DR-OoDT-.js} +2 -1
- package/dist/tv-DBAGlgXJ.js +58 -0
- package/package.json +19 -2
- package/src/components/accordion/accordion.styles.ts +41 -0
- package/src/components/accordion/accordion.tsx +86 -0
- package/src/components/accordion/index.ts +8 -0
- package/src/components/alert/alert-icons.tsx +66 -0
- package/src/components/alert/alert.styles.ts +57 -0
- package/src/components/alert/alert.tsx +86 -0
- package/src/components/alert/index.ts +2 -0
- package/src/components/alert-dialog/alert-dialog.styles.ts +24 -0
- package/src/components/alert-dialog/alert-dialog.tsx +118 -0
- package/src/components/alert-dialog/confirm.tsx +111 -0
- package/src/components/alert-dialog/index.ts +15 -0
- package/src/components/aspect-ratio/aspect-ratio.tsx +40 -0
- package/src/components/aspect-ratio/index.ts +1 -0
- package/src/components/badge/badge.styles.ts +1 -1
- package/src/components/banner/banner.styles.ts +40 -0
- package/src/components/banner/banner.tsx +101 -0
- package/src/components/banner/index.ts +2 -0
- package/src/components/blockquote/blockquote.tsx +59 -0
- package/src/components/blockquote/index.ts +1 -0
- package/src/components/breadcrumb/breadcrumb.styles.ts +20 -0
- package/src/components/breadcrumb/breadcrumb.tsx +135 -0
- package/src/components/breadcrumb/index.ts +12 -0
- package/src/components/button/button.tsx +11 -8
- package/src/components/calendar/calendar.styles.ts +50 -0
- package/src/components/calendar/calendar.tsx +84 -0
- package/src/components/calendar/index.ts +3 -0
- package/src/components/carousel/carousel.styles.ts +43 -0
- package/src/components/carousel/carousel.tsx +278 -0
- package/src/components/carousel/index.ts +13 -0
- package/src/components/code/code.styles.ts +33 -0
- package/src/components/code/code.tsx +96 -0
- package/src/components/code/index.ts +2 -0
- package/src/components/collapsible/collapsible.tsx +66 -0
- package/src/components/collapsible/index.ts +6 -0
- package/src/components/combobox/combobox.styles.ts +50 -0
- package/src/components/combobox/combobox.tsx +203 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command-menu/command-menu.styles.ts +41 -0
- package/src/components/command-menu/command-menu.tsx +217 -0
- package/src/components/command-menu/index.ts +14 -0
- package/src/components/context-menu/context-menu.styles.ts +19 -0
- package/src/components/context-menu/context-menu.tsx +226 -0
- package/src/components/context-menu/index.ts +19 -0
- package/src/components/copy-button/copy-button.tsx +117 -0
- package/src/components/copy-button/index.ts +1 -0
- package/src/components/date-picker/date-picker.styles.ts +42 -0
- package/src/components/date-picker/date-picker.tsx +322 -0
- package/src/components/date-picker/index.ts +8 -0
- package/src/components/description-list/description-list.styles.ts +31 -0
- package/src/components/description-list/description-list.tsx +56 -0
- package/src/components/description-list/index.ts +7 -0
- package/src/components/dialog/close-icon.tsx +22 -0
- package/src/components/dialog/dialog.styles.ts +41 -0
- package/src/components/dialog/dialog.tsx +111 -0
- package/src/components/dialog/index.ts +13 -0
- package/src/components/dropdown-menu/dropdown-menu.styles.ts +45 -0
- package/src/components/dropdown-menu/dropdown-menu.tsx +230 -0
- package/src/components/dropdown-menu/index.ts +19 -0
- package/src/components/file-upload/file-upload.styles.ts +43 -0
- package/src/components/file-upload/file-upload.tsx +343 -0
- package/src/components/file-upload/index.ts +10 -0
- package/src/components/grid/grid.tsx +144 -0
- package/src/components/grid/index.ts +1 -0
- package/src/components/hover-card/hover-card.tsx +41 -0
- package/src/components/hover-card/index.ts +1 -0
- package/src/components/input-otp/index.ts +10 -0
- package/src/components/input-otp/input-otp.styles.ts +38 -0
- package/src/components/input-otp/input-otp.tsx +116 -0
- package/src/components/link/index.ts +2 -0
- package/src/components/link/link.styles.ts +39 -0
- package/src/components/link/link.tsx +62 -0
- package/src/components/modal/index.ts +2 -0
- package/src/components/modal/modal.styles.ts +18 -0
- package/src/components/modal/modal.tsx +86 -0
- package/src/components/navigation-menu/index.ts +14 -0
- package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
- package/src/components/navigation-menu/navigation-menu.tsx +198 -0
- package/src/components/number-input/index.ts +2 -0
- package/src/components/number-input/number-input.styles.ts +41 -0
- package/src/components/number-input/number-input.tsx +211 -0
- package/src/components/pagination/index.ts +3 -0
- package/src/components/pagination/pagination-range.ts +35 -0
- package/src/components/pagination/pagination.styles.ts +41 -0
- package/src/components/pagination/pagination.tsx +130 -0
- package/src/components/password-input/index.ts +7 -0
- package/src/components/password-input/password-input.styles.ts +30 -0
- package/src/components/password-input/password-input.tsx +127 -0
- package/src/components/popover/index.ts +9 -0
- package/src/components/popover/popover.tsx +66 -0
- package/src/components/progress/index.ts +7 -0
- package/src/components/progress/progress.styles.ts +68 -0
- package/src/components/progress/progress.tsx +158 -0
- package/src/components/prose/index.ts +1 -0
- package/src/components/prose/prose.tsx +82 -0
- package/src/components/radio-group/index.ts +7 -0
- package/src/components/radio-group/radio-group.styles.ts +64 -0
- package/src/components/radio-group/radio-group.tsx +128 -0
- package/src/components/resizable/index.ts +9 -0
- package/src/components/resizable/resizable.styles.ts +28 -0
- package/src/components/resizable/resizable.tsx +69 -0
- package/src/components/scroll-area/index.ts +1 -0
- package/src/components/scroll-area/scroll-area.tsx +68 -0
- package/src/components/sheet/index.ts +13 -0
- package/src/components/sheet/sheet.styles.ts +60 -0
- package/src/components/sheet/sheet.tsx +108 -0
- package/src/components/slider/index.ts +2 -0
- package/src/components/slider/slider.styles.ts +32 -0
- package/src/components/slider/slider.tsx +131 -0
- package/src/components/stat-card/index.ts +2 -0
- package/src/components/stat-card/stat-card.styles.ts +24 -0
- package/src/components/stat-card/stat-card.tsx +108 -0
- package/src/components/stepper/index.ts +2 -0
- package/src/components/stepper/stepper.styles.ts +74 -0
- package/src/components/stepper/stepper.tsx +138 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.styles.ts +39 -0
- package/src/components/tag-input/tag-input.tsx +198 -0
- package/src/components/timeline/index.ts +8 -0
- package/src/components/timeline/timeline.styles.ts +56 -0
- package/src/components/timeline/timeline.tsx +91 -0
- package/src/components/toast/index.ts +1 -0
- package/src/components/toast/toast.tsx +91 -0
- package/src/components/toggle/index.ts +9 -0
- package/src/components/toggle/toggle.styles.ts +39 -0
- package/src/components/toggle/toggle.tsx +61 -0
- package/src/components/tree-view/index.ts +2 -0
- package/src/components/tree-view/tree-view.styles.ts +42 -0
- package/src/components/tree-view/tree-view.tsx +274 -0
- package/src/components/visually-hidden/index.ts +1 -0
- package/src/components/visually-hidden/visually-hidden.tsx +10 -0
- package/src/index.ts +45 -0
- package/src/patterns/chart/chart-parts.tsx +171 -0
- package/src/patterns/chart/chart.styles.ts +35 -0
- package/src/patterns/chart/chart.tsx +369 -0
- package/src/patterns/chart/index.ts +13 -0
- package/src/provider/glyph-provider.tsx +16 -2
- package/styles.css +16 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { DropdownMenu as MenuPrimitive } from 'radix-ui'
|
|
2
|
+
import type { ComponentProps, ReactNode, SVGProps } from 'react'
|
|
3
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { dropdownMenuStyles } from './dropdown-menu.styles'
|
|
5
|
+
|
|
6
|
+
function useParts(tone?: 'default' | 'danger', inset?: boolean) {
|
|
7
|
+
const [, slots] = useComponentConfig('DropdownMenu', {})
|
|
8
|
+
return { s: dropdownMenuStyles({ tone, inset }), slots }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const svg = {
|
|
12
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
13
|
+
viewBox: '0 0 24 24',
|
|
14
|
+
fill: 'none',
|
|
15
|
+
stroke: 'currentColor',
|
|
16
|
+
strokeWidth: 2,
|
|
17
|
+
strokeLinecap: 'round',
|
|
18
|
+
strokeLinejoin: 'round',
|
|
19
|
+
} as const satisfies SVGProps<SVGSVGElement>
|
|
20
|
+
|
|
21
|
+
const CheckIcon = () => (
|
|
22
|
+
<svg aria-hidden="true" {...svg}>
|
|
23
|
+
<path d="M20 6 9 17l-5-5" />
|
|
24
|
+
</svg>
|
|
25
|
+
)
|
|
26
|
+
const DotIcon = () => (
|
|
27
|
+
<svg aria-hidden="true" {...svg}>
|
|
28
|
+
<circle cx="12" cy="12" r="4" fill="currentColor" />
|
|
29
|
+
</svg>
|
|
30
|
+
)
|
|
31
|
+
const ChevronRightIcon = () => (
|
|
32
|
+
<svg aria-hidden="true" {...svg}>
|
|
33
|
+
<path d="m9 18 6-6-6-6" />
|
|
34
|
+
</svg>
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** A menu of actions opened from a button: row actions, account menus, "more" buttons. */
|
|
38
|
+
export const DropdownMenu = MenuPrimitive.Root
|
|
39
|
+
export const DropdownMenuTrigger = MenuPrimitive.Trigger
|
|
40
|
+
export const DropdownMenuGroup = MenuPrimitive.Group
|
|
41
|
+
export const DropdownMenuRadioGroup = MenuPrimitive.RadioGroup
|
|
42
|
+
export const DropdownMenuSub = MenuPrimitive.Sub
|
|
43
|
+
|
|
44
|
+
export function DropdownMenuContent(inProps: ComponentProps<typeof MenuPrimitive.Content>) {
|
|
45
|
+
const [props, slots] = useComponentConfig('DropdownMenu', inProps)
|
|
46
|
+
const { sideOffset = 6, align = 'start', className, ...rest } = props
|
|
47
|
+
const s = dropdownMenuStyles()
|
|
48
|
+
return (
|
|
49
|
+
<MenuPrimitive.Portal>
|
|
50
|
+
<MenuPrimitive.Content
|
|
51
|
+
data-slot="dropdown-menu-content"
|
|
52
|
+
sideOffset={sideOffset}
|
|
53
|
+
align={align}
|
|
54
|
+
className={s.content({ class: [slots.content, className] })}
|
|
55
|
+
{...rest}
|
|
56
|
+
/>
|
|
57
|
+
</MenuPrimitive.Portal>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DropdownMenuItemProps extends ComponentProps<typeof MenuPrimitive.Item> {
|
|
62
|
+
icon?: ReactNode
|
|
63
|
+
/** Keyboard hint shown at the end, e.g. "⌘D". Display only. */
|
|
64
|
+
shortcut?: ReactNode
|
|
65
|
+
/** `danger` for destructive actions. */
|
|
66
|
+
tone?: 'default' | 'danger'
|
|
67
|
+
/** Indent to line up with checkbox and radio items. */
|
|
68
|
+
inset?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function DropdownMenuItem({
|
|
72
|
+
icon,
|
|
73
|
+
shortcut,
|
|
74
|
+
tone,
|
|
75
|
+
inset,
|
|
76
|
+
className,
|
|
77
|
+
children,
|
|
78
|
+
...props
|
|
79
|
+
}: DropdownMenuItemProps) {
|
|
80
|
+
const { s, slots } = useParts(tone, inset)
|
|
81
|
+
return (
|
|
82
|
+
<MenuPrimitive.Item
|
|
83
|
+
data-slot="dropdown-menu-item"
|
|
84
|
+
data-tone={tone ?? 'default'}
|
|
85
|
+
className={s.item({ class: [slots.item, className] })}
|
|
86
|
+
{...props}
|
|
87
|
+
>
|
|
88
|
+
{icon && (
|
|
89
|
+
<span className={s.icon({ class: slots.icon })} aria-hidden="true">
|
|
90
|
+
{icon}
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
93
|
+
{children}
|
|
94
|
+
{shortcut && <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut>}
|
|
95
|
+
</MenuPrimitive.Item>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function DropdownMenuCheckboxItem({
|
|
100
|
+
className,
|
|
101
|
+
children,
|
|
102
|
+
...props
|
|
103
|
+
}: ComponentProps<typeof MenuPrimitive.CheckboxItem>) {
|
|
104
|
+
const { s, slots } = useParts(undefined, true)
|
|
105
|
+
return (
|
|
106
|
+
<MenuPrimitive.CheckboxItem
|
|
107
|
+
data-slot="dropdown-menu-checkbox-item"
|
|
108
|
+
className={s.item({ class: [slots.item, className] })}
|
|
109
|
+
{...props}
|
|
110
|
+
>
|
|
111
|
+
<span className={s.indicator({ class: slots.indicator })}>
|
|
112
|
+
<MenuPrimitive.ItemIndicator>
|
|
113
|
+
<CheckIcon />
|
|
114
|
+
</MenuPrimitive.ItemIndicator>
|
|
115
|
+
</span>
|
|
116
|
+
{children}
|
|
117
|
+
</MenuPrimitive.CheckboxItem>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function DropdownMenuRadioItem({
|
|
122
|
+
className,
|
|
123
|
+
children,
|
|
124
|
+
...props
|
|
125
|
+
}: ComponentProps<typeof MenuPrimitive.RadioItem>) {
|
|
126
|
+
const { s, slots } = useParts(undefined, true)
|
|
127
|
+
return (
|
|
128
|
+
<MenuPrimitive.RadioItem
|
|
129
|
+
data-slot="dropdown-menu-radio-item"
|
|
130
|
+
className={s.item({ class: [slots.item, className] })}
|
|
131
|
+
{...props}
|
|
132
|
+
>
|
|
133
|
+
<span className={s.indicator({ class: slots.indicator })}>
|
|
134
|
+
<MenuPrimitive.ItemIndicator>
|
|
135
|
+
<DotIcon />
|
|
136
|
+
</MenuPrimitive.ItemIndicator>
|
|
137
|
+
</span>
|
|
138
|
+
{children}
|
|
139
|
+
</MenuPrimitive.RadioItem>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function DropdownMenuLabel({
|
|
144
|
+
inset,
|
|
145
|
+
className,
|
|
146
|
+
...props
|
|
147
|
+
}: ComponentProps<typeof MenuPrimitive.Label> & { inset?: boolean }) {
|
|
148
|
+
const { s, slots } = useParts(undefined, inset)
|
|
149
|
+
return (
|
|
150
|
+
<MenuPrimitive.Label
|
|
151
|
+
data-slot="dropdown-menu-label"
|
|
152
|
+
className={s.label({ class: [slots.label, className] })}
|
|
153
|
+
{...props}
|
|
154
|
+
/>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function DropdownMenuSeparator({
|
|
159
|
+
className,
|
|
160
|
+
...props
|
|
161
|
+
}: ComponentProps<typeof MenuPrimitive.Separator>) {
|
|
162
|
+
const { s, slots } = useParts()
|
|
163
|
+
return (
|
|
164
|
+
<MenuPrimitive.Separator
|
|
165
|
+
data-slot="dropdown-menu-separator"
|
|
166
|
+
className={s.separator({ class: [slots.separator, className] })}
|
|
167
|
+
{...props}
|
|
168
|
+
/>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function DropdownMenuShortcut({ className, ...props }: ComponentProps<'span'>) {
|
|
173
|
+
const { s, slots } = useParts()
|
|
174
|
+
return (
|
|
175
|
+
<span
|
|
176
|
+
data-slot="dropdown-menu-shortcut"
|
|
177
|
+
className={s.shortcut({ class: [slots.shortcut, className] })}
|
|
178
|
+
{...props}
|
|
179
|
+
/>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface DropdownMenuSubTriggerProps
|
|
184
|
+
extends ComponentProps<typeof MenuPrimitive.SubTrigger> {
|
|
185
|
+
icon?: ReactNode
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function DropdownMenuSubTrigger({
|
|
189
|
+
icon,
|
|
190
|
+
className,
|
|
191
|
+
children,
|
|
192
|
+
...props
|
|
193
|
+
}: DropdownMenuSubTriggerProps) {
|
|
194
|
+
const { s, slots } = useParts()
|
|
195
|
+
return (
|
|
196
|
+
<MenuPrimitive.SubTrigger
|
|
197
|
+
data-slot="dropdown-menu-sub-trigger"
|
|
198
|
+
className={s.subTrigger({ class: [slots.subTrigger, className] })}
|
|
199
|
+
{...props}
|
|
200
|
+
>
|
|
201
|
+
{icon && (
|
|
202
|
+
<span className={s.icon({ class: slots.icon })} aria-hidden="true">
|
|
203
|
+
{icon}
|
|
204
|
+
</span>
|
|
205
|
+
)}
|
|
206
|
+
{children}
|
|
207
|
+
<span className="ml-auto flex text-faint-foreground">
|
|
208
|
+
<ChevronRightIcon />
|
|
209
|
+
</span>
|
|
210
|
+
</MenuPrimitive.SubTrigger>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function DropdownMenuSubContent({
|
|
215
|
+
sideOffset = 4,
|
|
216
|
+
className,
|
|
217
|
+
...props
|
|
218
|
+
}: ComponentProps<typeof MenuPrimitive.SubContent>) {
|
|
219
|
+
const { s, slots } = useParts()
|
|
220
|
+
return (
|
|
221
|
+
<MenuPrimitive.Portal>
|
|
222
|
+
<MenuPrimitive.SubContent
|
|
223
|
+
data-slot="dropdown-menu-sub-content"
|
|
224
|
+
sideOffset={sideOffset}
|
|
225
|
+
className={s.subContent({ class: [slots.subContent, className] })}
|
|
226
|
+
{...props}
|
|
227
|
+
/>
|
|
228
|
+
</MenuPrimitive.Portal>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DropdownMenu,
|
|
3
|
+
DropdownMenuCheckboxItem,
|
|
4
|
+
DropdownMenuContent,
|
|
5
|
+
DropdownMenuGroup,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
type DropdownMenuItemProps,
|
|
8
|
+
DropdownMenuLabel,
|
|
9
|
+
DropdownMenuRadioGroup,
|
|
10
|
+
DropdownMenuRadioItem,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuShortcut,
|
|
13
|
+
DropdownMenuSub,
|
|
14
|
+
DropdownMenuSubContent,
|
|
15
|
+
DropdownMenuSubTrigger,
|
|
16
|
+
type DropdownMenuSubTriggerProps,
|
|
17
|
+
DropdownMenuTrigger,
|
|
18
|
+
} from './dropdown-menu'
|
|
19
|
+
export { type DropdownMenuVariants, dropdownMenuStyles } from './dropdown-menu.styles'
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
2
|
+
import { focusRing, themedBorder, transition } from '../../utils/styles'
|
|
3
|
+
import { tv } from '../../utils/tv'
|
|
4
|
+
|
|
5
|
+
export const fileUploadStyles = tv({
|
|
6
|
+
slots: {
|
|
7
|
+
root: 'grid gap-3',
|
|
8
|
+
dropzone: [
|
|
9
|
+
'flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-dashed border-border-strong bg-subtle px-6 py-8 text-center',
|
|
10
|
+
themedBorder,
|
|
11
|
+
transition,
|
|
12
|
+
focusRing,
|
|
13
|
+
'hover:border-primary/60 hover:bg-primary/4',
|
|
14
|
+
'data-[dragging=true]:border-primary data-[dragging=true]:bg-primary/8',
|
|
15
|
+
'disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger',
|
|
16
|
+
],
|
|
17
|
+
icon: 'flex size-10 items-center justify-center rounded-md border border-border bg-surface text-muted-foreground shadow-xs [&>svg]:size-5',
|
|
18
|
+
title: 'text-small font-medium text-foreground',
|
|
19
|
+
highlight: 'text-primary',
|
|
20
|
+
hint: 'text-caption text-faint-foreground',
|
|
21
|
+
rejections: 'grid gap-1 text-small font-medium text-danger',
|
|
22
|
+
list: 'grid gap-2',
|
|
23
|
+
item: [
|
|
24
|
+
'grid grid-cols-[auto_1fr_auto] items-center gap-3 rounded-md border-border bg-surface px-3 py-2.5',
|
|
25
|
+
themedBorder,
|
|
26
|
+
],
|
|
27
|
+
fileIcon:
|
|
28
|
+
'flex size-8 items-center justify-center rounded-sm bg-muted text-muted-foreground [&>svg]:size-4',
|
|
29
|
+
fileText: 'grid min-w-0 gap-1',
|
|
30
|
+
fileName: 'truncate text-small font-medium text-foreground',
|
|
31
|
+
fileMeta: 'text-caption tabular-nums text-faint-foreground',
|
|
32
|
+
fileError: 'text-caption font-medium text-danger',
|
|
33
|
+
progress: 'h-1 overflow-hidden rounded-pill bg-muted',
|
|
34
|
+
progressBar:
|
|
35
|
+
'h-full rounded-pill bg-primary transition-[width] duration-(--glyph-duration-base)',
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
declare module '@glyphui/tokens' {
|
|
40
|
+
interface GlyphComponentRegistry {
|
|
41
|
+
FileUpload: StylesEntry<typeof fileUploadStyles>
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { type DragEvent, type ReactNode, useId, useRef, useState } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { useControllableState } from '../../hooks/use-controllable-state'
|
|
4
|
+
import { IconButton } from '../button'
|
|
5
|
+
import { useFieldControl } from '../field/field-context'
|
|
6
|
+
import { fileUploadStyles } from './file-upload.styles'
|
|
7
|
+
|
|
8
|
+
export interface FileItem {
|
|
9
|
+
/** Stable id for keys and updates. Generated when files are added. */
|
|
10
|
+
id: string
|
|
11
|
+
file: File
|
|
12
|
+
/** Upload progress, 0–100. Shows a bar while below 100. */
|
|
13
|
+
progress?: number
|
|
14
|
+
/** Shown under the file name, e.g. "Upload failed". */
|
|
15
|
+
error?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type FileRejectionReason = 'too-large' | 'wrong-type' | 'too-many'
|
|
19
|
+
|
|
20
|
+
export interface FileRejection {
|
|
21
|
+
file: File
|
|
22
|
+
reason: FileRejectionReason
|
|
23
|
+
message: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Formats bytes for people: 1 KB = 1024 bytes. */
|
|
27
|
+
export function formatBytes(bytes: number): string {
|
|
28
|
+
if (bytes < 1024) return `${bytes} B`
|
|
29
|
+
const units = ['KB', 'MB', 'GB', 'TB']
|
|
30
|
+
let value = bytes / 1024
|
|
31
|
+
let unit = 0
|
|
32
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
33
|
+
value /= 1024
|
|
34
|
+
unit++
|
|
35
|
+
}
|
|
36
|
+
return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Matches a file against an `accept` string like `"image/*,.pdf,application/zip"`. */
|
|
40
|
+
export function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
41
|
+
if (!accept) return true
|
|
42
|
+
const name = file.name.toLowerCase()
|
|
43
|
+
const type = file.type.toLowerCase()
|
|
44
|
+
return accept
|
|
45
|
+
.split(',')
|
|
46
|
+
.map((rule) => rule.trim().toLowerCase())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.some((rule) => {
|
|
49
|
+
if (rule.startsWith('.')) return name.endsWith(rule)
|
|
50
|
+
if (rule.endsWith('/*')) return type.startsWith(rule.slice(0, -1))
|
|
51
|
+
return type === rule
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let counter = 0
|
|
56
|
+
const newId = () => `file-${Date.now().toString(36)}-${(counter++).toString(36)}`
|
|
57
|
+
|
|
58
|
+
export interface FileUploadProps {
|
|
59
|
+
files?: FileItem[]
|
|
60
|
+
defaultFiles?: FileItem[]
|
|
61
|
+
onFilesChange?: (files: FileItem[]) => void
|
|
62
|
+
/** Called with files that were not added and why. */
|
|
63
|
+
onReject?: (rejections: FileRejection[]) => void
|
|
64
|
+
/** Allowed types, as for `<input type="file" accept>`. */
|
|
65
|
+
accept?: string
|
|
66
|
+
/** Maximum size per file, in bytes. */
|
|
67
|
+
maxSize?: number
|
|
68
|
+
/** Maximum number of files in the list. */
|
|
69
|
+
maxFiles?: number
|
|
70
|
+
/** Allow several files. Default true. */
|
|
71
|
+
multiple?: boolean
|
|
72
|
+
disabled?: boolean
|
|
73
|
+
/** Main dropzone text. */
|
|
74
|
+
label?: ReactNode
|
|
75
|
+
/** Secondary dropzone text. Defaults to a summary of `accept` and `maxSize`. */
|
|
76
|
+
hint?: ReactNode
|
|
77
|
+
id?: string
|
|
78
|
+
className?: string
|
|
79
|
+
'aria-describedby'?: string
|
|
80
|
+
'aria-invalid'?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function UploadIcon() {
|
|
84
|
+
return (
|
|
85
|
+
<svg
|
|
86
|
+
viewBox="0 0 24 24"
|
|
87
|
+
fill="none"
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
strokeWidth="2"
|
|
90
|
+
strokeLinecap="round"
|
|
91
|
+
strokeLinejoin="round"
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
>
|
|
94
|
+
<path d="M12 16V4M7 9l5-5 5 5" />
|
|
95
|
+
<path d="M20 16v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2" />
|
|
96
|
+
</svg>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function FileIcon() {
|
|
101
|
+
return (
|
|
102
|
+
<svg
|
|
103
|
+
viewBox="0 0 24 24"
|
|
104
|
+
fill="none"
|
|
105
|
+
stroke="currentColor"
|
|
106
|
+
strokeWidth="2"
|
|
107
|
+
strokeLinecap="round"
|
|
108
|
+
strokeLinejoin="round"
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
>
|
|
111
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" />
|
|
112
|
+
<path d="M14 2v6h6" />
|
|
113
|
+
</svg>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function XIcon() {
|
|
118
|
+
return (
|
|
119
|
+
<svg
|
|
120
|
+
viewBox="0 0 24 24"
|
|
121
|
+
fill="none"
|
|
122
|
+
stroke="currentColor"
|
|
123
|
+
strokeWidth="2"
|
|
124
|
+
strokeLinecap="round"
|
|
125
|
+
aria-hidden="true"
|
|
126
|
+
>
|
|
127
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
128
|
+
</svg>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Drag-and-drop or click to choose files, with type, size and count checks and a file
|
|
134
|
+
* list. It does no uploading: you get `File` objects and can set `progress` and `error`
|
|
135
|
+
* on each item while your own code uploads them.
|
|
136
|
+
*/
|
|
137
|
+
export function FileUpload(inProps: FileUploadProps) {
|
|
138
|
+
const [props, slots] = useComponentConfig('FileUpload', useFieldControl(inProps))
|
|
139
|
+
const {
|
|
140
|
+
files: filesProp,
|
|
141
|
+
defaultFiles = [],
|
|
142
|
+
onFilesChange,
|
|
143
|
+
onReject,
|
|
144
|
+
accept,
|
|
145
|
+
maxSize,
|
|
146
|
+
maxFiles,
|
|
147
|
+
multiple = true,
|
|
148
|
+
disabled,
|
|
149
|
+
label,
|
|
150
|
+
hint,
|
|
151
|
+
id,
|
|
152
|
+
className,
|
|
153
|
+
'aria-describedby': ariaDescribedBy,
|
|
154
|
+
'aria-invalid': ariaInvalid,
|
|
155
|
+
} = props
|
|
156
|
+
const [files, setFiles] = useControllableState<FileItem[]>({
|
|
157
|
+
value: filesProp,
|
|
158
|
+
defaultValue: defaultFiles,
|
|
159
|
+
onChange: onFilesChange,
|
|
160
|
+
})
|
|
161
|
+
const [dragging, setDragging] = useState(false)
|
|
162
|
+
const [rejections, setRejections] = useState<FileRejection[]>([])
|
|
163
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
164
|
+
const hintId = useId()
|
|
165
|
+
const rejectionsId = useId()
|
|
166
|
+
const s = fileUploadStyles()
|
|
167
|
+
|
|
168
|
+
const limit = multiple ? (maxFiles ?? Number.POSITIVE_INFINITY) : 1
|
|
169
|
+
const full = files.length >= limit
|
|
170
|
+
|
|
171
|
+
const addFiles = (incoming: File[]) => {
|
|
172
|
+
const accepted: FileItem[] = []
|
|
173
|
+
const rejected: FileRejection[] = []
|
|
174
|
+
let room = limit - (multiple ? files.length : 0)
|
|
175
|
+
for (const file of incoming) {
|
|
176
|
+
if (!matchesAccept(file, accept)) {
|
|
177
|
+
rejected.push({
|
|
178
|
+
file,
|
|
179
|
+
reason: 'wrong-type',
|
|
180
|
+
message: `${file.name} isn't an allowed file type.`,
|
|
181
|
+
})
|
|
182
|
+
} else if (maxSize !== undefined && file.size > maxSize) {
|
|
183
|
+
rejected.push({
|
|
184
|
+
file,
|
|
185
|
+
reason: 'too-large',
|
|
186
|
+
message: `${file.name} is too large (${formatBytes(file.size)}). The limit is ${formatBytes(maxSize)}.`,
|
|
187
|
+
})
|
|
188
|
+
} else if (room <= 0) {
|
|
189
|
+
rejected.push({
|
|
190
|
+
file,
|
|
191
|
+
reason: 'too-many',
|
|
192
|
+
message: `${file.name} wasn't added: the limit is ${limit} file${limit === 1 ? '' : 's'}.`,
|
|
193
|
+
})
|
|
194
|
+
} else {
|
|
195
|
+
accepted.push({ id: newId(), file })
|
|
196
|
+
room--
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (accepted.length > 0) setFiles(multiple ? [...files, ...accepted] : accepted)
|
|
200
|
+
setRejections(rejected)
|
|
201
|
+
if (rejected.length > 0) onReject?.(rejected)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const onDrop = (event: DragEvent) => {
|
|
205
|
+
event.preventDefault()
|
|
206
|
+
setDragging(false)
|
|
207
|
+
if (disabled) return
|
|
208
|
+
addFiles(Array.from(event.dataTransfer.files))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const defaultHint = [
|
|
212
|
+
accept
|
|
213
|
+
? `Accepted: ${accept
|
|
214
|
+
.split(',')
|
|
215
|
+
.map((a) => a.trim())
|
|
216
|
+
.join(', ')}`
|
|
217
|
+
: null,
|
|
218
|
+
maxSize !== undefined ? `up to ${formatBytes(maxSize)} each` : null,
|
|
219
|
+
multiple && maxFiles !== undefined ? `max ${maxFiles} files` : null,
|
|
220
|
+
]
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.join(' · ')
|
|
223
|
+
|
|
224
|
+
const describedBy =
|
|
225
|
+
[ariaDescribedBy, hintId, rejections.length > 0 ? rejectionsId : undefined]
|
|
226
|
+
.filter(Boolean)
|
|
227
|
+
.join(' ') || undefined
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div data-slot="file-upload" className={s.root({ class: [slots.root, className] })}>
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
id={id}
|
|
234
|
+
data-dragging={dragging}
|
|
235
|
+
disabled={disabled || full}
|
|
236
|
+
aria-describedby={describedBy}
|
|
237
|
+
aria-invalid={ariaInvalid}
|
|
238
|
+
className={s.dropzone({ class: slots.dropzone })}
|
|
239
|
+
onClick={() => inputRef.current?.click()}
|
|
240
|
+
onDragEnter={(event) => {
|
|
241
|
+
event.preventDefault()
|
|
242
|
+
if (!disabled) setDragging(true)
|
|
243
|
+
}}
|
|
244
|
+
onDragOver={(event) => event.preventDefault()}
|
|
245
|
+
onDragLeave={(event) => {
|
|
246
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false)
|
|
247
|
+
}}
|
|
248
|
+
onDrop={onDrop}
|
|
249
|
+
>
|
|
250
|
+
<span className={s.icon({ class: slots.icon })}>
|
|
251
|
+
<UploadIcon />
|
|
252
|
+
</span>
|
|
253
|
+
<span className={s.title({ class: slots.title })}>
|
|
254
|
+
{label ?? (
|
|
255
|
+
<>
|
|
256
|
+
Drop {multiple ? 'files' : 'a file'} here or{' '}
|
|
257
|
+
<span className={s.highlight({ class: slots.highlight })}>browse</span>
|
|
258
|
+
</>
|
|
259
|
+
)}
|
|
260
|
+
</span>
|
|
261
|
+
<span id={hintId} className={s.hint({ class: slots.hint })}>
|
|
262
|
+
{full ? 'File limit reached. Remove a file to add another.' : (hint ?? defaultHint)}
|
|
263
|
+
</span>
|
|
264
|
+
</button>
|
|
265
|
+
<input
|
|
266
|
+
ref={inputRef}
|
|
267
|
+
type="file"
|
|
268
|
+
tabIndex={-1}
|
|
269
|
+
aria-hidden="true"
|
|
270
|
+
className="sr-only"
|
|
271
|
+
accept={accept}
|
|
272
|
+
multiple={multiple}
|
|
273
|
+
disabled={disabled}
|
|
274
|
+
onChange={(event) => {
|
|
275
|
+
addFiles(Array.from(event.target.files ?? []))
|
|
276
|
+
event.target.value = ''
|
|
277
|
+
}}
|
|
278
|
+
/>
|
|
279
|
+
|
|
280
|
+
{rejections.length > 0 && (
|
|
281
|
+
<ul id={rejectionsId} role="alert" className={s.rejections({ class: slots.rejections })}>
|
|
282
|
+
{rejections.map((r) => (
|
|
283
|
+
<li key={`${r.file.name}-${r.reason}`}>{r.message}</li>
|
|
284
|
+
))}
|
|
285
|
+
</ul>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
{files.length > 0 && (
|
|
289
|
+
<ul className={s.list({ class: slots.list })} aria-label="Selected files">
|
|
290
|
+
{files.map((item) => (
|
|
291
|
+
<li
|
|
292
|
+
key={item.id}
|
|
293
|
+
data-slot="file-upload-item"
|
|
294
|
+
className={s.item({ class: slots.item })}
|
|
295
|
+
>
|
|
296
|
+
<span className={s.fileIcon({ class: slots.fileIcon })}>
|
|
297
|
+
<FileIcon />
|
|
298
|
+
</span>
|
|
299
|
+
<span className={s.fileText({ class: slots.fileText })}>
|
|
300
|
+
<span className={s.fileName({ class: slots.fileName })}>{item.file.name}</span>
|
|
301
|
+
{item.error ? (
|
|
302
|
+
<span className={s.fileError({ class: slots.fileError })}>{item.error}</span>
|
|
303
|
+
) : (
|
|
304
|
+
<span className={s.fileMeta({ class: slots.fileMeta })}>
|
|
305
|
+
{formatBytes(item.file.size)}
|
|
306
|
+
{item.progress !== undefined &&
|
|
307
|
+
item.progress < 100 &&
|
|
308
|
+
` · ${Math.round(item.progress)}%`}
|
|
309
|
+
</span>
|
|
310
|
+
)}
|
|
311
|
+
{item.progress !== undefined && item.progress < 100 && !item.error && (
|
|
312
|
+
<span
|
|
313
|
+
role="progressbar"
|
|
314
|
+
aria-label={`Uploading ${item.file.name}`}
|
|
315
|
+
aria-valuemin={0}
|
|
316
|
+
aria-valuemax={100}
|
|
317
|
+
aria-valuenow={Math.round(item.progress)}
|
|
318
|
+
className={s.progress({ class: slots.progress })}
|
|
319
|
+
>
|
|
320
|
+
<span
|
|
321
|
+
className={s.progressBar({ class: slots.progressBar })}
|
|
322
|
+
style={{ width: `${Math.max(0, Math.min(100, item.progress))}%` }}
|
|
323
|
+
/>
|
|
324
|
+
</span>
|
|
325
|
+
)}
|
|
326
|
+
</span>
|
|
327
|
+
<IconButton
|
|
328
|
+
size="sm"
|
|
329
|
+
icon={<XIcon />}
|
|
330
|
+
aria-label={`Remove ${item.file.name}`}
|
|
331
|
+
disabled={disabled}
|
|
332
|
+
onClick={() => {
|
|
333
|
+
setFiles(files.filter((f) => f.id !== item.id))
|
|
334
|
+
setRejections([])
|
|
335
|
+
}}
|
|
336
|
+
/>
|
|
337
|
+
</li>
|
|
338
|
+
))}
|
|
339
|
+
</ul>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
}
|