@glyphui/ui 0.2.0 → 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 (75) hide show
  1. package/dist/chart.d.ts +202 -0
  2. package/dist/chart.js +457 -0
  3. package/dist/data-table.js +3 -2
  4. package/dist/{field-CCecIzdq.js → field-CWZ_FdkD.js} +2 -1
  5. package/dist/{field-context-B6WOcK5m.js → field-context-anGPYXbW.js} +2 -56
  6. package/dist/form.js +3 -2
  7. package/dist/index.d.ts +2285 -241
  8. package/dist/index.js +2819 -266
  9. package/dist/{states-Rp8wZ_jH.js → states-DR-OoDT-.js} +2 -1
  10. package/dist/tv-DBAGlgXJ.js +58 -0
  11. package/package.json +17 -2
  12. package/src/components/accordion/accordion.styles.ts +4 -4
  13. package/src/components/calendar/calendar.styles.ts +50 -0
  14. package/src/components/calendar/calendar.tsx +84 -0
  15. package/src/components/calendar/index.ts +3 -0
  16. package/src/components/carousel/carousel.styles.ts +43 -0
  17. package/src/components/carousel/carousel.tsx +278 -0
  18. package/src/components/carousel/index.ts +13 -0
  19. package/src/components/collapsible/collapsible.tsx +66 -0
  20. package/src/components/collapsible/index.ts +6 -0
  21. package/src/components/context-menu/context-menu.styles.ts +19 -0
  22. package/src/components/context-menu/context-menu.tsx +226 -0
  23. package/src/components/context-menu/index.ts +19 -0
  24. package/src/components/copy-button/copy-button.tsx +117 -0
  25. package/src/components/copy-button/index.ts +1 -0
  26. package/src/components/date-picker/date-picker.styles.ts +42 -0
  27. package/src/components/date-picker/date-picker.tsx +322 -0
  28. package/src/components/date-picker/index.ts +8 -0
  29. package/src/components/file-upload/file-upload.styles.ts +43 -0
  30. package/src/components/file-upload/file-upload.tsx +343 -0
  31. package/src/components/file-upload/index.ts +10 -0
  32. package/src/components/hover-card/hover-card.tsx +41 -0
  33. package/src/components/hover-card/index.ts +1 -0
  34. package/src/components/input-otp/index.ts +10 -0
  35. package/src/components/input-otp/input-otp.styles.ts +38 -0
  36. package/src/components/input-otp/input-otp.tsx +116 -0
  37. package/src/components/modal/index.ts +2 -0
  38. package/src/components/modal/modal.styles.ts +18 -0
  39. package/src/components/modal/modal.tsx +86 -0
  40. package/src/components/navigation-menu/index.ts +14 -0
  41. package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
  42. package/src/components/navigation-menu/navigation-menu.tsx +198 -0
  43. package/src/components/number-input/index.ts +2 -0
  44. package/src/components/number-input/number-input.styles.ts +41 -0
  45. package/src/components/number-input/number-input.tsx +211 -0
  46. package/src/components/password-input/index.ts +7 -0
  47. package/src/components/password-input/password-input.styles.ts +30 -0
  48. package/src/components/password-input/password-input.tsx +127 -0
  49. package/src/components/resizable/index.ts +9 -0
  50. package/src/components/resizable/resizable.styles.ts +28 -0
  51. package/src/components/resizable/resizable.tsx +69 -0
  52. package/src/components/sheet/sheet.styles.ts +7 -7
  53. package/src/components/stepper/index.ts +2 -0
  54. package/src/components/stepper/stepper.styles.ts +74 -0
  55. package/src/components/stepper/stepper.tsx +138 -0
  56. package/src/components/tag-input/index.ts +2 -0
  57. package/src/components/tag-input/tag-input.styles.ts +39 -0
  58. package/src/components/tag-input/tag-input.tsx +198 -0
  59. package/src/components/timeline/index.ts +8 -0
  60. package/src/components/timeline/timeline.styles.ts +56 -0
  61. package/src/components/timeline/timeline.tsx +91 -0
  62. package/src/components/toggle/index.ts +9 -0
  63. package/src/components/toggle/toggle.styles.ts +39 -0
  64. package/src/components/toggle/toggle.tsx +61 -0
  65. package/src/components/tree-view/index.ts +2 -0
  66. package/src/components/tree-view/tree-view.styles.ts +42 -0
  67. package/src/components/tree-view/tree-view.tsx +274 -0
  68. package/src/components/visually-hidden/index.ts +1 -0
  69. package/src/components/visually-hidden/visually-hidden.tsx +10 -0
  70. package/src/index.ts +20 -0
  71. package/src/patterns/chart/chart-parts.tsx +171 -0
  72. package/src/patterns/chart/chart.styles.ts +35 -0
  73. package/src/patterns/chart/chart.tsx +369 -0
  74. package/src/patterns/chart/index.ts +13 -0
  75. package/styles.css +16 -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,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'
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { Modal, ModalClose, type ModalProps } from './modal'
2
+ export { modalStyles } from './modal.styles'
@@ -0,0 +1,18 @@
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
+ },
12
+ })
13
+
14
+ declare module '@glyphui/tokens' {
15
+ interface GlyphComponentRegistry {
16
+ Modal: StylesEntry<typeof modalStyles>
17
+ }
18
+ }
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useComponentConfig } from '../../hooks/use-component-config'
3
+ import {
4
+ Dialog,
5
+ DialogBody,
6
+ DialogClose,
7
+ DialogContent,
8
+ type DialogContentProps,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogTrigger,
14
+ } from '../dialog'
15
+ import { modalStyles } from './modal.styles'
16
+
17
+ export interface ModalProps extends Omit<DialogContentProps, 'title' | 'children'> {
18
+ open?: boolean
19
+ defaultOpen?: boolean
20
+ onOpenChange?: (open: boolean) => void
21
+ /** Heading of the modal. Also its accessible name. */
22
+ title: ReactNode
23
+ description?: ReactNode
24
+ /** Element that opens the modal, e.g. a Button. Optional when you control `open`. */
25
+ trigger?: ReactNode
26
+ /** Actions at the bottom. Wrap a button in `ModalClose` to close on click. */
27
+ footer?: ReactNode
28
+ /** Close when the backdrop is clicked. Default true. */
29
+ closeOnOverlayClick?: boolean
30
+ children?: ReactNode
31
+ }
32
+
33
+ /**
34
+ * The everyday modal in one component: title, description, body and footer as props.
35
+ * For custom layouts use the Dialog parts; for yes/no decisions use `useConfirm()`.
36
+ */
37
+ export function Modal(inProps: ModalProps) {
38
+ const [props, slots] = useComponentConfig('Modal', inProps)
39
+ const {
40
+ open,
41
+ defaultOpen,
42
+ onOpenChange,
43
+ title,
44
+ description,
45
+ trigger,
46
+ footer,
47
+ closeOnOverlayClick = true,
48
+ onPointerDownOutside,
49
+ className,
50
+ children,
51
+ ...rest
52
+ } = props
53
+ const s = modalStyles()
54
+
55
+ return (
56
+ <Dialog
57
+ {...(open !== undefined && { open })}
58
+ {...(defaultOpen !== undefined && { defaultOpen })}
59
+ {...(onOpenChange && { onOpenChange })}
60
+ >
61
+ {trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
62
+ <DialogContent
63
+ data-slot="modal"
64
+ className={s.content({ class: [slots.content, className] })}
65
+ onPointerDownOutside={(event) => {
66
+ onPointerDownOutside?.(event)
67
+ if (!closeOnOverlayClick) event.preventDefault()
68
+ }}
69
+ {...(!description && { 'aria-describedby': undefined })}
70
+ {...rest}
71
+ >
72
+ <DialogHeader className={s.header({ class: slots.header })}>
73
+ <DialogTitle>{title}</DialogTitle>
74
+ {description && <DialogDescription>{description}</DialogDescription>}
75
+ </DialogHeader>
76
+ {children && <DialogBody className={s.body({ class: slots.body })}>{children}</DialogBody>}
77
+ {footer && (
78
+ <DialogFooter className={s.footer({ class: slots.footer })}>{footer}</DialogFooter>
79
+ )}
80
+ </DialogContent>
81
+ </Dialog>
82
+ )
83
+ }
84
+
85
+ /** Closes the surrounding Modal. Use with `asChild` around a button. */
86
+ export const ModalClose: typeof DialogClose = DialogClose
@@ -0,0 +1,14 @@
1
+ export {
2
+ NavigationMenu,
3
+ NavigationMenuCard,
4
+ type NavigationMenuCardProps,
5
+ NavigationMenuContent,
6
+ NavigationMenuItem,
7
+ NavigationMenuLink,
8
+ type NavigationMenuLinkProps,
9
+ NavigationMenuList,
10
+ type NavigationMenuProps,
11
+ NavigationMenuTrigger,
12
+ NavigationMenuViewport,
13
+ } from './navigation-menu'
14
+ export { navigationMenuStyles } from './navigation-menu.styles'