@glyphui/ui 0.2.0 → 0.4.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 +65 -12
- package/dist/chart.d.ts +202 -0
- package/dist/chart.js +466 -0
- package/dist/color-mode-script-Bf7LYkOj.js +13 -0
- package/dist/color-mode-script-KdwItKN2.d.ts +16 -0
- package/dist/data-table.js +3 -2
- package/dist/{field-CCecIzdq.js → field-CVbywksE.js} +21 -5
- package/dist/{field-context-B6WOcK5m.js → field-context-7I-cWvvS.js} +3 -57
- package/dist/form.d.ts +11 -4
- package/dist/form.js +34 -8
- package/dist/glyph-context-DxGJrdvr.d.ts +50 -0
- package/dist/{index-BWK4czIG.d.ts → index-Bi7rZ6ba.d.ts} +11 -0
- package/dist/index.d.ts +2334 -283
- package/dist/index.js +2952 -326
- package/dist/react-router.d.ts +18 -0
- package/dist/react-router.js +44 -0
- package/dist/{states-Rp8wZ_jH.js → states-IvhjBgna.js} +2 -1
- package/dist/tv-DLbT66dC.js +70 -0
- package/dist/vite.d.ts +27 -0
- package/dist/vite.js +29 -0
- package/fonts.css +10 -0
- package/package.json +47 -5
- package/src/components/accordion/accordion.styles.ts +4 -4
- package/src/components/badge/badge.styles.ts +11 -16
- package/src/components/banner/banner.styles.ts +4 -4
- package/src/components/button/button.styles.ts +3 -1
- package/src/components/calendar/calendar.styles.ts +51 -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/collapsible/collapsible.tsx +66 -0
- package/src/components/collapsible/index.ts +6 -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/dropdown-menu/dropdown-menu.styles.ts +2 -2
- package/src/components/eyebrow/eyebrow.styles.ts +1 -1
- package/src/components/field/field-context.ts +2 -0
- package/src/components/field/field.styles.ts +4 -2
- package/src/components/field/field.tsx +25 -4
- 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/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/link.styles.ts +1 -1
- package/src/components/modal/index.ts +2 -0
- package/src/components/modal/modal.styles.ts +20 -0
- package/src/components/modal/modal.tsx +111 -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 +208 -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/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/prose/prose.tsx +1 -1
- 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/scroll-area.tsx +23 -1
- package/src/components/segmented-control/segmented-control.tsx +17 -2
- package/src/components/sheet/sheet.styles.ts +7 -7
- package/src/components/slider/slider.tsx +9 -2
- 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/text/text.styles.ts +4 -4
- 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/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 +20 -0
- package/src/integrations/react-router.tsx +39 -0
- package/src/integrations/vite.test-d.ts +6 -0
- package/src/integrations/vite.test.ts +28 -0
- package/src/integrations/vite.ts +33 -0
- package/src/internal/dev.ts +12 -0
- package/src/patterns/app-shell/app-shell.styles.ts +1 -1
- package/src/patterns/app-shell/navbar.tsx +34 -16
- package/src/patterns/app-shell/sidebar.tsx +7 -2
- package/src/patterns/chart/chart-parts.tsx +179 -0
- package/src/patterns/chart/chart.styles.ts +35 -0
- package/src/patterns/chart/chart.tsx +386 -0
- package/src/patterns/chart/index.ts +13 -0
- package/src/patterns/form/form.tsx +48 -6
- package/src/patterns/form/index.ts +1 -0
- package/src/provider/color-mode-script.ts +28 -0
- package/src/provider/color-mode.tsx +14 -20
- package/src/provider/glyph-context.ts +38 -0
- package/src/provider/glyph-provider.tsx +16 -4
- package/src/provider/index.ts +4 -0
- package/src/provider/theme-scope.tsx +7 -3
- package/styles.css +88 -0
|
@@ -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,41 @@
|
|
|
1
|
+
import { HoverCard as HoverCardPrimitive } from 'radix-ui'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
3
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
5
|
+
import { popover } from '../../utils/styles'
|
|
6
|
+
import { tv } from '../../utils/tv'
|
|
7
|
+
|
|
8
|
+
export const hoverCardStyles = tv({
|
|
9
|
+
slots: {
|
|
10
|
+
content: [popover, 'w-72 max-w-[calc(100vw-2rem)] p-4 text-small outline-none'],
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
declare module '@glyphui/tokens' {
|
|
15
|
+
interface GlyphComponentRegistry {
|
|
16
|
+
HoverCard: StylesEntry<typeof hoverCardStyles>
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A rich preview shown while hovering or focusing a link, such as a user or repository card.
|
|
22
|
+
* Sighted mouse users only see it on hover; keep the same information reachable elsewhere.
|
|
23
|
+
*/
|
|
24
|
+
export const HoverCard = HoverCardPrimitive.Root
|
|
25
|
+
export const HoverCardTrigger = HoverCardPrimitive.Trigger
|
|
26
|
+
|
|
27
|
+
export function HoverCardContent(inProps: ComponentProps<typeof HoverCardPrimitive.Content>) {
|
|
28
|
+
const [props, slots] = useComponentConfig('HoverCard', inProps)
|
|
29
|
+
const { sideOffset = 6, align = 'center', className, ...rest } = props
|
|
30
|
+
return (
|
|
31
|
+
<HoverCardPrimitive.Portal>
|
|
32
|
+
<HoverCardPrimitive.Content
|
|
33
|
+
data-slot="hover-card-content"
|
|
34
|
+
sideOffset={sideOffset}
|
|
35
|
+
align={align}
|
|
36
|
+
className={hoverCardStyles().content({ class: [slots.content, className] })}
|
|
37
|
+
{...rest}
|
|
38
|
+
/>
|
|
39
|
+
</HoverCardPrimitive.Portal>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { HoverCard, HoverCardContent, HoverCardTrigger, hoverCardStyles } from './hover-card'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { REGEXP_ONLY_CHARS, REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp'
|
|
2
|
+
export {
|
|
3
|
+
InputOTP,
|
|
4
|
+
InputOTPGroup,
|
|
5
|
+
type InputOTPProps,
|
|
6
|
+
InputOTPSeparator,
|
|
7
|
+
InputOTPSlot,
|
|
8
|
+
type InputOTPSlotProps,
|
|
9
|
+
} from './input-otp'
|
|
10
|
+
export { type InputOTPVariants, inputOtpStyles } from './input-otp.styles'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
2
|
+
import { transition } from '../../utils/styles'
|
|
3
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
4
|
+
|
|
5
|
+
export const inputOtpStyles = tv({
|
|
6
|
+
slots: {
|
|
7
|
+
root: 'flex items-center gap-2 has-disabled:opacity-50',
|
|
8
|
+
input: 'disabled:cursor-not-allowed',
|
|
9
|
+
group: 'flex items-center',
|
|
10
|
+
slot: [
|
|
11
|
+
'relative flex items-center justify-center border-input bg-surface font-medium tabular-nums text-foreground shadow-xs',
|
|
12
|
+
'border-y-(length:--glyph-border-width) border-r-(length:--glyph-border-width)',
|
|
13
|
+
'first:rounded-l-md first:border-l-(length:--glyph-border-width) last:rounded-r-md',
|
|
14
|
+
'data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/20',
|
|
15
|
+
'aria-invalid:border-danger',
|
|
16
|
+
transition,
|
|
17
|
+
],
|
|
18
|
+
caret: 'pointer-events-none absolute inset-0 flex items-center justify-center',
|
|
19
|
+
caretBar: 'h-4 w-px animate-pulse bg-foreground',
|
|
20
|
+
separator: 'flex items-center text-faint-foreground [&>svg]:size-4',
|
|
21
|
+
},
|
|
22
|
+
variants: {
|
|
23
|
+
size: {
|
|
24
|
+
sm: { slot: 'size-8 text-small' },
|
|
25
|
+
md: { slot: 'size-10 text-body' },
|
|
26
|
+
lg: { slot: 'size-12 text-h4' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: { size: 'md' },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export type InputOTPVariants = VariantProps<typeof inputOtpStyles>
|
|
33
|
+
|
|
34
|
+
declare module '@glyphui/tokens' {
|
|
35
|
+
interface GlyphComponentRegistry {
|
|
36
|
+
InputOTP: StylesEntry<typeof inputOtpStyles>
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { OTPInput, OTPInputContext, type OTPInputProps } from 'input-otp'
|
|
2
|
+
import { type ComponentProps, createContext, type ReactNode, useContext } from 'react'
|
|
3
|
+
import { type SlotClasses, useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { useFieldControl } from '../field/field-context'
|
|
5
|
+
import { type InputOTPVariants, inputOtpStyles } from './input-otp.styles'
|
|
6
|
+
|
|
7
|
+
type Styles = ReturnType<typeof inputOtpStyles>
|
|
8
|
+
const PartsContext = createContext<{
|
|
9
|
+
s: Styles
|
|
10
|
+
slots: SlotClasses<'InputOTP'>
|
|
11
|
+
invalid: boolean
|
|
12
|
+
} | null>(null)
|
|
13
|
+
|
|
14
|
+
function useParts() {
|
|
15
|
+
return useContext(PartsContext) ?? { s: inputOtpStyles(), slots: {}, invalid: false }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type InputOTPProps = Omit<OTPInputProps, 'render' | 'children' | 'maxLength'> &
|
|
19
|
+
InputOTPVariants & {
|
|
20
|
+
/** Number of characters. Default 6. */
|
|
21
|
+
length?: number
|
|
22
|
+
/** Custom slot layout (InputOTPGroup, InputOTPSlot, InputOTPSeparator). Defaults to one group of `length` slots. */
|
|
23
|
+
children?: ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One-time code input: one box per character, paste support, and autofill from SMS on
|
|
28
|
+
* mobile. Built on input-otp. Restrict characters with `pattern`, e.g. `REGEXP_ONLY_DIGITS`.
|
|
29
|
+
*/
|
|
30
|
+
export function InputOTP(inProps: InputOTPProps) {
|
|
31
|
+
const [props, slots] = useComponentConfig('InputOTP', useFieldControl(inProps))
|
|
32
|
+
const { length = 6, size, children, className, containerClassName, ...rest } = props
|
|
33
|
+
const s = inputOtpStyles({ size })
|
|
34
|
+
const invalid = rest['aria-invalid'] === true || rest['aria-invalid'] === 'true'
|
|
35
|
+
return (
|
|
36
|
+
<PartsContext.Provider value={{ s, slots, invalid }}>
|
|
37
|
+
<OTPInput
|
|
38
|
+
data-slot="input-otp"
|
|
39
|
+
maxLength={length}
|
|
40
|
+
autoComplete="one-time-code"
|
|
41
|
+
inputMode="numeric"
|
|
42
|
+
containerClassName={s.root({ class: [slots.root, containerClassName] })}
|
|
43
|
+
className={s.input({ class: [slots.input, className] })}
|
|
44
|
+
{...rest}
|
|
45
|
+
>
|
|
46
|
+
{children ?? (
|
|
47
|
+
<InputOTPGroup>
|
|
48
|
+
{Array.from({ length }, (_, index) => (
|
|
49
|
+
<InputOTPSlot key={index} index={index} />
|
|
50
|
+
))}
|
|
51
|
+
</InputOTPGroup>
|
|
52
|
+
)}
|
|
53
|
+
</OTPInput>
|
|
54
|
+
</PartsContext.Provider>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function InputOTPGroup({ className, ...props }: ComponentProps<'div'>) {
|
|
59
|
+
const { s, slots } = useParts()
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
data-slot="input-otp-group"
|
|
63
|
+
className={s.group({ class: [slots.group, className] })}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface InputOTPSlotProps extends ComponentProps<'div'> {
|
|
70
|
+
/** Position of the character this box shows, starting at 0. */
|
|
71
|
+
index: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function InputOTPSlot({ index, className, ...props }: InputOTPSlotProps) {
|
|
75
|
+
const { s, slots, invalid } = useParts()
|
|
76
|
+
const slot = useContext(OTPInputContext).slots[index]
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
data-slot="input-otp-slot"
|
|
80
|
+
data-active={slot?.isActive ?? false}
|
|
81
|
+
aria-invalid={invalid || undefined}
|
|
82
|
+
className={s.slot({ class: [slots.slot, className] })}
|
|
83
|
+
{...props}
|
|
84
|
+
>
|
|
85
|
+
{slot?.char ?? slot?.placeholderChar}
|
|
86
|
+
{slot?.hasFakeCaret && (
|
|
87
|
+
<div className={s.caret({ class: slots.caret })}>
|
|
88
|
+
<div className={s.caretBar({ class: slots.caretBar })} />
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function InputOTPSeparator({ className, ...props }: ComponentProps<'div'>) {
|
|
96
|
+
const { s, slots } = useParts()
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
data-slot="input-otp-separator"
|
|
100
|
+
aria-hidden="true"
|
|
101
|
+
className={s.separator({ class: [slots.separator, className] })}
|
|
102
|
+
{...props}
|
|
103
|
+
>
|
|
104
|
+
<svg
|
|
105
|
+
viewBox="0 0 24 24"
|
|
106
|
+
fill="none"
|
|
107
|
+
stroke="currentColor"
|
|
108
|
+
strokeWidth="2"
|
|
109
|
+
strokeLinecap="round"
|
|
110
|
+
aria-hidden="true"
|
|
111
|
+
>
|
|
112
|
+
<path d="M6 12h12" />
|
|
113
|
+
</svg>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -17,7 +17,7 @@ export const linkStyles = tv({
|
|
|
17
17
|
tone: {
|
|
18
18
|
default: { base: 'text-foreground' },
|
|
19
19
|
muted: { base: 'text-muted-foreground hover:text-foreground' },
|
|
20
|
-
primary: { base: 'text-primary' },
|
|
20
|
+
primary: { base: 'text-primary-text' },
|
|
21
21
|
},
|
|
22
22
|
underline: {
|
|
23
23
|
hover: {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
2
|
+
import { tv } from '../../utils/tv'
|
|
3
|
+
|
|
4
|
+
/** Modal is built from Dialog parts; these slots add Modal-specific classes on top. */
|
|
5
|
+
export const modalStyles = tv({
|
|
6
|
+
slots: {
|
|
7
|
+
content: '',
|
|
8
|
+
header: '',
|
|
9
|
+
body: 'grid gap-4',
|
|
10
|
+
footer: '',
|
|
11
|
+
/** Wraps body and footer when `onSubmit` is set; `contents` keeps the dialog layout. */
|
|
12
|
+
form: 'contents',
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
declare module '@glyphui/tokens' {
|
|
17
|
+
interface GlyphComponentRegistry {
|
|
18
|
+
Modal: StylesEntry<typeof modalStyles>
|
|
19
|
+
}
|
|
20
|
+
}
|