@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,28 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { tv, type VariantProps } from '../../utils/tv'
3
+
4
+ export const resizableStyles = tv({
5
+ slots: {
6
+ group: 'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
7
+ panel: 'min-w-0',
8
+ // A 1px divider with a wider invisible hit area. Highlights while hovered, focused or dragged.
9
+ handle: [
10
+ 'relative flex w-px shrink-0 items-center justify-center bg-border outline-none',
11
+ 'transition-colors duration-(--glyph-duration-fast)',
12
+ 'after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2',
13
+ 'hover:bg-border-strong data-[resize-handle-state=drag]:bg-primary',
14
+ 'focus-visible:bg-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
15
+ 'data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full',
16
+ 'data-[panel-group-direction=vertical]:after:inset-x-0 data-[panel-group-direction=vertical]:after:inset-y-auto data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:top-1/2 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2',
17
+ ],
18
+ grip: 'z-10 flex h-5 w-3 items-center justify-center rounded-xs border border-border bg-surface text-faint-foreground shadow-xs in-data-[panel-group-direction=vertical]:h-3 in-data-[panel-group-direction=vertical]:w-5 [&>svg]:size-2.5 in-data-[panel-group-direction=vertical]:[&>svg]:rotate-90',
19
+ },
20
+ })
21
+
22
+ export type ResizableVariants = VariantProps<typeof resizableStyles>
23
+
24
+ declare module '@glyphui/tokens' {
25
+ interface GlyphComponentRegistry {
26
+ Resizable: StylesEntry<typeof resizableStyles>
27
+ }
28
+ }
@@ -0,0 +1,69 @@
1
+ import type { ComponentProps } from 'react'
2
+ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import { resizableStyles } from './resizable.styles'
5
+
6
+ const s = resizableStyles()
7
+
8
+ function useSlots() {
9
+ return useComponentConfig('Resizable', {})[1]
10
+ }
11
+
12
+ export type ResizablePanelGroupProps = ComponentProps<typeof PanelGroup>
13
+
14
+ /**
15
+ * Panels separated by draggable, keyboard-accessible dividers, for editors and split views.
16
+ * Set `direction` to "horizontal" or "vertical"; pass `autoSaveId` to remember sizes.
17
+ */
18
+ export function ResizablePanelGroup({ className, ...props }: ResizablePanelGroupProps) {
19
+ const slots = useSlots()
20
+ return (
21
+ <PanelGroup
22
+ data-slot="resizable-group"
23
+ className={s.group({ class: [slots.group, className] })}
24
+ {...props}
25
+ />
26
+ )
27
+ }
28
+
29
+ export type ResizablePanelProps = ComponentProps<typeof Panel>
30
+
31
+ export function ResizablePanel({ className, ...props }: ResizablePanelProps) {
32
+ const slots = useSlots()
33
+ return (
34
+ <Panel
35
+ data-slot="resizable-panel"
36
+ className={s.panel({ class: [slots.panel, className] })}
37
+ {...props}
38
+ />
39
+ )
40
+ }
41
+
42
+ export interface ResizableHandleProps extends ComponentProps<typeof PanelResizeHandle> {
43
+ /** Show a visible grip on the divider. */
44
+ withHandle?: boolean
45
+ }
46
+
47
+ export function ResizableHandle({ withHandle, className, ...props }: ResizableHandleProps) {
48
+ const slots = useSlots()
49
+ return (
50
+ <PanelResizeHandle
51
+ data-slot="resizable-handle"
52
+ className={s.handle({ class: [slots.handle, className] })}
53
+ {...props}
54
+ >
55
+ {withHandle && (
56
+ <div className={s.grip({ class: slots.grip })} aria-hidden="true">
57
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
58
+ <circle cx="9" cy="6" r="1.5" />
59
+ <circle cx="15" cy="6" r="1.5" />
60
+ <circle cx="9" cy="12" r="1.5" />
61
+ <circle cx="15" cy="12" r="1.5" />
62
+ <circle cx="9" cy="18" r="1.5" />
63
+ <circle cx="15" cy="18" r="1.5" />
64
+ </svg>
65
+ </div>
66
+ )}
67
+ </PanelResizeHandle>
68
+ )
69
+ }
@@ -4,10 +4,10 @@ import { tv, type VariantProps } from '../../utils/tv'
4
4
 
5
5
  export const sheetStyles = tv({
6
6
  slots: {
7
- overlay: 'fixed inset-0 z-50 animate-fade-in bg-overlay',
7
+ overlay:
8
+ 'fixed inset-0 z-50 bg-overlay data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out',
8
9
  content: [
9
10
  'fixed z-50 flex flex-col gap-5 border-border bg-surface p-6 text-foreground shadow-lg outline-none',
10
- 'transition-[translate] duration-(--glyph-duration-slow) ease-standard',
11
11
  ],
12
12
  close: [
13
13
  'absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground [&>svg]:size-4',
@@ -21,23 +21,23 @@ export const sheetStyles = tv({
21
21
  footer: 'mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
22
22
  },
23
23
  variants: {
24
- // Slide in from the edge using @starting-style (Tailwind's `starting:` variant).
24
+ // Slides in and out from its edge; Radix keeps it mounted until the exit animation ends.
25
25
  side: {
26
26
  right: {
27
27
  content:
28
- 'inset-y-0 right-0 h-full w-full border-l-(length:--glyph-border-width) starting:translate-x-full',
28
+ 'inset-y-0 right-0 h-full w-full border-l-(length:--glyph-border-width) data-[state=open]:animate-sheet-in-right data-[state=closed]:animate-sheet-out-right',
29
29
  },
30
30
  left: {
31
31
  content:
32
- 'inset-y-0 left-0 h-full w-full border-r-(length:--glyph-border-width) starting:-translate-x-full',
32
+ 'inset-y-0 left-0 h-full w-full border-r-(length:--glyph-border-width) data-[state=open]:animate-sheet-in-left data-[state=closed]:animate-sheet-out-left',
33
33
  },
34
34
  top: {
35
35
  content:
36
- 'inset-x-0 top-0 max-h-[85dvh] border-b-(length:--glyph-border-width) starting:-translate-y-full',
36
+ 'inset-x-0 top-0 max-h-[85dvh] border-b-(length:--glyph-border-width) data-[state=open]:animate-sheet-in-top data-[state=closed]:animate-sheet-out-top',
37
37
  },
38
38
  bottom: {
39
39
  content:
40
- 'inset-x-0 bottom-0 max-h-[85dvh] rounded-t-xl border-t-(length:--glyph-border-width) starting:translate-y-full',
40
+ 'inset-x-0 bottom-0 max-h-[85dvh] rounded-t-xl border-t-(length:--glyph-border-width) data-[state=open]:animate-sheet-in-bottom data-[state=closed]:animate-sheet-out-bottom',
41
41
  },
42
42
  },
43
43
  size: { sm: {}, md: {}, lg: {}, xl: {} },
@@ -0,0 +1,2 @@
1
+ export { Stepper, type StepperProps, type StepperStep, type StepStatus } from './stepper'
2
+ export { type StepperVariants, stepperStyles } from './stepper.styles'
@@ -0,0 +1,74 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { focusRing, themedBorder, transition } from '../../utils/styles'
3
+ import { tv, type VariantProps } from '../../utils/tv'
4
+
5
+ export const stepperStyles = tv({
6
+ slots: {
7
+ root: 'flex w-full list-none p-0',
8
+ step: 'relative flex min-w-0',
9
+ trigger: ['flex min-w-0 rounded-md text-left', focusRing],
10
+ indicator: [
11
+ 'relative z-10 flex size-8 shrink-0 items-center justify-center rounded-pill text-small font-medium tabular-nums [&>svg]:size-4',
12
+ themedBorder,
13
+ transition,
14
+ ],
15
+ connector: 'absolute transition-colors duration-(--glyph-duration-base)',
16
+ text: 'grid min-w-0 gap-0.5',
17
+ title: 'text-small font-medium',
18
+ description: 'text-caption text-muted-foreground',
19
+ },
20
+ variants: {
21
+ orientation: {
22
+ horizontal: {
23
+ root: 'items-start',
24
+ step: 'flex-1 flex-col items-center text-center',
25
+ trigger: 'flex-col items-center gap-2 text-center',
26
+ connector: 'top-4 right-[calc(-50%+1.25rem)] left-[calc(50%+1.25rem)] h-px',
27
+ text: 'px-2',
28
+ },
29
+ vertical: {
30
+ root: 'flex-col',
31
+ step: 'gap-3 pb-7 last:pb-0',
32
+ trigger: 'items-start gap-3',
33
+ connector: 'top-9.5 bottom-1.5 left-4 w-px -translate-x-1/2',
34
+ text: 'pt-1.5',
35
+ },
36
+ },
37
+ status: {
38
+ complete: {
39
+ indicator: 'border-primary bg-primary text-primary-foreground',
40
+ connector: 'bg-primary',
41
+ title: 'text-foreground',
42
+ },
43
+ current: {
44
+ indicator: 'border-primary bg-surface text-primary ring-4 ring-primary/15',
45
+ connector: 'bg-border',
46
+ title: 'text-foreground',
47
+ },
48
+ upcoming: {
49
+ indicator: 'border-border bg-surface text-muted-foreground',
50
+ connector: 'bg-border',
51
+ title: 'text-muted-foreground',
52
+ },
53
+ error: {
54
+ indicator: 'border-danger bg-danger text-danger-foreground',
55
+ connector: 'bg-border',
56
+ title: 'text-danger',
57
+ },
58
+ },
59
+ clickable: {
60
+ true: {
61
+ trigger: 'cursor-pointer hover:[&_[data-slot=stepper-title]]:underline underline-offset-4',
62
+ },
63
+ },
64
+ },
65
+ defaultVariants: { orientation: 'horizontal', status: 'upcoming' },
66
+ })
67
+
68
+ export type StepperVariants = VariantProps<typeof stepperStyles>
69
+
70
+ declare module '@glyphui/tokens' {
71
+ interface GlyphComponentRegistry {
72
+ Stepper: StylesEntry<typeof stepperStyles>
73
+ }
74
+ }
@@ -0,0 +1,138 @@
1
+ import type { ComponentProps, ReactNode } from 'react'
2
+ import { useComponentConfig } from '../../hooks/use-component-config'
3
+ import { stepperStyles } from './stepper.styles'
4
+
5
+ export type StepStatus = 'complete' | 'current' | 'upcoming' | 'error'
6
+
7
+ export interface StepperStep {
8
+ title: ReactNode
9
+ description?: ReactNode
10
+ /** Override the status derived from `current`, e.g. `'error'` for a failed step. */
11
+ status?: StepStatus
12
+ }
13
+
14
+ export interface StepperProps extends Omit<ComponentProps<'ol'>, 'children'> {
15
+ steps: readonly StepperStep[]
16
+ /** Index of the current step. Earlier steps are complete, later ones upcoming. */
17
+ current: number
18
+ orientation?: 'horizontal' | 'vertical'
19
+ /** Makes completed steps clickable, e.g. to go back in a wizard. */
20
+ onStepClick?: (index: number) => void
21
+ }
22
+
23
+ const statusText: Record<StepStatus, string> = {
24
+ complete: 'completed',
25
+ current: 'current step',
26
+ upcoming: 'not started',
27
+ error: 'needs attention',
28
+ }
29
+
30
+ const icon = {
31
+ xmlns: 'http://www.w3.org/2000/svg',
32
+ viewBox: '0 0 24 24',
33
+ fill: 'none',
34
+ stroke: 'currentColor',
35
+ strokeWidth: 2.5,
36
+ strokeLinecap: 'round',
37
+ strokeLinejoin: 'round',
38
+ } as const
39
+
40
+ const CheckIcon = () => (
41
+ <svg aria-hidden="true" {...icon}>
42
+ <path d="M20 6 9 17l-5-5" />
43
+ </svg>
44
+ )
45
+ const AlertIcon = () => (
46
+ <svg aria-hidden="true" {...icon}>
47
+ <path d="M12 7v6M12 17h.01" />
48
+ </svg>
49
+ )
50
+
51
+ function statusOf(step: StepperStep, index: number, current: number): StepStatus {
52
+ if (step.status) return step.status
53
+ if (index < current) return 'complete'
54
+ return index === current ? 'current' : 'upcoming'
55
+ }
56
+
57
+ /** Progress through a multi-step flow such as onboarding or checkout. */
58
+ export function Stepper(inProps: StepperProps) {
59
+ const [props, slots] = useComponentConfig('Stepper', inProps)
60
+ const {
61
+ steps,
62
+ current,
63
+ orientation = 'horizontal',
64
+ onStepClick,
65
+ className,
66
+ 'aria-label': ariaLabel = 'Progress',
67
+ ...rest
68
+ } = props
69
+ const root = stepperStyles({ orientation })
70
+
71
+ return (
72
+ <ol
73
+ data-slot="stepper"
74
+ data-orientation={orientation}
75
+ aria-label={ariaLabel}
76
+ className={root.root({ class: [slots.root, className] })}
77
+ {...rest}
78
+ >
79
+ {steps.map((step, index) => {
80
+ const status = statusOf(step, index, current)
81
+ const clickable = Boolean(onStepClick) && status === 'complete'
82
+ const s = stepperStyles({ orientation, status, clickable })
83
+ const content = (
84
+ <>
85
+ <span data-slot="stepper-indicator" className={s.indicator({ class: slots.indicator })}>
86
+ {status === 'complete' ? (
87
+ <CheckIcon />
88
+ ) : status === 'error' ? (
89
+ <AlertIcon />
90
+ ) : (
91
+ index + 1
92
+ )}
93
+ </span>
94
+ <span className={s.text({ class: slots.text })}>
95
+ <span data-slot="stepper-title" className={s.title({ class: slots.title })}>
96
+ {step.title}
97
+ </span>
98
+ {step.description && (
99
+ <span className={s.description({ class: slots.description })}>
100
+ {step.description}
101
+ </span>
102
+ )}
103
+ <span className="sr-only">, {statusText[status]}</span>
104
+ </span>
105
+ </>
106
+ )
107
+ return (
108
+ <li
109
+ key={index}
110
+ data-slot="stepper-step"
111
+ data-status={status}
112
+ aria-current={status === 'current' ? 'step' : undefined}
113
+ className={s.step({ class: slots.step })}
114
+ >
115
+ {index < steps.length - 1 && (
116
+ <span
117
+ data-slot="stepper-connector"
118
+ aria-hidden="true"
119
+ className={s.connector({ class: slots.connector })}
120
+ />
121
+ )}
122
+ {clickable ? (
123
+ <button
124
+ type="button"
125
+ onClick={() => onStepClick?.(index)}
126
+ className={s.trigger({ class: slots.trigger })}
127
+ >
128
+ {content}
129
+ </button>
130
+ ) : (
131
+ <span className={s.trigger({ class: slots.trigger })}>{content}</span>
132
+ )}
133
+ </li>
134
+ )
135
+ })}
136
+ </ol>
137
+ )
138
+ }
@@ -0,0 +1,2 @@
1
+ export { TagInput, type TagInputProps } from './tag-input'
2
+ export { type TagInputVariants, tagInputStyles } from './tag-input.styles'
@@ -0,0 +1,39 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { tv, type VariantProps } from '../../utils/tv'
3
+ import { fieldControl } from '../input/input.styles'
4
+
5
+ export const tagInputStyles = tv({
6
+ slots: {
7
+ root: 'grid gap-1.5',
8
+ frame: [
9
+ ...fieldControl,
10
+ 'flex cursor-text flex-wrap items-center gap-1.5 px-2 py-1',
11
+ 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20',
12
+ 'data-invalid:focus-within:ring-danger/20',
13
+ 'has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50',
14
+ ],
15
+ tag: 'inline-flex h-6 max-w-full items-center gap-1 rounded-pill bg-muted pr-1 pl-2.5 text-caption text-foreground',
16
+ tagLabel: 'truncate',
17
+ remove:
18
+ 'inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-pill text-muted-foreground hover:bg-border-strong/40 hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring [&>svg]:size-3',
19
+ input:
20
+ 'h-7 min-w-24 flex-1 bg-transparent px-1 text-foreground outline-none placeholder:text-faint-foreground disabled:cursor-not-allowed',
21
+ message: 'text-small font-medium text-danger',
22
+ },
23
+ variants: {
24
+ size: {
25
+ sm: { frame: 'min-h-(--glyph-control-sm) text-small' },
26
+ md: { frame: 'min-h-(--glyph-control-md) text-small' },
27
+ lg: { frame: 'min-h-(--glyph-control-lg) text-body' },
28
+ },
29
+ },
30
+ defaultVariants: { size: 'md' },
31
+ })
32
+
33
+ export type TagInputVariants = VariantProps<typeof tagInputStyles>
34
+
35
+ declare module '@glyphui/tokens' {
36
+ interface GlyphComponentRegistry {
37
+ TagInput: StylesEntry<typeof tagInputStyles>
38
+ }
39
+ }
@@ -0,0 +1,198 @@
1
+ import { type ClipboardEvent, type KeyboardEvent, useId, useRef, useState } from 'react'
2
+ import { useComponentConfig } from '../../hooks/use-component-config'
3
+ import { useControllableState } from '../../hooks/use-controllable-state'
4
+ import { useFieldControl } from '../field/field-context'
5
+ import { type TagInputVariants, tagInputStyles } from './tag-input.styles'
6
+
7
+ export interface TagInputProps extends TagInputVariants {
8
+ value?: string[]
9
+ defaultValue?: string[]
10
+ onValueChange?: (tags: string[]) => void
11
+ placeholder?: string
12
+ /** Stop accepting tags after this many. */
13
+ maxTags?: number
14
+ /** Return an error message to reject a tag, or `true` to accept it. */
15
+ validate?: (tag: string, tags: readonly string[]) => string | true
16
+ /** Allow the same tag more than once. Default false. */
17
+ allowDuplicates?: boolean
18
+ /** Keys (besides Enter) that turn the typed text into a tag. Default `[',']`. */
19
+ separators?: readonly string[]
20
+ disabled?: boolean
21
+ /** Submits each tag as its own hidden input with this name. */
22
+ name?: string
23
+ id?: string
24
+ className?: string
25
+ 'aria-label'?: string
26
+ 'aria-describedby'?: string
27
+ 'aria-invalid'?: boolean
28
+ }
29
+
30
+ function XIcon() {
31
+ return (
32
+ <svg
33
+ viewBox="0 0 24 24"
34
+ fill="none"
35
+ stroke="currentColor"
36
+ strokeWidth="2.5"
37
+ strokeLinecap="round"
38
+ aria-hidden="true"
39
+ >
40
+ <path d="M18 6 6 18M6 6l12 12" />
41
+ </svg>
42
+ )
43
+ }
44
+
45
+ /**
46
+ * Free-form multi-value input. Enter or a comma turns the text into a removable tag;
47
+ * Backspace on an empty input removes the last one. Pasting a comma-separated list adds them all.
48
+ */
49
+ export function TagInput(inProps: TagInputProps) {
50
+ const [props, slots] = useComponentConfig('TagInput', useFieldControl(inProps))
51
+ const {
52
+ value: valueProp,
53
+ defaultValue = [],
54
+ onValueChange,
55
+ placeholder = 'Add a tag…',
56
+ maxTags,
57
+ validate,
58
+ allowDuplicates = false,
59
+ separators = [','],
60
+ disabled,
61
+ name,
62
+ id,
63
+ size,
64
+ className,
65
+ 'aria-label': ariaLabel,
66
+ 'aria-describedby': ariaDescribedBy,
67
+ 'aria-invalid': ariaInvalid,
68
+ } = props
69
+ const [tags, setTags] = useControllableState<string[]>({
70
+ value: valueProp,
71
+ defaultValue,
72
+ onChange: onValueChange,
73
+ })
74
+ const [draft, setDraft] = useState('')
75
+ const [error, setError] = useState<string | null>(null)
76
+ const inputRef = useRef<HTMLInputElement>(null)
77
+ const errorId = useId()
78
+ const s = tagInputStyles({ size })
79
+ const full = maxTags !== undefined && tags.length >= maxTags
80
+ const invalid = Boolean(error) || ariaInvalid === true
81
+
82
+ const add = (raw: readonly string[]) => {
83
+ let next = [...tags]
84
+ let problem: string | null = null
85
+ for (const candidate of raw.map((t) => t.trim()).filter(Boolean)) {
86
+ if (maxTags !== undefined && next.length >= maxTags) {
87
+ problem = `You can add up to ${maxTags} tags.`
88
+ break
89
+ }
90
+ if (!allowDuplicates && next.includes(candidate)) {
91
+ problem = `“${candidate}” is already added.`
92
+ continue
93
+ }
94
+ const verdict = validate?.(candidate, next) ?? true
95
+ if (verdict !== true) {
96
+ problem = verdict
97
+ continue
98
+ }
99
+ next = [...next, candidate]
100
+ }
101
+ if (next.length !== tags.length) setTags(next)
102
+ setError(problem)
103
+ return problem === null
104
+ }
105
+
106
+ const commitDraft = () => {
107
+ if (!draft.trim()) return
108
+ if (add([draft])) setDraft('')
109
+ }
110
+
111
+ const remove = (index: number) => {
112
+ setTags(tags.filter((_, i) => i !== index))
113
+ setError(null)
114
+ inputRef.current?.focus()
115
+ }
116
+
117
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
118
+ if (event.key === 'Enter' || separators.includes(event.key)) {
119
+ if (event.key === 'Enter' && !draft.trim()) return
120
+ event.preventDefault()
121
+ commitDraft()
122
+ } else if (event.key === 'Backspace' && draft === '' && tags.length > 0) {
123
+ event.preventDefault()
124
+ remove(tags.length - 1)
125
+ }
126
+ }
127
+
128
+ const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
129
+ const text = event.clipboardData.getData('text')
130
+ const pattern = new RegExp(
131
+ `[${['\n', ...separators].map((s) => s.replace(/[-\\^\]]/g, '\\$&')).join('')}]`,
132
+ )
133
+ if (!pattern.test(text)) return
134
+ event.preventDefault()
135
+ add(text.split(pattern))
136
+ }
137
+
138
+ const describedBy =
139
+ [ariaDescribedBy, error ? errorId : undefined].filter(Boolean).join(' ') || undefined
140
+
141
+ return (
142
+ <div data-slot="tag-input" className={s.root({ class: [slots.root, className] })}>
143
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: clicking the frame focuses the input, a convenience; the input itself is focusable */}
144
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: keyboard users reach the input directly */}
145
+ <div
146
+ data-invalid={invalid || undefined}
147
+ className={s.frame({ class: slots.frame })}
148
+ onClick={() => inputRef.current?.focus()}
149
+ >
150
+ {tags.map((tag, index) => (
151
+ <span key={`${tag}-${index}`} data-slot="tag" className={s.tag({ class: slots.tag })}>
152
+ <span className={s.tagLabel({ class: slots.tagLabel })}>{tag}</span>
153
+ {!disabled && (
154
+ <button
155
+ type="button"
156
+ aria-label={`Remove ${tag}`}
157
+ className={s.remove({ class: slots.remove })}
158
+ onClick={(event) => {
159
+ event.stopPropagation()
160
+ remove(index)
161
+ }}
162
+ >
163
+ <XIcon />
164
+ </button>
165
+ )}
166
+ </span>
167
+ ))}
168
+ <input
169
+ ref={inputRef}
170
+ id={id}
171
+ value={draft}
172
+ disabled={disabled || full}
173
+ placeholder={full ? undefined : tags.length === 0 ? placeholder : undefined}
174
+ aria-label={ariaLabel}
175
+ aria-describedby={describedBy}
176
+ aria-invalid={invalid || undefined}
177
+ className={s.input({ class: slots.input })}
178
+ onChange={(event) => {
179
+ setDraft(event.target.value)
180
+ if (error) setError(null)
181
+ }}
182
+ onKeyDown={handleKeyDown}
183
+ onPaste={handlePaste}
184
+ onBlur={commitDraft}
185
+ />
186
+ </div>
187
+ {error && (
188
+ <p id={errorId} role="alert" className={s.message({ class: slots.message })}>
189
+ {error}
190
+ </p>
191
+ )}
192
+ {name &&
193
+ tags.map((tag, index) => (
194
+ <input key={`${tag}-${index}`} type="hidden" name={name} value={tag} />
195
+ ))}
196
+ </div>
197
+ )
198
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ Timeline,
3
+ TimelineItem,
4
+ type TimelineItemData,
5
+ type TimelineItemProps,
6
+ type TimelineProps,
7
+ } from './timeline'
8
+ export { type TimelineVariants, timelineStyles } from './timeline.styles'
@@ -0,0 +1,56 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { themedBorder } from '../../utils/styles'
3
+ import { tv, type VariantProps } from '../../utils/tv'
4
+
5
+ export const timelineStyles = tv({
6
+ slots: {
7
+ root: 'grid',
8
+ item: 'group/item relative grid grid-cols-[auto_1fr] gap-x-3',
9
+ rail: 'flex flex-col items-center',
10
+ marker: [
11
+ 'z-10 flex size-6 shrink-0 items-center justify-center rounded-pill bg-surface [&>svg]:size-3.5',
12
+ themedBorder,
13
+ ],
14
+ dot: 'size-2 rounded-pill bg-current',
15
+ connector: 'my-1 w-px flex-1 bg-border group-last/item:hidden',
16
+ content: 'grid min-w-0 gap-0.5 pt-0.5 pb-6 group-last/item:pb-0',
17
+ header: 'flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5',
18
+ title: 'text-small font-medium text-foreground',
19
+ time: 'font-mono text-caption text-faint-foreground tabular-nums',
20
+ description: 'text-small text-muted-foreground',
21
+ },
22
+ variants: {
23
+ tone: {
24
+ neutral: { marker: 'border-border text-muted-foreground' },
25
+ primary: {
26
+ marker:
27
+ 'border-primary/40 text-[color-mix(in_oklab,var(--color-primary)_80%,var(--color-foreground))]',
28
+ },
29
+ info: {
30
+ marker:
31
+ 'border-info/40 text-[color-mix(in_oklab,var(--color-info)_80%,var(--color-foreground))]',
32
+ },
33
+ success: {
34
+ marker:
35
+ 'border-success/40 text-[color-mix(in_oklab,var(--color-success)_80%,var(--color-foreground))]',
36
+ },
37
+ warning: {
38
+ marker:
39
+ 'border-warning/50 text-[color-mix(in_oklab,var(--color-warning)_80%,var(--color-foreground))]',
40
+ },
41
+ danger: {
42
+ marker:
43
+ 'border-danger/40 text-[color-mix(in_oklab,var(--color-danger)_80%,var(--color-foreground))]',
44
+ },
45
+ },
46
+ },
47
+ defaultVariants: { tone: 'neutral' },
48
+ })
49
+
50
+ export type TimelineVariants = VariantProps<typeof timelineStyles>
51
+
52
+ declare module '@glyphui/tokens' {
53
+ interface GlyphComponentRegistry {
54
+ Timeline: StylesEntry<typeof timelineStyles>
55
+ }
56
+ }