@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,117 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
4
|
+
import { tv } from '../../utils/tv'
|
|
5
|
+
import { Button, type ButtonProps } from '../button'
|
|
6
|
+
|
|
7
|
+
export const copyButtonStyles = tv({
|
|
8
|
+
slots: {
|
|
9
|
+
base: '',
|
|
10
|
+
icon: 'size-4',
|
|
11
|
+
status: 'sr-only',
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
declare module '@glyphui/tokens' {
|
|
16
|
+
interface GlyphComponentRegistry {
|
|
17
|
+
CopyButton: StylesEntry<typeof copyButtonStyles>
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const icon = {
|
|
22
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
23
|
+
viewBox: '0 0 24 24',
|
|
24
|
+
fill: 'none',
|
|
25
|
+
stroke: 'currentColor',
|
|
26
|
+
strokeWidth: 2,
|
|
27
|
+
strokeLinecap: 'round',
|
|
28
|
+
strokeLinejoin: 'round',
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
const CopyIcon = ({ className }: { className?: string }) => (
|
|
32
|
+
<svg aria-hidden="true" {...icon} className={className}>
|
|
33
|
+
<rect width="13" height="13" x="9" y="9" rx="2" />
|
|
34
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
35
|
+
</svg>
|
|
36
|
+
)
|
|
37
|
+
const CheckIcon = ({ className }: { className?: string }) => (
|
|
38
|
+
<svg aria-hidden="true" {...icon} className={className}>
|
|
39
|
+
<path d="M20 6 9 17l-5-5" />
|
|
40
|
+
</svg>
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
export interface CopyButtonProps
|
|
44
|
+
extends Omit<
|
|
45
|
+
ButtonProps,
|
|
46
|
+
'onClick' | 'onCopy' | 'leftIcon' | 'rightIcon' | 'iconOnly' | 'asChild' | 'value'
|
|
47
|
+
> {
|
|
48
|
+
/** Text written to the clipboard. */
|
|
49
|
+
value: string
|
|
50
|
+
/** Visible label. Without it the button is icon-only, labelled "Copy". */
|
|
51
|
+
children?: ReactNode
|
|
52
|
+
/** Announced and shown after copying. Default "Copied". */
|
|
53
|
+
copiedLabel?: string
|
|
54
|
+
/** How long the copied state lasts, in ms. Default 1500. */
|
|
55
|
+
timeout?: number
|
|
56
|
+
onCopy?: (value: string) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Copies a value to the clipboard and confirms it visually and to screen readers. */
|
|
60
|
+
export function CopyButton(inProps: CopyButtonProps) {
|
|
61
|
+
const [props, slots] = useComponentConfig('CopyButton', inProps)
|
|
62
|
+
const {
|
|
63
|
+
value,
|
|
64
|
+
children,
|
|
65
|
+
copiedLabel = 'Copied',
|
|
66
|
+
timeout = 1500,
|
|
67
|
+
onCopy,
|
|
68
|
+
variant = 'outline',
|
|
69
|
+
className,
|
|
70
|
+
'aria-label': ariaLabel,
|
|
71
|
+
...rest
|
|
72
|
+
} = props
|
|
73
|
+
const [copied, setCopied] = useState(false)
|
|
74
|
+
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
75
|
+
const s = copyButtonStyles()
|
|
76
|
+
|
|
77
|
+
useEffect(() => () => clearTimeout(timer.current), [])
|
|
78
|
+
|
|
79
|
+
const copy = async () => {
|
|
80
|
+
try {
|
|
81
|
+
await navigator.clipboard.writeText(value)
|
|
82
|
+
} catch {
|
|
83
|
+
// Clipboard blocked (permissions, insecure context): leave the button as it was.
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
setCopied(true)
|
|
87
|
+
onCopy?.(value)
|
|
88
|
+
clearTimeout(timer.current)
|
|
89
|
+
timer.current = setTimeout(() => setCopied(false), timeout)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const glyph = copied ? (
|
|
93
|
+
<CheckIcon className={s.icon({ class: slots.icon })} />
|
|
94
|
+
) : (
|
|
95
|
+
<CopyIcon className={s.icon({ class: slots.icon })} />
|
|
96
|
+
)
|
|
97
|
+
const iconOnly = children === undefined
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Button
|
|
101
|
+
data-slot="copy-button"
|
|
102
|
+
data-copied={copied || undefined}
|
|
103
|
+
variant={variant}
|
|
104
|
+
iconOnly={iconOnly}
|
|
105
|
+
onClick={copy}
|
|
106
|
+
className={s.base({ class: [slots.base, className] })}
|
|
107
|
+
aria-label={iconOnly ? (copied ? copiedLabel : (ariaLabel ?? 'Copy')) : ariaLabel}
|
|
108
|
+
{...(!iconOnly && { leftIcon: glyph })}
|
|
109
|
+
{...rest}
|
|
110
|
+
>
|
|
111
|
+
{iconOnly ? glyph : copied ? copiedLabel : children}
|
|
112
|
+
<span role="status" className={s.status({ class: slots.status })}>
|
|
113
|
+
{copied ? copiedLabel : ''}
|
|
114
|
+
</span>
|
|
115
|
+
</Button>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CopyButton, type CopyButtonProps, copyButtonStyles } from './copy-button'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
2
|
+
import { popover } from '../../utils/styles'
|
|
3
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
4
|
+
import { fieldControl } from '../input/input.styles'
|
|
5
|
+
|
|
6
|
+
export const datePickerStyles = tv({
|
|
7
|
+
slots: {
|
|
8
|
+
trigger: [
|
|
9
|
+
...fieldControl,
|
|
10
|
+
'flex cursor-pointer items-center gap-2 px-3 text-left outline-none',
|
|
11
|
+
'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20',
|
|
12
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
13
|
+
],
|
|
14
|
+
icon: 'size-4 shrink-0 text-faint-foreground',
|
|
15
|
+
value: 'min-w-0 flex-1 truncate',
|
|
16
|
+
placeholder: 'min-w-0 flex-1 truncate text-faint-foreground',
|
|
17
|
+
clear:
|
|
18
|
+
'inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-xs text-faint-foreground hover:bg-muted hover:text-foreground [&>svg]:size-3.5',
|
|
19
|
+
content: [popover, 'w-auto p-0'],
|
|
20
|
+
layout: 'flex flex-col sm:flex-row',
|
|
21
|
+
presets:
|
|
22
|
+
'flex gap-1 overflow-x-auto border-b border-border p-2 sm:w-40 sm:flex-col sm:overflow-visible sm:border-r sm:border-b-0',
|
|
23
|
+
preset:
|
|
24
|
+
'shrink-0 cursor-pointer rounded-sm px-2 py-1.5 text-left text-small whitespace-nowrap text-muted-foreground hover:bg-muted hover:text-foreground data-[active=true]:bg-muted data-[active=true]:text-foreground',
|
|
25
|
+
},
|
|
26
|
+
variants: {
|
|
27
|
+
size: {
|
|
28
|
+
sm: { trigger: 'h-(--glyph-control-sm) text-small' },
|
|
29
|
+
md: { trigger: 'h-(--glyph-control-md) text-small' },
|
|
30
|
+
lg: { trigger: 'h-(--glyph-control-lg) text-body' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: { size: 'md' },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export type DatePickerVariants = VariantProps<typeof datePickerStyles>
|
|
37
|
+
|
|
38
|
+
declare module '@glyphui/tokens' {
|
|
39
|
+
interface GlyphComponentRegistry {
|
|
40
|
+
DatePicker: StylesEntry<typeof datePickerStyles>
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { format as formatDate, isSameDay } from 'date-fns'
|
|
2
|
+
import { type ReactNode, useState } from 'react'
|
|
3
|
+
import type { DateRange, Matcher } from 'react-day-picker'
|
|
4
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
5
|
+
import { useControllableState } from '../../hooks/use-controllable-state'
|
|
6
|
+
import { Calendar } from '../calendar/calendar'
|
|
7
|
+
import { useFieldControl } from '../field/field-context'
|
|
8
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../popover'
|
|
9
|
+
import { type DatePickerVariants, datePickerStyles } from './date-picker.styles'
|
|
10
|
+
|
|
11
|
+
function CalendarIcon({ className }: { className?: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<svg
|
|
14
|
+
viewBox="0 0 24 24"
|
|
15
|
+
fill="none"
|
|
16
|
+
stroke="currentColor"
|
|
17
|
+
strokeWidth="2"
|
|
18
|
+
strokeLinecap="round"
|
|
19
|
+
strokeLinejoin="round"
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
className={className}
|
|
22
|
+
>
|
|
23
|
+
<rect x="3" y="4" width="18" height="18" rx="2" />
|
|
24
|
+
<path d="M16 2v4M8 2v4M3 10h18" />
|
|
25
|
+
</svg>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ClearIcon() {
|
|
30
|
+
return (
|
|
31
|
+
<svg
|
|
32
|
+
viewBox="0 0 24 24"
|
|
33
|
+
fill="none"
|
|
34
|
+
stroke="currentColor"
|
|
35
|
+
strokeWidth="2"
|
|
36
|
+
strokeLinecap="round"
|
|
37
|
+
aria-hidden="true"
|
|
38
|
+
>
|
|
39
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
40
|
+
</svg>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SharedProps extends DatePickerVariants {
|
|
45
|
+
placeholder?: ReactNode
|
|
46
|
+
/** date-fns format string for the trigger. Default `"MMM d, yyyy"`. */
|
|
47
|
+
format?: string
|
|
48
|
+
/** Earliest selectable date. */
|
|
49
|
+
minDate?: Date
|
|
50
|
+
/** Latest selectable date. */
|
|
51
|
+
maxDate?: Date
|
|
52
|
+
/** Extra disabled dates, in react-day-picker matcher form. */
|
|
53
|
+
disabledDates?: Matcher | Matcher[]
|
|
54
|
+
/** Show a button that clears the value. */
|
|
55
|
+
clearable?: boolean
|
|
56
|
+
disabled?: boolean
|
|
57
|
+
required?: boolean
|
|
58
|
+
/** Submits the ISO date (yyyy-MM-dd) with a native form. */
|
|
59
|
+
name?: string
|
|
60
|
+
id?: string
|
|
61
|
+
className?: string
|
|
62
|
+
contentClassName?: string
|
|
63
|
+
'aria-label'?: string
|
|
64
|
+
'aria-describedby'?: string
|
|
65
|
+
'aria-invalid'?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function disabledMatchers({ minDate, maxDate, disabledDates }: SharedProps): Matcher[] {
|
|
69
|
+
const list: Matcher[] = []
|
|
70
|
+
if (minDate) list.push({ before: minDate })
|
|
71
|
+
if (maxDate) list.push({ after: maxDate })
|
|
72
|
+
if (disabledDates) list.push(...(Array.isArray(disabledDates) ? disabledDates : [disabledDates]))
|
|
73
|
+
return list
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const iso = (date: Date) => formatDate(date, 'yyyy-MM-dd')
|
|
77
|
+
|
|
78
|
+
export interface DatePickerProps extends SharedProps {
|
|
79
|
+
value?: Date | undefined
|
|
80
|
+
defaultValue?: Date | undefined
|
|
81
|
+
onValueChange?: (date: Date | undefined) => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A date field that opens a calendar. Works with Field and with `Form` (`value` + `onValueChange`). */
|
|
85
|
+
export function DatePicker(inProps: DatePickerProps) {
|
|
86
|
+
const [props, slots] = useComponentConfig('DatePicker', useFieldControl(inProps))
|
|
87
|
+
const {
|
|
88
|
+
value: valueProp,
|
|
89
|
+
defaultValue,
|
|
90
|
+
onValueChange,
|
|
91
|
+
placeholder = 'Pick a date',
|
|
92
|
+
format = 'MMM d, yyyy',
|
|
93
|
+
clearable = false,
|
|
94
|
+
size,
|
|
95
|
+
disabled,
|
|
96
|
+
name,
|
|
97
|
+
id,
|
|
98
|
+
className,
|
|
99
|
+
contentClassName,
|
|
100
|
+
'aria-label': ariaLabel,
|
|
101
|
+
'aria-describedby': ariaDescribedBy,
|
|
102
|
+
'aria-invalid': ariaInvalid,
|
|
103
|
+
} = props
|
|
104
|
+
const [open, setOpen] = useState(false)
|
|
105
|
+
const [value, setValue] = useControllableState<Date | undefined>({
|
|
106
|
+
value: valueProp,
|
|
107
|
+
defaultValue,
|
|
108
|
+
onChange: onValueChange,
|
|
109
|
+
})
|
|
110
|
+
// Controlled `undefined` means "no date", not "uncontrolled".
|
|
111
|
+
const current = 'value' in inProps ? valueProp : value
|
|
112
|
+
const s = datePickerStyles({ size })
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
116
|
+
<PopoverTrigger asChild>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
id={id}
|
|
120
|
+
data-slot="date-picker"
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
aria-label={ariaLabel}
|
|
123
|
+
aria-describedby={ariaDescribedBy}
|
|
124
|
+
aria-invalid={ariaInvalid}
|
|
125
|
+
className={s.trigger({ class: [slots.trigger, className] })}
|
|
126
|
+
>
|
|
127
|
+
<CalendarIcon className={s.icon({ class: slots.icon })} />
|
|
128
|
+
{current ? (
|
|
129
|
+
<span className={s.value({ class: slots.value })}>{formatDate(current, format)}</span>
|
|
130
|
+
) : (
|
|
131
|
+
<span className={s.placeholder({ class: slots.placeholder })}>{placeholder}</span>
|
|
132
|
+
)}
|
|
133
|
+
{clearable && current && !disabled && (
|
|
134
|
+
// biome-ignore lint/a11y/useSemanticElements: nested inside the trigger button, so it can't be a <button>
|
|
135
|
+
<span
|
|
136
|
+
role="button"
|
|
137
|
+
tabIndex={0}
|
|
138
|
+
aria-label="Clear date"
|
|
139
|
+
className={s.clear({ class: slots.clear })}
|
|
140
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
141
|
+
onClick={(event) => {
|
|
142
|
+
event.stopPropagation()
|
|
143
|
+
setValue(undefined)
|
|
144
|
+
}}
|
|
145
|
+
onKeyDown={(event) => {
|
|
146
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
147
|
+
event.preventDefault()
|
|
148
|
+
event.stopPropagation()
|
|
149
|
+
setValue(undefined)
|
|
150
|
+
}
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<ClearIcon />
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
</button>
|
|
157
|
+
</PopoverTrigger>
|
|
158
|
+
<PopoverContent
|
|
159
|
+
align="start"
|
|
160
|
+
className={s.content({ class: [slots.content, contentClassName] })}
|
|
161
|
+
>
|
|
162
|
+
<Calendar
|
|
163
|
+
mode="single"
|
|
164
|
+
selected={current}
|
|
165
|
+
defaultMonth={current}
|
|
166
|
+
disabled={disabledMatchers(props)}
|
|
167
|
+
onSelect={(date) => {
|
|
168
|
+
setValue(date)
|
|
169
|
+
if (date) setOpen(false)
|
|
170
|
+
}}
|
|
171
|
+
autoFocus
|
|
172
|
+
/>
|
|
173
|
+
</PopoverContent>
|
|
174
|
+
{name && <input type="hidden" name={name} value={current ? iso(current) : ''} />}
|
|
175
|
+
</Popover>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface DateRangePreset {
|
|
180
|
+
label: string
|
|
181
|
+
/** Called when chosen, so relative ranges ("Last 7 days") are computed at that moment. */
|
|
182
|
+
range: () => DateRange
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface DateRangePickerProps extends SharedProps {
|
|
186
|
+
value?: DateRange | undefined
|
|
187
|
+
defaultValue?: DateRange | undefined
|
|
188
|
+
onValueChange?: (range: DateRange | undefined) => void
|
|
189
|
+
/** Quick picks shown beside the calendar. */
|
|
190
|
+
presets?: readonly DateRangePreset[]
|
|
191
|
+
/** Months shown side by side. Default 2. */
|
|
192
|
+
numberOfMonths?: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function formatRange(range: DateRange, format: string): string {
|
|
196
|
+
if (!range.from) return ''
|
|
197
|
+
if (!range.to || isSameDay(range.from, range.to)) return formatDate(range.from, format)
|
|
198
|
+
return `${formatDate(range.from, format)} – ${formatDate(range.to, format)}`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A date-range field with a two-month calendar and optional presets. */
|
|
202
|
+
export function DateRangePicker(inProps: DateRangePickerProps) {
|
|
203
|
+
const [props, slots] = useComponentConfig('DatePicker', useFieldControl(inProps))
|
|
204
|
+
const {
|
|
205
|
+
value: valueProp,
|
|
206
|
+
defaultValue,
|
|
207
|
+
onValueChange,
|
|
208
|
+
presets,
|
|
209
|
+
numberOfMonths = 2,
|
|
210
|
+
placeholder = 'Pick a date range',
|
|
211
|
+
format = 'MMM d, yyyy',
|
|
212
|
+
clearable = false,
|
|
213
|
+
size,
|
|
214
|
+
disabled,
|
|
215
|
+
name,
|
|
216
|
+
id,
|
|
217
|
+
className,
|
|
218
|
+
contentClassName,
|
|
219
|
+
'aria-label': ariaLabel,
|
|
220
|
+
'aria-describedby': ariaDescribedBy,
|
|
221
|
+
'aria-invalid': ariaInvalid,
|
|
222
|
+
} = props
|
|
223
|
+
const [open, setOpen] = useState(false)
|
|
224
|
+
const [value, setValue] = useControllableState<DateRange | undefined>({
|
|
225
|
+
value: valueProp,
|
|
226
|
+
defaultValue,
|
|
227
|
+
onChange: onValueChange,
|
|
228
|
+
})
|
|
229
|
+
const current = 'value' in inProps ? valueProp : value
|
|
230
|
+
const s = datePickerStyles({ size })
|
|
231
|
+
const label = current ? formatRange(current, format) : ''
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
235
|
+
<PopoverTrigger asChild>
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
id={id}
|
|
239
|
+
data-slot="date-range-picker"
|
|
240
|
+
disabled={disabled}
|
|
241
|
+
aria-label={ariaLabel}
|
|
242
|
+
aria-describedby={ariaDescribedBy}
|
|
243
|
+
aria-invalid={ariaInvalid}
|
|
244
|
+
className={s.trigger({ class: [slots.trigger, className] })}
|
|
245
|
+
>
|
|
246
|
+
<CalendarIcon className={s.icon({ class: slots.icon })} />
|
|
247
|
+
{label ? (
|
|
248
|
+
<span className={s.value({ class: slots.value })}>{label}</span>
|
|
249
|
+
) : (
|
|
250
|
+
<span className={s.placeholder({ class: slots.placeholder })}>{placeholder}</span>
|
|
251
|
+
)}
|
|
252
|
+
{clearable && current?.from && !disabled && (
|
|
253
|
+
// biome-ignore lint/a11y/useSemanticElements: nested inside the trigger button, so it can't be a <button>
|
|
254
|
+
<span
|
|
255
|
+
role="button"
|
|
256
|
+
tabIndex={0}
|
|
257
|
+
aria-label="Clear dates"
|
|
258
|
+
className={s.clear({ class: slots.clear })}
|
|
259
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
260
|
+
onClick={(event) => {
|
|
261
|
+
event.stopPropagation()
|
|
262
|
+
setValue(undefined)
|
|
263
|
+
}}
|
|
264
|
+
onKeyDown={(event) => {
|
|
265
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
266
|
+
event.preventDefault()
|
|
267
|
+
event.stopPropagation()
|
|
268
|
+
setValue(undefined)
|
|
269
|
+
}
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
<ClearIcon />
|
|
273
|
+
</span>
|
|
274
|
+
)}
|
|
275
|
+
</button>
|
|
276
|
+
</PopoverTrigger>
|
|
277
|
+
<PopoverContent
|
|
278
|
+
align="start"
|
|
279
|
+
className={s.content({ class: [slots.content, contentClassName] })}
|
|
280
|
+
>
|
|
281
|
+
<div className={s.layout({ class: slots.layout })}>
|
|
282
|
+
{presets && presets.length > 0 && (
|
|
283
|
+
<div className={s.presets({ class: slots.presets })}>
|
|
284
|
+
{presets.map((preset) => (
|
|
285
|
+
<button
|
|
286
|
+
key={preset.label}
|
|
287
|
+
type="button"
|
|
288
|
+
className={s.preset({ class: slots.preset })}
|
|
289
|
+
onClick={() => {
|
|
290
|
+
setValue(preset.range())
|
|
291
|
+
setOpen(false)
|
|
292
|
+
}}
|
|
293
|
+
>
|
|
294
|
+
{preset.label}
|
|
295
|
+
</button>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
<Calendar
|
|
300
|
+
mode="range"
|
|
301
|
+
selected={current}
|
|
302
|
+
defaultMonth={current?.from}
|
|
303
|
+
numberOfMonths={numberOfMonths}
|
|
304
|
+
disabled={disabledMatchers(props)}
|
|
305
|
+
onSelect={(range) => setValue(range)}
|
|
306
|
+
autoFocus
|
|
307
|
+
/>
|
|
308
|
+
</div>
|
|
309
|
+
</PopoverContent>
|
|
310
|
+
{name && (
|
|
311
|
+
<>
|
|
312
|
+
<input
|
|
313
|
+
type="hidden"
|
|
314
|
+
name={`${name}.from`}
|
|
315
|
+
value={current?.from ? iso(current.from) : ''}
|
|
316
|
+
/>
|
|
317
|
+
<input type="hidden" name={`${name}.to`} value={current?.to ? iso(current.to) : ''} />
|
|
318
|
+
</>
|
|
319
|
+
)}
|
|
320
|
+
</Popover>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
@@ -27,8 +27,8 @@ export const dropdownMenuStyles = tv({
|
|
|
27
27
|
tone: {
|
|
28
28
|
default: {},
|
|
29
29
|
danger: {
|
|
30
|
-
item: 'text-danger data-highlighted:bg-danger/10',
|
|
31
|
-
icon: 'text-danger',
|
|
30
|
+
item: 'text-danger-text data-highlighted:bg-danger/10',
|
|
31
|
+
icon: 'text-danger-text',
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
34
|
inset: { true: { item: 'pl-8', label: 'pl-8' } },
|
|
@@ -2,6 +2,8 @@ import { createContext, useContext } from 'react'
|
|
|
2
2
|
|
|
3
3
|
export interface FieldContextValue {
|
|
4
4
|
id: string
|
|
5
|
+
/** Id of the visible label, for controls labelled by reference (aria-labelledby). */
|
|
6
|
+
labelId: string | undefined
|
|
5
7
|
descriptionId: string | undefined
|
|
6
8
|
errorId: string | undefined
|
|
7
9
|
invalid: boolean
|
|
@@ -4,10 +4,12 @@ export const fieldStyles = tv({
|
|
|
4
4
|
slots: {
|
|
5
5
|
root: 'grid gap-1.5',
|
|
6
6
|
label: '',
|
|
7
|
-
|
|
7
|
+
labelRow: 'flex items-center justify-between gap-2',
|
|
8
|
+
labelAction: 'text-small',
|
|
9
|
+
required: 'text-danger-text',
|
|
8
10
|
text: 'grid gap-0.5',
|
|
9
11
|
description: 'text-small text-faint-foreground',
|
|
10
|
-
error: 'text-small font-medium text-danger',
|
|
12
|
+
error: 'text-small font-medium text-danger-text',
|
|
11
13
|
},
|
|
12
14
|
variants: {
|
|
13
15
|
orientation: {
|
|
@@ -6,6 +6,11 @@ import { FieldContext } from './field-context'
|
|
|
6
6
|
|
|
7
7
|
export interface FieldProps extends Omit<ComponentProps<'div'>, 'children'>, FieldVariants {
|
|
8
8
|
label?: ReactNode
|
|
9
|
+
/**
|
|
10
|
+
* Shown at the end of the label row, outside the label, so it is not part of the
|
|
11
|
+
* control's accessible name. For example a "Forgot password?" link.
|
|
12
|
+
*/
|
|
13
|
+
labelAction?: ReactNode
|
|
9
14
|
description?: ReactNode
|
|
10
15
|
/** Error message. Marks the control invalid and is announced to screen readers. */
|
|
11
16
|
error?: ReactNode
|
|
@@ -27,6 +32,7 @@ export function Field(inProps: FieldProps) {
|
|
|
27
32
|
const [props, slots] = useComponentConfig('Field', inProps)
|
|
28
33
|
const {
|
|
29
34
|
label,
|
|
35
|
+
labelAction,
|
|
30
36
|
description,
|
|
31
37
|
error,
|
|
32
38
|
required = false,
|
|
@@ -40,17 +46,18 @@ export function Field(inProps: FieldProps) {
|
|
|
40
46
|
const generated = useId()
|
|
41
47
|
const id = controlId ?? generated
|
|
42
48
|
const invalid = Boolean(error)
|
|
49
|
+
const labelId = label ? `${id}-label` : undefined
|
|
43
50
|
const descriptionId = description ? `${id}-description` : undefined
|
|
44
51
|
const errorId = invalid ? `${id}-error` : undefined
|
|
45
52
|
const s = fieldStyles({ orientation })
|
|
46
53
|
|
|
47
54
|
const context = useMemo(
|
|
48
|
-
() => ({ id, descriptionId, errorId, invalid, required, disabled }),
|
|
49
|
-
[id, descriptionId, errorId, invalid, required, disabled],
|
|
55
|
+
() => ({ id, labelId, descriptionId, errorId, invalid, required, disabled }),
|
|
56
|
+
[id, labelId, descriptionId, errorId, invalid, required, disabled],
|
|
50
57
|
)
|
|
51
58
|
|
|
52
|
-
const
|
|
53
|
-
<Label htmlFor={id} className={s.label({ class: slots.label })}>
|
|
59
|
+
const labelElement = label ? (
|
|
60
|
+
<Label id={labelId} htmlFor={id} className={s.label({ class: slots.label })}>
|
|
54
61
|
{label}
|
|
55
62
|
{required && (
|
|
56
63
|
<span className={s.required({ class: slots.required })} aria-hidden="true">
|
|
@@ -59,6 +66,20 @@ export function Field(inProps: FieldProps) {
|
|
|
59
66
|
)}
|
|
60
67
|
</Label>
|
|
61
68
|
) : null
|
|
69
|
+
const labelNode =
|
|
70
|
+
labelElement && labelAction ? (
|
|
71
|
+
<div data-slot="field-label-row" className={s.labelRow({ class: slots.labelRow })}>
|
|
72
|
+
{labelElement}
|
|
73
|
+
<span
|
|
74
|
+
data-slot="field-label-action"
|
|
75
|
+
className={s.labelAction({ class: slots.labelAction })}
|
|
76
|
+
>
|
|
77
|
+
{labelAction}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
) : (
|
|
81
|
+
labelElement
|
|
82
|
+
)
|
|
62
83
|
|
|
63
84
|
const messages = (
|
|
64
85
|
<>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
2
|
+
import { focusRing, themedBorder, transition } from '../../utils/styles'
|
|
3
|
+
import { tv } from '../../utils/tv'
|
|
4
|
+
|
|
5
|
+
export const fileUploadStyles = tv({
|
|
6
|
+
slots: {
|
|
7
|
+
root: 'grid gap-3',
|
|
8
|
+
dropzone: [
|
|
9
|
+
'flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-dashed border-border-strong bg-subtle px-6 py-8 text-center',
|
|
10
|
+
themedBorder,
|
|
11
|
+
transition,
|
|
12
|
+
focusRing,
|
|
13
|
+
'hover:border-primary/60 hover:bg-primary/4',
|
|
14
|
+
'data-[dragging=true]:border-primary data-[dragging=true]:bg-primary/8',
|
|
15
|
+
'disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger',
|
|
16
|
+
],
|
|
17
|
+
icon: 'flex size-10 items-center justify-center rounded-md border border-border bg-surface text-muted-foreground shadow-xs [&>svg]:size-5',
|
|
18
|
+
title: 'text-small font-medium text-foreground',
|
|
19
|
+
highlight: 'text-primary-text',
|
|
20
|
+
hint: 'text-caption text-faint-foreground',
|
|
21
|
+
rejections: 'grid gap-1 text-small font-medium text-danger-text',
|
|
22
|
+
list: 'grid gap-2',
|
|
23
|
+
item: [
|
|
24
|
+
'grid grid-cols-[auto_1fr_auto] items-center gap-3 rounded-md border-border bg-surface px-3 py-2.5',
|
|
25
|
+
themedBorder,
|
|
26
|
+
],
|
|
27
|
+
fileIcon:
|
|
28
|
+
'flex size-8 items-center justify-center rounded-sm bg-muted text-muted-foreground [&>svg]:size-4',
|
|
29
|
+
fileText: 'grid min-w-0 gap-1',
|
|
30
|
+
fileName: 'truncate text-small font-medium text-foreground',
|
|
31
|
+
fileMeta: 'text-caption tabular-nums text-faint-foreground',
|
|
32
|
+
fileError: 'text-caption font-medium text-danger-text',
|
|
33
|
+
progress: 'h-1 overflow-hidden rounded-pill bg-muted',
|
|
34
|
+
progressBar:
|
|
35
|
+
'h-full rounded-pill bg-primary transition-[width] duration-(--glyph-duration-base)',
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
declare module '@glyphui/tokens' {
|
|
40
|
+
interface GlyphComponentRegistry {
|
|
41
|
+
FileUpload: StylesEntry<typeof fileUploadStyles>
|
|
42
|
+
}
|
|
43
|
+
}
|