@glyphui/ui 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/chart.d.ts +202 -0
- package/dist/chart.js +457 -0
- package/dist/data-table.js +3 -2
- package/dist/{field-DXZD0l8w.js → field-CWZ_FdkD.js} +2 -1
- package/dist/{field-context-B8cYheXz.js → field-context-anGPYXbW.js} +89 -143
- package/dist/form.js +3 -2
- package/dist/index.d.ts +4960 -266
- package/dist/index.js +5336 -146
- package/dist/{states-BxPjCUxz.js → states-DR-OoDT-.js} +2 -1
- package/dist/tv-DBAGlgXJ.js +58 -0
- package/package.json +19 -2
- package/src/components/accordion/accordion.styles.ts +41 -0
- package/src/components/accordion/accordion.tsx +86 -0
- package/src/components/accordion/index.ts +8 -0
- package/src/components/alert/alert-icons.tsx +66 -0
- package/src/components/alert/alert.styles.ts +57 -0
- package/src/components/alert/alert.tsx +86 -0
- package/src/components/alert/index.ts +2 -0
- package/src/components/alert-dialog/alert-dialog.styles.ts +24 -0
- package/src/components/alert-dialog/alert-dialog.tsx +118 -0
- package/src/components/alert-dialog/confirm.tsx +111 -0
- package/src/components/alert-dialog/index.ts +15 -0
- package/src/components/aspect-ratio/aspect-ratio.tsx +40 -0
- package/src/components/aspect-ratio/index.ts +1 -0
- package/src/components/badge/badge.styles.ts +1 -1
- package/src/components/banner/banner.styles.ts +40 -0
- package/src/components/banner/banner.tsx +101 -0
- package/src/components/banner/index.ts +2 -0
- package/src/components/blockquote/blockquote.tsx +59 -0
- package/src/components/blockquote/index.ts +1 -0
- package/src/components/breadcrumb/breadcrumb.styles.ts +20 -0
- package/src/components/breadcrumb/breadcrumb.tsx +135 -0
- package/src/components/breadcrumb/index.ts +12 -0
- package/src/components/button/button.tsx +11 -8
- package/src/components/calendar/calendar.styles.ts +50 -0
- package/src/components/calendar/calendar.tsx +84 -0
- package/src/components/calendar/index.ts +3 -0
- package/src/components/carousel/carousel.styles.ts +43 -0
- package/src/components/carousel/carousel.tsx +278 -0
- package/src/components/carousel/index.ts +13 -0
- package/src/components/code/code.styles.ts +33 -0
- package/src/components/code/code.tsx +96 -0
- package/src/components/code/index.ts +2 -0
- package/src/components/collapsible/collapsible.tsx +66 -0
- package/src/components/collapsible/index.ts +6 -0
- package/src/components/combobox/combobox.styles.ts +50 -0
- package/src/components/combobox/combobox.tsx +203 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command-menu/command-menu.styles.ts +41 -0
- package/src/components/command-menu/command-menu.tsx +217 -0
- package/src/components/command-menu/index.ts +14 -0
- package/src/components/context-menu/context-menu.styles.ts +19 -0
- package/src/components/context-menu/context-menu.tsx +226 -0
- package/src/components/context-menu/index.ts +19 -0
- package/src/components/copy-button/copy-button.tsx +117 -0
- package/src/components/copy-button/index.ts +1 -0
- package/src/components/date-picker/date-picker.styles.ts +42 -0
- package/src/components/date-picker/date-picker.tsx +322 -0
- package/src/components/date-picker/index.ts +8 -0
- package/src/components/description-list/description-list.styles.ts +31 -0
- package/src/components/description-list/description-list.tsx +56 -0
- package/src/components/description-list/index.ts +7 -0
- package/src/components/dialog/close-icon.tsx +22 -0
- package/src/components/dialog/dialog.styles.ts +41 -0
- package/src/components/dialog/dialog.tsx +111 -0
- package/src/components/dialog/index.ts +13 -0
- package/src/components/dropdown-menu/dropdown-menu.styles.ts +45 -0
- package/src/components/dropdown-menu/dropdown-menu.tsx +230 -0
- package/src/components/dropdown-menu/index.ts +19 -0
- package/src/components/file-upload/file-upload.styles.ts +43 -0
- package/src/components/file-upload/file-upload.tsx +343 -0
- package/src/components/file-upload/index.ts +10 -0
- package/src/components/grid/grid.tsx +144 -0
- package/src/components/grid/index.ts +1 -0
- package/src/components/hover-card/hover-card.tsx +41 -0
- package/src/components/hover-card/index.ts +1 -0
- package/src/components/input-otp/index.ts +10 -0
- package/src/components/input-otp/input-otp.styles.ts +38 -0
- package/src/components/input-otp/input-otp.tsx +116 -0
- package/src/components/link/index.ts +2 -0
- package/src/components/link/link.styles.ts +39 -0
- package/src/components/link/link.tsx +62 -0
- package/src/components/modal/index.ts +2 -0
- package/src/components/modal/modal.styles.ts +18 -0
- package/src/components/modal/modal.tsx +86 -0
- package/src/components/navigation-menu/index.ts +14 -0
- package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
- package/src/components/navigation-menu/navigation-menu.tsx +198 -0
- package/src/components/number-input/index.ts +2 -0
- package/src/components/number-input/number-input.styles.ts +41 -0
- package/src/components/number-input/number-input.tsx +211 -0
- package/src/components/pagination/index.ts +3 -0
- package/src/components/pagination/pagination-range.ts +35 -0
- package/src/components/pagination/pagination.styles.ts +41 -0
- package/src/components/pagination/pagination.tsx +130 -0
- package/src/components/password-input/index.ts +7 -0
- package/src/components/password-input/password-input.styles.ts +30 -0
- package/src/components/password-input/password-input.tsx +127 -0
- package/src/components/popover/index.ts +9 -0
- package/src/components/popover/popover.tsx +66 -0
- package/src/components/progress/index.ts +7 -0
- package/src/components/progress/progress.styles.ts +68 -0
- package/src/components/progress/progress.tsx +158 -0
- package/src/components/prose/index.ts +1 -0
- package/src/components/prose/prose.tsx +82 -0
- package/src/components/radio-group/index.ts +7 -0
- package/src/components/radio-group/radio-group.styles.ts +64 -0
- package/src/components/radio-group/radio-group.tsx +128 -0
- package/src/components/resizable/index.ts +9 -0
- package/src/components/resizable/resizable.styles.ts +28 -0
- package/src/components/resizable/resizable.tsx +69 -0
- package/src/components/scroll-area/index.ts +1 -0
- package/src/components/scroll-area/scroll-area.tsx +68 -0
- package/src/components/sheet/index.ts +13 -0
- package/src/components/sheet/sheet.styles.ts +60 -0
- package/src/components/sheet/sheet.tsx +108 -0
- package/src/components/slider/index.ts +2 -0
- package/src/components/slider/slider.styles.ts +32 -0
- package/src/components/slider/slider.tsx +131 -0
- package/src/components/stat-card/index.ts +2 -0
- package/src/components/stat-card/stat-card.styles.ts +24 -0
- package/src/components/stat-card/stat-card.tsx +108 -0
- package/src/components/stepper/index.ts +2 -0
- package/src/components/stepper/stepper.styles.ts +74 -0
- package/src/components/stepper/stepper.tsx +138 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.styles.ts +39 -0
- package/src/components/tag-input/tag-input.tsx +198 -0
- package/src/components/timeline/index.ts +8 -0
- package/src/components/timeline/timeline.styles.ts +56 -0
- package/src/components/timeline/timeline.tsx +91 -0
- package/src/components/toast/index.ts +1 -0
- package/src/components/toast/toast.tsx +91 -0
- package/src/components/toggle/index.ts +9 -0
- package/src/components/toggle/toggle.styles.ts +39 -0
- package/src/components/toggle/toggle.tsx +61 -0
- package/src/components/tree-view/index.ts +2 -0
- package/src/components/tree-view/tree-view.styles.ts +42 -0
- package/src/components/tree-view/tree-view.tsx +274 -0
- package/src/components/visually-hidden/index.ts +1 -0
- package/src/components/visually-hidden/visually-hidden.tsx +10 -0
- package/src/index.ts +45 -0
- package/src/patterns/chart/chart-parts.tsx +171 -0
- package/src/patterns/chart/chart.styles.ts +35 -0
- package/src/patterns/chart/chart.tsx +369 -0
- package/src/patterns/chart/index.ts +13 -0
- package/src/provider/glyph-provider.tsx +16 -2
- package/styles.css +16 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
2
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
3
|
+
|
|
4
|
+
export const statCardStyles = tv({
|
|
5
|
+
slots: {
|
|
6
|
+
root: 'gap-2',
|
|
7
|
+
header: 'flex items-center justify-between gap-2',
|
|
8
|
+
label: 'text-small text-muted-foreground',
|
|
9
|
+
icon: 'flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground [&>svg]:size-4',
|
|
10
|
+
valueRow: 'flex flex-wrap items-baseline gap-x-2 gap-y-1',
|
|
11
|
+
value: 'font-display text-h2 tabular-nums text-foreground',
|
|
12
|
+
delta: 'gap-1 [&>svg]:size-3',
|
|
13
|
+
description: 'text-small text-faint-foreground',
|
|
14
|
+
chart: 'mt-1 min-w-0 text-primary',
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export type StatCardVariants = VariantProps<typeof statCardStyles>
|
|
19
|
+
|
|
20
|
+
declare module '@glyphui/tokens' {
|
|
21
|
+
interface GlyphComponentRegistry {
|
|
22
|
+
StatCard: StylesEntry<typeof statCardStyles>
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { Badge, type BadgeProps } from '../badge'
|
|
4
|
+
import { Card, type CardProps } from '../card'
|
|
5
|
+
import { statCardStyles } from './stat-card.styles'
|
|
6
|
+
|
|
7
|
+
type Trend = 'up' | 'down' | 'neutral'
|
|
8
|
+
|
|
9
|
+
export interface StatCardProps extends Omit<CardProps, 'children'> {
|
|
10
|
+
label: ReactNode
|
|
11
|
+
value: ReactNode
|
|
12
|
+
/** Change versus the previous period, e.g. "+12.4%". */
|
|
13
|
+
delta?: ReactNode
|
|
14
|
+
/** Direction of the change. Colors the delta: up is good unless `invertTrend` is set. */
|
|
15
|
+
trend?: Trend
|
|
16
|
+
/** For metrics where going down is good (churn, latency, cost). */
|
|
17
|
+
invertTrend?: boolean
|
|
18
|
+
icon?: ReactNode
|
|
19
|
+
description?: ReactNode
|
|
20
|
+
/** A sparkline or small chart under the value. Colored with the primary color by default. */
|
|
21
|
+
chart?: ReactNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ArrowUp = () => (
|
|
25
|
+
<svg
|
|
26
|
+
viewBox="0 0 24 24"
|
|
27
|
+
fill="none"
|
|
28
|
+
stroke="currentColor"
|
|
29
|
+
strokeWidth="2.5"
|
|
30
|
+
strokeLinecap="round"
|
|
31
|
+
strokeLinejoin="round"
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
>
|
|
34
|
+
<path d="M7 17 17 7M8 7h9v9" />
|
|
35
|
+
</svg>
|
|
36
|
+
)
|
|
37
|
+
const ArrowDown = () => (
|
|
38
|
+
<svg
|
|
39
|
+
viewBox="0 0 24 24"
|
|
40
|
+
fill="none"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
strokeWidth="2.5"
|
|
43
|
+
strokeLinecap="round"
|
|
44
|
+
strokeLinejoin="round"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
>
|
|
47
|
+
<path d="M7 7l10 10M17 8v9H8" />
|
|
48
|
+
</svg>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
function deltaTone(trend: Trend, invert: boolean): BadgeProps['tone'] {
|
|
52
|
+
if (trend === 'neutral') return 'neutral'
|
|
53
|
+
const good = (trend === 'up') !== invert
|
|
54
|
+
return good ? 'success' : 'danger'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A key metric with its label, change versus the last period, and an optional chart. */
|
|
58
|
+
export function StatCard(inProps: StatCardProps) {
|
|
59
|
+
const [props, slots] = useComponentConfig('StatCard', inProps)
|
|
60
|
+
const {
|
|
61
|
+
label,
|
|
62
|
+
value,
|
|
63
|
+
delta,
|
|
64
|
+
trend = 'neutral',
|
|
65
|
+
invertTrend = false,
|
|
66
|
+
icon,
|
|
67
|
+
description,
|
|
68
|
+
chart,
|
|
69
|
+
className,
|
|
70
|
+
...cardProps
|
|
71
|
+
} = props
|
|
72
|
+
const s = statCardStyles()
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Card
|
|
76
|
+
data-slot="stat-card"
|
|
77
|
+
className={s.root({ class: [slots.root, className] })}
|
|
78
|
+
{...cardProps}
|
|
79
|
+
>
|
|
80
|
+
<div className={s.header({ class: slots.header })}>
|
|
81
|
+
<p className={s.label({ class: slots.label })}>{label}</p>
|
|
82
|
+
{icon && (
|
|
83
|
+
<span className={s.icon({ class: slots.icon })} aria-hidden="true">
|
|
84
|
+
{icon}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
<div className={s.valueRow({ class: slots.valueRow })}>
|
|
89
|
+
<p data-slot="stat-card-value" className={s.value({ class: slots.value })}>
|
|
90
|
+
{value}
|
|
91
|
+
</p>
|
|
92
|
+
{delta !== undefined && (
|
|
93
|
+
<Badge
|
|
94
|
+
data-trend={trend}
|
|
95
|
+
tone={deltaTone(trend, invertTrend)}
|
|
96
|
+
className={s.delta({ class: slots.delta })}
|
|
97
|
+
>
|
|
98
|
+
{trend === 'up' && <ArrowUp />}
|
|
99
|
+
{trend === 'down' && <ArrowDown />}
|
|
100
|
+
{delta}
|
|
101
|
+
</Badge>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
{description && <p className={s.description({ class: slots.description })}>{description}</p>}
|
|
105
|
+
{chart && <div className={s.chart({ class: slots.chart })}>{chart}</div>}
|
|
106
|
+
</Card>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -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,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,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
|
+
}
|