@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,127 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { Input, type InputProps } from '../input/input'
|
|
4
|
+
import { passwordInputStyles } from './password-input.styles'
|
|
5
|
+
|
|
6
|
+
export type PasswordStrength = 'weak' | 'fair' | 'strong'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A quick strength estimate from length and character variety. A hint for users,
|
|
10
|
+
* not a security check: enforce real rules on the server.
|
|
11
|
+
*/
|
|
12
|
+
export function passwordStrength(password: string): { score: number; strength: PasswordStrength } {
|
|
13
|
+
let score = 0
|
|
14
|
+
if (password.length >= 8) score++
|
|
15
|
+
if (password.length >= 12) score++
|
|
16
|
+
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++
|
|
17
|
+
if (/\d/.test(password)) score++
|
|
18
|
+
if (/[^A-Za-z0-9]/.test(password)) score++
|
|
19
|
+
const strength: PasswordStrength = score >= 4 ? 'strong' : score >= 3 ? 'fair' : 'weak'
|
|
20
|
+
return { score, strength }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const filled: Record<PasswordStrength, number> = { weak: 1, fair: 2, strong: 3 }
|
|
24
|
+
const barColor: Record<PasswordStrength, string> = {
|
|
25
|
+
weak: 'bg-danger',
|
|
26
|
+
fair: 'bg-warning',
|
|
27
|
+
strong: 'bg-success',
|
|
28
|
+
}
|
|
29
|
+
const labels: Record<PasswordStrength, string> = { weak: 'Weak', fair: 'Fair', strong: 'Strong' }
|
|
30
|
+
|
|
31
|
+
export interface PasswordInputProps extends Omit<InputProps, 'type' | 'suffix'> {
|
|
32
|
+
/** Show a strength meter under the field once the user starts typing. */
|
|
33
|
+
showStrength?: boolean
|
|
34
|
+
/** Start with the password visible. */
|
|
35
|
+
defaultVisible?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function EyeIcon({ off }: { off: boolean }) {
|
|
39
|
+
return (
|
|
40
|
+
<svg
|
|
41
|
+
viewBox="0 0 24 24"
|
|
42
|
+
fill="none"
|
|
43
|
+
stroke="currentColor"
|
|
44
|
+
strokeWidth="2"
|
|
45
|
+
strokeLinecap="round"
|
|
46
|
+
strokeLinejoin="round"
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
>
|
|
49
|
+
{off ? (
|
|
50
|
+
<>
|
|
51
|
+
<path d="M10.7 5.1A10.4 10.4 0 0 1 12 5c7 0 10 7 10 7a13 13 0 0 1-1.7 2.7M6.6 6.6A13.5 13.5 0 0 0 2 12s3 7 10 7a9.7 9.7 0 0 0 5.4-1.6" />
|
|
52
|
+
<path d="M9.9 9.9a3 3 0 1 0 4.2 4.2M2 2l20 20" />
|
|
53
|
+
</>
|
|
54
|
+
) : (
|
|
55
|
+
<>
|
|
56
|
+
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12Z" />
|
|
57
|
+
<circle cx="12" cy="12" r="3" />
|
|
58
|
+
</>
|
|
59
|
+
)}
|
|
60
|
+
</svg>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A password field with a show/hide toggle and an optional strength meter. */
|
|
65
|
+
export function PasswordInput(inProps: PasswordInputProps) {
|
|
66
|
+
const [props, slots] = useComponentConfig('PasswordInput', inProps)
|
|
67
|
+
const {
|
|
68
|
+
showStrength = false,
|
|
69
|
+
defaultVisible = false,
|
|
70
|
+
value,
|
|
71
|
+
defaultValue,
|
|
72
|
+
onChange,
|
|
73
|
+
className,
|
|
74
|
+
...rest
|
|
75
|
+
} = props
|
|
76
|
+
const [visible, setVisible] = useState(defaultVisible)
|
|
77
|
+
const [typed, setTyped] = useState(String(defaultValue ?? ''))
|
|
78
|
+
const current = value !== undefined ? String(value) : typed
|
|
79
|
+
const { strength } = passwordStrength(current)
|
|
80
|
+
const s = passwordInputStyles({ strength })
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div data-slot="password-input" className={s.root({ class: [slots.root, className] })}>
|
|
84
|
+
<Input
|
|
85
|
+
{...rest}
|
|
86
|
+
{...(value !== undefined && { value })}
|
|
87
|
+
{...(defaultValue !== undefined && { defaultValue })}
|
|
88
|
+
type={visible ? 'text' : 'password'}
|
|
89
|
+
autoComplete={rest.autoComplete ?? 'current-password'}
|
|
90
|
+
spellCheck={false}
|
|
91
|
+
onChange={(event) => {
|
|
92
|
+
setTyped(event.target.value)
|
|
93
|
+
onChange?.(event)
|
|
94
|
+
}}
|
|
95
|
+
suffix={
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
aria-label={visible ? 'Hide password' : 'Show password'}
|
|
99
|
+
aria-pressed={visible}
|
|
100
|
+
disabled={rest.disabled}
|
|
101
|
+
className={s.toggle({ class: slots.toggle })}
|
|
102
|
+
onClick={() => setVisible((v) => !v)}
|
|
103
|
+
>
|
|
104
|
+
<EyeIcon off={visible} />
|
|
105
|
+
</button>
|
|
106
|
+
}
|
|
107
|
+
/>
|
|
108
|
+
{showStrength && current.length > 0 && (
|
|
109
|
+
<div className={s.meter({ class: slots.meter })}>
|
|
110
|
+
<div className={s.bars({ class: slots.bars })} aria-hidden="true">
|
|
111
|
+
{[1, 2, 3].map((n) => (
|
|
112
|
+
<span
|
|
113
|
+
key={n}
|
|
114
|
+
className={s.bar({
|
|
115
|
+
class: [slots.bar, n <= filled[strength] && barColor[strength]],
|
|
116
|
+
})}
|
|
117
|
+
/>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
<span className={s.label({ class: slots.label })} aria-live="polite">
|
|
121
|
+
{labels[strength]}
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
@@ -20,7 +20,7 @@ export const proseStyles = tv({
|
|
|
20
20
|
// Text
|
|
21
21
|
'[&_p]:my-[1em] [&_p]:text-pretty',
|
|
22
22
|
'[&_strong]:font-semibold [&_strong]:text-foreground',
|
|
23
|
-
'[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:decoration-primary/40 [&_a]:underline-offset-4 [&_a:hover]:decoration-primary',
|
|
23
|
+
'[&_a]:font-medium [&_a]:text-primary-text [&_a]:underline [&_a]:decoration-primary/40 [&_a]:underline-offset-4 [&_a:hover]:decoration-primary',
|
|
24
24
|
// Lists
|
|
25
25
|
'[&_ul]:my-[1em] [&_ul]:list-disc [&_ul]:pl-[1.5em] [&_ol]:my-[1em] [&_ol]:list-decimal [&_ol]:pl-[1.5em]',
|
|
26
26
|
'[&_li]:my-[0.35em] [&_li]:pl-[0.25em] [&_li::marker]:text-faint-foreground',
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
|
|
2
|
-
import type
|
|
2
|
+
import { type ComponentProps, useEffect, useRef, useState } from 'react'
|
|
3
3
|
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
4
|
import type { StylesEntry } from '../../utils/registry'
|
|
5
5
|
import { focusRing } from '../../utils/styles'
|
|
@@ -31,6 +31,24 @@ export interface ScrollAreaProps extends ComponentProps<typeof ScrollAreaPrimiti
|
|
|
31
31
|
viewportClassName?: string
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** True while the element's content overflows it, so keyboard users need to reach it. */
|
|
35
|
+
function useOverflowing<E extends HTMLElement>() {
|
|
36
|
+
const ref = useRef<E>(null)
|
|
37
|
+
const [overflowing, setOverflowing] = useState(false)
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const el = ref.current
|
|
40
|
+
if (!el) return
|
|
41
|
+
const measure = () =>
|
|
42
|
+
setOverflowing(el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth)
|
|
43
|
+
measure()
|
|
44
|
+
const observer = new ResizeObserver(measure)
|
|
45
|
+
observer.observe(el)
|
|
46
|
+
if (el.firstElementChild) observer.observe(el.firstElementChild)
|
|
47
|
+
return () => observer.disconnect()
|
|
48
|
+
}, [])
|
|
49
|
+
return [ref, overflowing] as const
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
/**
|
|
35
53
|
* A scrollable region with slim, theme-colored scrollbars. Give it a height (or width)
|
|
36
54
|
* with `className`, e.g. `h-72`.
|
|
@@ -39,6 +57,7 @@ export function ScrollArea(inProps: ScrollAreaProps) {
|
|
|
39
57
|
const [props, slots] = useComponentConfig('ScrollArea', inProps)
|
|
40
58
|
const { orientation = 'vertical', viewportClassName, className, children, ...rest } = props
|
|
41
59
|
const s = scrollAreaStyles()
|
|
60
|
+
const [viewportRef, overflowing] = useOverflowing<HTMLDivElement>()
|
|
42
61
|
const bar = (o: 'vertical' | 'horizontal') => (
|
|
43
62
|
<ScrollAreaPrimitive.Scrollbar
|
|
44
63
|
data-slot="scroll-area-scrollbar"
|
|
@@ -55,7 +74,10 @@ export function ScrollArea(inProps: ScrollAreaProps) {
|
|
|
55
74
|
{...rest}
|
|
56
75
|
>
|
|
57
76
|
<ScrollAreaPrimitive.Viewport
|
|
77
|
+
ref={viewportRef}
|
|
58
78
|
data-slot="scroll-area-viewport"
|
|
79
|
+
// Scrollable regions must be reachable by keyboard to scroll with arrow keys.
|
|
80
|
+
tabIndex={overflowing ? 0 : undefined}
|
|
59
81
|
className={s.viewport({ class: [slots.viewport, viewportClassName] })}
|
|
60
82
|
>
|
|
61
83
|
{children}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ToggleGroup } from 'radix-ui'
|
|
2
|
-
import type
|
|
2
|
+
import { type ReactNode, useContext } from 'react'
|
|
3
3
|
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { warnOnce } from '../../internal/dev'
|
|
4
5
|
import { focusRing, themedBorder, transition } from '../../utils/styles'
|
|
5
6
|
import { tv, type VariantProps } from '../../utils/tv'
|
|
7
|
+
import { FieldContext } from '../field/field-context'
|
|
6
8
|
|
|
7
9
|
export const segmentedControlStyles = tv({
|
|
8
10
|
slots: {
|
|
@@ -40,7 +42,9 @@ export interface SegmentedControlProps<T extends string>
|
|
|
40
42
|
value?: T
|
|
41
43
|
defaultValue?: T
|
|
42
44
|
onValueChange?: (value: T) => void
|
|
43
|
-
|
|
45
|
+
/** Required unless the control sits in a `<Field label>`, which labels it. */
|
|
46
|
+
'aria-label'?: string
|
|
47
|
+
'aria-labelledby'?: string
|
|
44
48
|
className?: string
|
|
45
49
|
id?: string
|
|
46
50
|
}
|
|
@@ -52,7 +56,16 @@ export interface SegmentedControlProps<T extends string>
|
|
|
52
56
|
export function SegmentedControl<T extends string>(inProps: SegmentedControlProps<T>) {
|
|
53
57
|
const [props, slots] = useComponentConfig('SegmentedControl', inProps)
|
|
54
58
|
const { options, value, defaultValue, onValueChange, size, fullWidth, className, ...rest } = props
|
|
59
|
+
const field = useContext(FieldContext)
|
|
55
60
|
const s = segmentedControlStyles({ size, fullWidth })
|
|
61
|
+
const labelledBy = rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : field?.labelId)
|
|
62
|
+
if (!rest['aria-label'] && !labelledBy) {
|
|
63
|
+
warnOnce(
|
|
64
|
+
'segmented-control-label',
|
|
65
|
+
'SegmentedControl needs a label: pass aria-label, or place it in a <Field label="…">.',
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
const describedBy = [field?.descriptionId, field?.errorId].filter(Boolean).join(' ')
|
|
56
69
|
return (
|
|
57
70
|
<ToggleGroup.Root
|
|
58
71
|
type="single"
|
|
@@ -62,6 +75,8 @@ export function SegmentedControl<T extends string>(inProps: SegmentedControlProp
|
|
|
62
75
|
// Radix emits '' when the active item is clicked again; a segmented control never deselects.
|
|
63
76
|
onValueChange={(next: string) => next && onValueChange?.(next as T)}
|
|
64
77
|
className={s.root({ class: [slots.root, className] })}
|
|
78
|
+
aria-labelledby={labelledBy}
|
|
79
|
+
aria-describedby={describedBy || undefined}
|
|
65
80
|
{...rest}
|
|
66
81
|
>
|
|
67
82
|
{options.map((option) => (
|
|
@@ -4,10 +4,10 @@ import { tv, type VariantProps } from '../../utils/tv'
|
|
|
4
4
|
|
|
5
5
|
export const sheetStyles = tv({
|
|
6
6
|
slots: {
|
|
7
|
-
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
|
-
//
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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: {} },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Slider as SliderPrimitive } from 'radix-ui'
|
|
2
|
-
import type
|
|
2
|
+
import { type ReactNode, useContext } from 'react'
|
|
3
3
|
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
-
import { useFieldControl } from '../field/field-context'
|
|
4
|
+
import { FieldContext, useFieldControl } from '../field/field-context'
|
|
5
5
|
import { sliderStyles } from './slider.styles'
|
|
6
6
|
|
|
7
7
|
export type SliderValue = number | readonly [number, number]
|
|
@@ -31,7 +31,9 @@ export interface SliderProps<V extends SliderValue> {
|
|
|
31
31
|
name?: string
|
|
32
32
|
id?: string
|
|
33
33
|
className?: string
|
|
34
|
+
/** Accessible name. Inside a `Field`, the field's label is used when this is not set. */
|
|
34
35
|
'aria-label'?: string
|
|
36
|
+
'aria-labelledby'?: string
|
|
35
37
|
'aria-describedby'?: string
|
|
36
38
|
'aria-invalid'?: boolean
|
|
37
39
|
/** Accessible names for range thumbs. Default: "Minimum", "Maximum". */
|
|
@@ -61,9 +63,13 @@ export function Slider<V extends SliderValue = number>(inProps: SliderProps<V>)
|
|
|
61
63
|
className,
|
|
62
64
|
thumbLabels = ['Minimum', 'Maximum'],
|
|
63
65
|
'aria-label': ariaLabel,
|
|
66
|
+
'aria-labelledby': ariaLabelledBy,
|
|
64
67
|
'aria-describedby': ariaDescribedBy,
|
|
65
68
|
'aria-invalid': ariaInvalid,
|
|
66
69
|
} = props
|
|
70
|
+
const field = useContext(FieldContext)
|
|
71
|
+
// Thumbs are not labelable elements, so a Field's <label for> can't name them.
|
|
72
|
+
const labelledBy = ariaLabelledBy ?? (ariaLabel ? undefined : field?.labelId)
|
|
67
73
|
const isRange = Array.isArray(value ?? defaultValue)
|
|
68
74
|
const fromArray = (next: number[]) => (isRange ? [next[0], next[1]] : next[0]) as V
|
|
69
75
|
const current = toArray(value) ?? toArray(defaultValue) ?? [min]
|
|
@@ -97,6 +103,7 @@ export function Slider<V extends SliderValue = number>(inProps: SliderProps<V>)
|
|
|
97
103
|
key={index}
|
|
98
104
|
id={index === 0 ? id : undefined}
|
|
99
105
|
aria-label={isRange ? thumbLabels[index] : ariaLabel}
|
|
106
|
+
aria-labelledby={isRange ? undefined : labelledBy}
|
|
100
107
|
aria-describedby={ariaDescribedBy}
|
|
101
108
|
aria-invalid={ariaInvalid}
|
|
102
109
|
className={s.thumb({ class: slots.thumb })}
|
|
@@ -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-text 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-text',
|
|
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
|
+
}
|