@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.
Files changed (149) hide show
  1. package/README.md +1 -1
  2. package/dist/chart.d.ts +202 -0
  3. package/dist/chart.js +457 -0
  4. package/dist/data-table.js +3 -2
  5. package/dist/{field-DXZD0l8w.js → field-CWZ_FdkD.js} +2 -1
  6. package/dist/{field-context-B8cYheXz.js → field-context-anGPYXbW.js} +89 -143
  7. package/dist/form.js +3 -2
  8. package/dist/index.d.ts +4960 -266
  9. package/dist/index.js +5336 -146
  10. package/dist/{states-BxPjCUxz.js → states-DR-OoDT-.js} +2 -1
  11. package/dist/tv-DBAGlgXJ.js +58 -0
  12. package/package.json +19 -2
  13. package/src/components/accordion/accordion.styles.ts +41 -0
  14. package/src/components/accordion/accordion.tsx +86 -0
  15. package/src/components/accordion/index.ts +8 -0
  16. package/src/components/alert/alert-icons.tsx +66 -0
  17. package/src/components/alert/alert.styles.ts +57 -0
  18. package/src/components/alert/alert.tsx +86 -0
  19. package/src/components/alert/index.ts +2 -0
  20. package/src/components/alert-dialog/alert-dialog.styles.ts +24 -0
  21. package/src/components/alert-dialog/alert-dialog.tsx +118 -0
  22. package/src/components/alert-dialog/confirm.tsx +111 -0
  23. package/src/components/alert-dialog/index.ts +15 -0
  24. package/src/components/aspect-ratio/aspect-ratio.tsx +40 -0
  25. package/src/components/aspect-ratio/index.ts +1 -0
  26. package/src/components/badge/badge.styles.ts +1 -1
  27. package/src/components/banner/banner.styles.ts +40 -0
  28. package/src/components/banner/banner.tsx +101 -0
  29. package/src/components/banner/index.ts +2 -0
  30. package/src/components/blockquote/blockquote.tsx +59 -0
  31. package/src/components/blockquote/index.ts +1 -0
  32. package/src/components/breadcrumb/breadcrumb.styles.ts +20 -0
  33. package/src/components/breadcrumb/breadcrumb.tsx +135 -0
  34. package/src/components/breadcrumb/index.ts +12 -0
  35. package/src/components/button/button.tsx +11 -8
  36. package/src/components/calendar/calendar.styles.ts +50 -0
  37. package/src/components/calendar/calendar.tsx +84 -0
  38. package/src/components/calendar/index.ts +3 -0
  39. package/src/components/carousel/carousel.styles.ts +43 -0
  40. package/src/components/carousel/carousel.tsx +278 -0
  41. package/src/components/carousel/index.ts +13 -0
  42. package/src/components/code/code.styles.ts +33 -0
  43. package/src/components/code/code.tsx +96 -0
  44. package/src/components/code/index.ts +2 -0
  45. package/src/components/collapsible/collapsible.tsx +66 -0
  46. package/src/components/collapsible/index.ts +6 -0
  47. package/src/components/combobox/combobox.styles.ts +50 -0
  48. package/src/components/combobox/combobox.tsx +203 -0
  49. package/src/components/combobox/index.ts +2 -0
  50. package/src/components/command-menu/command-menu.styles.ts +41 -0
  51. package/src/components/command-menu/command-menu.tsx +217 -0
  52. package/src/components/command-menu/index.ts +14 -0
  53. package/src/components/context-menu/context-menu.styles.ts +19 -0
  54. package/src/components/context-menu/context-menu.tsx +226 -0
  55. package/src/components/context-menu/index.ts +19 -0
  56. package/src/components/copy-button/copy-button.tsx +117 -0
  57. package/src/components/copy-button/index.ts +1 -0
  58. package/src/components/date-picker/date-picker.styles.ts +42 -0
  59. package/src/components/date-picker/date-picker.tsx +322 -0
  60. package/src/components/date-picker/index.ts +8 -0
  61. package/src/components/description-list/description-list.styles.ts +31 -0
  62. package/src/components/description-list/description-list.tsx +56 -0
  63. package/src/components/description-list/index.ts +7 -0
  64. package/src/components/dialog/close-icon.tsx +22 -0
  65. package/src/components/dialog/dialog.styles.ts +41 -0
  66. package/src/components/dialog/dialog.tsx +111 -0
  67. package/src/components/dialog/index.ts +13 -0
  68. package/src/components/dropdown-menu/dropdown-menu.styles.ts +45 -0
  69. package/src/components/dropdown-menu/dropdown-menu.tsx +230 -0
  70. package/src/components/dropdown-menu/index.ts +19 -0
  71. package/src/components/file-upload/file-upload.styles.ts +43 -0
  72. package/src/components/file-upload/file-upload.tsx +343 -0
  73. package/src/components/file-upload/index.ts +10 -0
  74. package/src/components/grid/grid.tsx +144 -0
  75. package/src/components/grid/index.ts +1 -0
  76. package/src/components/hover-card/hover-card.tsx +41 -0
  77. package/src/components/hover-card/index.ts +1 -0
  78. package/src/components/input-otp/index.ts +10 -0
  79. package/src/components/input-otp/input-otp.styles.ts +38 -0
  80. package/src/components/input-otp/input-otp.tsx +116 -0
  81. package/src/components/link/index.ts +2 -0
  82. package/src/components/link/link.styles.ts +39 -0
  83. package/src/components/link/link.tsx +62 -0
  84. package/src/components/modal/index.ts +2 -0
  85. package/src/components/modal/modal.styles.ts +18 -0
  86. package/src/components/modal/modal.tsx +86 -0
  87. package/src/components/navigation-menu/index.ts +14 -0
  88. package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
  89. package/src/components/navigation-menu/navigation-menu.tsx +198 -0
  90. package/src/components/number-input/index.ts +2 -0
  91. package/src/components/number-input/number-input.styles.ts +41 -0
  92. package/src/components/number-input/number-input.tsx +211 -0
  93. package/src/components/pagination/index.ts +3 -0
  94. package/src/components/pagination/pagination-range.ts +35 -0
  95. package/src/components/pagination/pagination.styles.ts +41 -0
  96. package/src/components/pagination/pagination.tsx +130 -0
  97. package/src/components/password-input/index.ts +7 -0
  98. package/src/components/password-input/password-input.styles.ts +30 -0
  99. package/src/components/password-input/password-input.tsx +127 -0
  100. package/src/components/popover/index.ts +9 -0
  101. package/src/components/popover/popover.tsx +66 -0
  102. package/src/components/progress/index.ts +7 -0
  103. package/src/components/progress/progress.styles.ts +68 -0
  104. package/src/components/progress/progress.tsx +158 -0
  105. package/src/components/prose/index.ts +1 -0
  106. package/src/components/prose/prose.tsx +82 -0
  107. package/src/components/radio-group/index.ts +7 -0
  108. package/src/components/radio-group/radio-group.styles.ts +64 -0
  109. package/src/components/radio-group/radio-group.tsx +128 -0
  110. package/src/components/resizable/index.ts +9 -0
  111. package/src/components/resizable/resizable.styles.ts +28 -0
  112. package/src/components/resizable/resizable.tsx +69 -0
  113. package/src/components/scroll-area/index.ts +1 -0
  114. package/src/components/scroll-area/scroll-area.tsx +68 -0
  115. package/src/components/sheet/index.ts +13 -0
  116. package/src/components/sheet/sheet.styles.ts +60 -0
  117. package/src/components/sheet/sheet.tsx +108 -0
  118. package/src/components/slider/index.ts +2 -0
  119. package/src/components/slider/slider.styles.ts +32 -0
  120. package/src/components/slider/slider.tsx +131 -0
  121. package/src/components/stat-card/index.ts +2 -0
  122. package/src/components/stat-card/stat-card.styles.ts +24 -0
  123. package/src/components/stat-card/stat-card.tsx +108 -0
  124. package/src/components/stepper/index.ts +2 -0
  125. package/src/components/stepper/stepper.styles.ts +74 -0
  126. package/src/components/stepper/stepper.tsx +138 -0
  127. package/src/components/tag-input/index.ts +2 -0
  128. package/src/components/tag-input/tag-input.styles.ts +39 -0
  129. package/src/components/tag-input/tag-input.tsx +198 -0
  130. package/src/components/timeline/index.ts +8 -0
  131. package/src/components/timeline/timeline.styles.ts +56 -0
  132. package/src/components/timeline/timeline.tsx +91 -0
  133. package/src/components/toast/index.ts +1 -0
  134. package/src/components/toast/toast.tsx +91 -0
  135. package/src/components/toggle/index.ts +9 -0
  136. package/src/components/toggle/toggle.styles.ts +39 -0
  137. package/src/components/toggle/toggle.tsx +61 -0
  138. package/src/components/tree-view/index.ts +2 -0
  139. package/src/components/tree-view/tree-view.styles.ts +42 -0
  140. package/src/components/tree-view/tree-view.tsx +274 -0
  141. package/src/components/visually-hidden/index.ts +1 -0
  142. package/src/components/visually-hidden/visually-hidden.tsx +10 -0
  143. package/src/index.ts +45 -0
  144. package/src/patterns/chart/chart-parts.tsx +171 -0
  145. package/src/patterns/chart/chart.styles.ts +35 -0
  146. package/src/patterns/chart/chart.tsx +369 -0
  147. package/src/patterns/chart/index.ts +13 -0
  148. package/src/provider/glyph-provider.tsx +16 -2
  149. 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
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ type FileItem,
3
+ type FileRejection,
4
+ type FileRejectionReason,
5
+ FileUpload,
6
+ type FileUploadProps,
7
+ formatBytes,
8
+ matchesAccept,
9
+ } from './file-upload'
10
+ export { fileUploadStyles } from './file-upload.styles'