@glyphui/ui 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/dist/data-table.js +2 -2
  3. package/dist/{field-DXZD0l8w.js → field-CCecIzdq.js} +1 -1
  4. package/dist/{field-context-B8cYheXz.js → field-context-B6WOcK5m.js} +99 -99
  5. package/dist/form.js +2 -2
  6. package/dist/index.d.ts +2743 -93
  7. package/dist/index.js +2748 -111
  8. package/dist/{states-BxPjCUxz.js → states-Rp8wZ_jH.js} +1 -1
  9. package/package.json +4 -2
  10. package/src/components/accordion/accordion.styles.ts +41 -0
  11. package/src/components/accordion/accordion.tsx +86 -0
  12. package/src/components/accordion/index.ts +8 -0
  13. package/src/components/alert/alert-icons.tsx +66 -0
  14. package/src/components/alert/alert.styles.ts +57 -0
  15. package/src/components/alert/alert.tsx +86 -0
  16. package/src/components/alert/index.ts +2 -0
  17. package/src/components/alert-dialog/alert-dialog.styles.ts +24 -0
  18. package/src/components/alert-dialog/alert-dialog.tsx +118 -0
  19. package/src/components/alert-dialog/confirm.tsx +111 -0
  20. package/src/components/alert-dialog/index.ts +15 -0
  21. package/src/components/aspect-ratio/aspect-ratio.tsx +40 -0
  22. package/src/components/aspect-ratio/index.ts +1 -0
  23. package/src/components/badge/badge.styles.ts +1 -1
  24. package/src/components/banner/banner.styles.ts +40 -0
  25. package/src/components/banner/banner.tsx +101 -0
  26. package/src/components/banner/index.ts +2 -0
  27. package/src/components/blockquote/blockquote.tsx +59 -0
  28. package/src/components/blockquote/index.ts +1 -0
  29. package/src/components/breadcrumb/breadcrumb.styles.ts +20 -0
  30. package/src/components/breadcrumb/breadcrumb.tsx +135 -0
  31. package/src/components/breadcrumb/index.ts +12 -0
  32. package/src/components/button/button.tsx +11 -8
  33. package/src/components/code/code.styles.ts +33 -0
  34. package/src/components/code/code.tsx +96 -0
  35. package/src/components/code/index.ts +2 -0
  36. package/src/components/combobox/combobox.styles.ts +50 -0
  37. package/src/components/combobox/combobox.tsx +203 -0
  38. package/src/components/combobox/index.ts +2 -0
  39. package/src/components/command-menu/command-menu.styles.ts +41 -0
  40. package/src/components/command-menu/command-menu.tsx +217 -0
  41. package/src/components/command-menu/index.ts +14 -0
  42. package/src/components/description-list/description-list.styles.ts +31 -0
  43. package/src/components/description-list/description-list.tsx +56 -0
  44. package/src/components/description-list/index.ts +7 -0
  45. package/src/components/dialog/close-icon.tsx +22 -0
  46. package/src/components/dialog/dialog.styles.ts +41 -0
  47. package/src/components/dialog/dialog.tsx +111 -0
  48. package/src/components/dialog/index.ts +13 -0
  49. package/src/components/dropdown-menu/dropdown-menu.styles.ts +45 -0
  50. package/src/components/dropdown-menu/dropdown-menu.tsx +230 -0
  51. package/src/components/dropdown-menu/index.ts +19 -0
  52. package/src/components/grid/grid.tsx +144 -0
  53. package/src/components/grid/index.ts +1 -0
  54. package/src/components/link/index.ts +2 -0
  55. package/src/components/link/link.styles.ts +39 -0
  56. package/src/components/link/link.tsx +62 -0
  57. package/src/components/pagination/index.ts +3 -0
  58. package/src/components/pagination/pagination-range.ts +35 -0
  59. package/src/components/pagination/pagination.styles.ts +41 -0
  60. package/src/components/pagination/pagination.tsx +130 -0
  61. package/src/components/popover/index.ts +9 -0
  62. package/src/components/popover/popover.tsx +66 -0
  63. package/src/components/progress/index.ts +7 -0
  64. package/src/components/progress/progress.styles.ts +68 -0
  65. package/src/components/progress/progress.tsx +158 -0
  66. package/src/components/prose/index.ts +1 -0
  67. package/src/components/prose/prose.tsx +82 -0
  68. package/src/components/radio-group/index.ts +7 -0
  69. package/src/components/radio-group/radio-group.styles.ts +64 -0
  70. package/src/components/radio-group/radio-group.tsx +128 -0
  71. package/src/components/scroll-area/index.ts +1 -0
  72. package/src/components/scroll-area/scroll-area.tsx +68 -0
  73. package/src/components/sheet/index.ts +13 -0
  74. package/src/components/sheet/sheet.styles.ts +60 -0
  75. package/src/components/sheet/sheet.tsx +108 -0
  76. package/src/components/slider/index.ts +2 -0
  77. package/src/components/slider/slider.styles.ts +32 -0
  78. package/src/components/slider/slider.tsx +131 -0
  79. package/src/components/stat-card/index.ts +2 -0
  80. package/src/components/stat-card/stat-card.styles.ts +24 -0
  81. package/src/components/stat-card/stat-card.tsx +108 -0
  82. package/src/components/toast/index.ts +1 -0
  83. package/src/components/toast/toast.tsx +91 -0
  84. package/src/index.ts +25 -0
  85. package/src/provider/glyph-provider.tsx +16 -2
@@ -0,0 +1,128 @@
1
+ import { RadioGroup as RadioGroupPrimitive } from 'radix-ui'
2
+ import { type ComponentProps, createContext, type ReactNode, useContext, useId } from 'react'
3
+ import { type SlotClasses, useComponentConfig } from '../../hooks/use-component-config'
4
+ import { useFieldControl } from '../field/field-context'
5
+ import { type RadioGroupVariants, radioGroupStyles } from './radio-group.styles'
6
+
7
+ type RootProps = Omit<
8
+ ComponentProps<typeof RadioGroupPrimitive.Root>,
9
+ 'value' | 'defaultValue' | 'onValueChange' | 'orientation'
10
+ >
11
+
12
+ export interface RadioGroupProps<T extends string> extends RootProps, RadioGroupVariants {
13
+ value?: T
14
+ defaultValue?: T
15
+ /** Receives your value type, not `string`. */
16
+ onValueChange?: (value: T) => void
17
+ }
18
+
19
+ interface RadioGroupContextValue {
20
+ s: ReturnType<typeof radioGroupStyles>
21
+ slots: SlotClasses<'RadioGroup'>
22
+ variant: NonNullable<RadioGroupVariants['variant']>
23
+ }
24
+
25
+ const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
26
+
27
+ function useRadioGroupContext(): RadioGroupContextValue {
28
+ return useContext(RadioGroupContext) ?? { s: radioGroupStyles(), slots: {}, variant: 'default' }
29
+ }
30
+
31
+ /**
32
+ * Pick exactly one option from a short visible list. `variant="card"` turns each option
33
+ * into a bordered card with a title and description.
34
+ */
35
+ export function RadioGroup<T extends string>(inProps: RadioGroupProps<T>) {
36
+ const [props, slots] = useComponentConfig('RadioGroup', useFieldControl(inProps))
37
+ const { variant = 'default', orientation = 'vertical', onValueChange, className, ...rest } = props
38
+ const s = radioGroupStyles({ variant, orientation })
39
+ return (
40
+ <RadioGroupContext.Provider value={{ s, slots, variant }}>
41
+ <RadioGroupPrimitive.Root
42
+ data-slot="radio-group"
43
+ data-variant={variant}
44
+ orientation={orientation}
45
+ {...(onValueChange && { onValueChange: (value: string) => onValueChange(value as T) })}
46
+ className={s.root({ class: [slots.root, className] })}
47
+ {...rest}
48
+ />
49
+ </RadioGroupContext.Provider>
50
+ )
51
+ }
52
+
53
+ export interface RadioGroupItemProps
54
+ extends Omit<ComponentProps<typeof RadioGroupPrimitive.Item>, 'children'> {
55
+ /** Visible label. For card items this is the card title. */
56
+ label?: ReactNode
57
+ description?: ReactNode
58
+ /** Extra content inside a card item, below the description. */
59
+ children?: ReactNode
60
+ }
61
+
62
+ export function RadioGroupItem({
63
+ label,
64
+ description,
65
+ children,
66
+ className,
67
+ id,
68
+ ...props
69
+ }: RadioGroupItemProps) {
70
+ const { s, slots, variant } = useRadioGroupContext()
71
+ const generated = useId()
72
+ const itemId = id ?? generated
73
+ const descriptionId = description ? `${itemId}-description` : undefined
74
+
75
+ if (variant === 'card') {
76
+ return (
77
+ <RadioGroupPrimitive.Item
78
+ id={itemId}
79
+ data-slot="radio-group-card"
80
+ aria-describedby={descriptionId}
81
+ className={s.card({ class: [slots.card, className] })}
82
+ {...props}
83
+ >
84
+ <span className={s.cardControl({ class: slots.cardControl })} aria-hidden="true">
85
+ <RadioGroupPrimitive.Indicator className={s.indicator({ class: slots.indicator })} />
86
+ </span>
87
+ <span className={s.text({ class: slots.text })}>
88
+ {label && <span className={s.label({ class: slots.label })}>{label}</span>}
89
+ {description && (
90
+ <span id={descriptionId} className={s.description({ class: slots.description })}>
91
+ {description}
92
+ </span>
93
+ )}
94
+ {children}
95
+ </span>
96
+ </RadioGroupPrimitive.Item>
97
+ )
98
+ }
99
+
100
+ const control = (
101
+ <RadioGroupPrimitive.Item
102
+ id={itemId}
103
+ data-slot="radio-group-item"
104
+ aria-describedby={descriptionId}
105
+ className={s.control({ class: [slots.control, !label && className] })}
106
+ {...props}
107
+ >
108
+ <RadioGroupPrimitive.Indicator className={s.indicator({ class: slots.indicator })} />
109
+ </RadioGroupPrimitive.Item>
110
+ )
111
+ if (!label) return control
112
+
113
+ return (
114
+ <div data-slot="radio-group-option" className={s.option({ class: [slots.option, className] })}>
115
+ {control}
116
+ <span className={s.text({ class: slots.text })}>
117
+ <label htmlFor={itemId} className={s.label({ class: slots.label })}>
118
+ {label}
119
+ </label>
120
+ {description && (
121
+ <span id={descriptionId} className={s.description({ class: slots.description })}>
122
+ {description}
123
+ </span>
124
+ )}
125
+ </span>
126
+ </div>
127
+ )
128
+ }
@@ -0,0 +1 @@
1
+ export { ScrollArea, type ScrollAreaProps, scrollAreaStyles } from './scroll-area'
@@ -0,0 +1,68 @@
1
+ import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
2
+ import type { ComponentProps } from 'react'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import type { StylesEntry } from '../../utils/registry'
5
+ import { focusRing } from '../../utils/styles'
6
+ import { tv } from '../../utils/tv'
7
+
8
+ export const scrollAreaStyles = tv({
9
+ slots: {
10
+ root: 'relative overflow-hidden',
11
+ viewport: ['size-full rounded-[inherit]', focusRing],
12
+ scrollbar: [
13
+ 'flex touch-none select-none p-px transition-colors duration-(--glyph-duration-fast)',
14
+ 'data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2.5 data-[orientation=vertical]:border-l data-[orientation=vertical]:border-l-transparent',
15
+ 'data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent',
16
+ ],
17
+ thumb: 'relative flex-1 rounded-pill bg-border-strong hover:bg-faint-foreground',
18
+ corner: 'bg-transparent',
19
+ },
20
+ })
21
+
22
+ declare module '@glyphui/tokens' {
23
+ interface GlyphComponentRegistry {
24
+ ScrollArea: StylesEntry<typeof scrollAreaStyles>
25
+ }
26
+ }
27
+
28
+ export interface ScrollAreaProps extends ComponentProps<typeof ScrollAreaPrimitive.Root> {
29
+ /** Which scrollbars to show. Default vertical. */
30
+ orientation?: 'vertical' | 'horizontal' | 'both'
31
+ viewportClassName?: string
32
+ }
33
+
34
+ /**
35
+ * A scrollable region with slim, theme-colored scrollbars. Give it a height (or width)
36
+ * with `className`, e.g. `h-72`.
37
+ */
38
+ export function ScrollArea(inProps: ScrollAreaProps) {
39
+ const [props, slots] = useComponentConfig('ScrollArea', inProps)
40
+ const { orientation = 'vertical', viewportClassName, className, children, ...rest } = props
41
+ const s = scrollAreaStyles()
42
+ const bar = (o: 'vertical' | 'horizontal') => (
43
+ <ScrollAreaPrimitive.Scrollbar
44
+ data-slot="scroll-area-scrollbar"
45
+ orientation={o}
46
+ className={s.scrollbar({ class: slots.scrollbar })}
47
+ >
48
+ <ScrollAreaPrimitive.Thumb className={s.thumb({ class: slots.thumb })} />
49
+ </ScrollAreaPrimitive.Scrollbar>
50
+ )
51
+ return (
52
+ <ScrollAreaPrimitive.Root
53
+ data-slot="scroll-area"
54
+ className={s.root({ class: [slots.root, className] })}
55
+ {...rest}
56
+ >
57
+ <ScrollAreaPrimitive.Viewport
58
+ data-slot="scroll-area-viewport"
59
+ className={s.viewport({ class: [slots.viewport, viewportClassName] })}
60
+ >
61
+ {children}
62
+ </ScrollAreaPrimitive.Viewport>
63
+ {orientation !== 'horizontal' && bar('vertical')}
64
+ {orientation !== 'vertical' && bar('horizontal')}
65
+ <ScrollAreaPrimitive.Corner className={s.corner({ class: slots.corner })} />
66
+ </ScrollAreaPrimitive.Root>
67
+ )
68
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ Sheet,
3
+ SheetBody,
4
+ SheetClose,
5
+ SheetContent,
6
+ type SheetContentProps,
7
+ SheetDescription,
8
+ SheetFooter,
9
+ SheetHeader,
10
+ SheetTitle,
11
+ SheetTrigger,
12
+ } from './sheet'
13
+ export { type SheetVariants, sheetStyles } from './sheet.styles'
@@ -0,0 +1,60 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { focusRing, transition } from '../../utils/styles'
3
+ import { tv, type VariantProps } from '../../utils/tv'
4
+
5
+ export const sheetStyles = tv({
6
+ slots: {
7
+ overlay: 'fixed inset-0 z-50 animate-fade-in bg-overlay',
8
+ content: [
9
+ '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
+ ],
12
+ close: [
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',
14
+ transition,
15
+ focusRing,
16
+ ],
17
+ header: 'flex flex-col gap-1.5 pr-8',
18
+ title: 'font-display text-h4 text-foreground',
19
+ description: 'text-small text-muted-foreground',
20
+ body: '-mx-6 min-h-0 flex-1 overflow-y-auto px-6 text-small',
21
+ footer: 'mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
22
+ },
23
+ variants: {
24
+ // Slide in from the edge using @starting-style (Tailwind's `starting:` variant).
25
+ side: {
26
+ right: {
27
+ content:
28
+ 'inset-y-0 right-0 h-full w-full border-l-(length:--glyph-border-width) starting:translate-x-full',
29
+ },
30
+ left: {
31
+ content:
32
+ 'inset-y-0 left-0 h-full w-full border-r-(length:--glyph-border-width) starting:-translate-x-full',
33
+ },
34
+ top: {
35
+ content:
36
+ 'inset-x-0 top-0 max-h-[85dvh] border-b-(length:--glyph-border-width) starting:-translate-y-full',
37
+ },
38
+ bottom: {
39
+ content:
40
+ 'inset-x-0 bottom-0 max-h-[85dvh] rounded-t-xl border-t-(length:--glyph-border-width) starting:translate-y-full',
41
+ },
42
+ },
43
+ size: { sm: {}, md: {}, lg: {}, xl: {} },
44
+ },
45
+ compoundVariants: [
46
+ { side: ['left', 'right'], size: 'sm', class: { content: 'sm:max-w-sm' } },
47
+ { side: ['left', 'right'], size: 'md', class: { content: 'sm:max-w-md' } },
48
+ { side: ['left', 'right'], size: 'lg', class: { content: 'sm:max-w-xl' } },
49
+ { side: ['left', 'right'], size: 'xl', class: { content: 'sm:max-w-3xl' } },
50
+ ],
51
+ defaultVariants: { side: 'right', size: 'md' },
52
+ })
53
+
54
+ export type SheetVariants = VariantProps<typeof sheetStyles>
55
+
56
+ declare module '@glyphui/tokens' {
57
+ interface GlyphComponentRegistry {
58
+ Sheet: StylesEntry<typeof sheetStyles>
59
+ }
60
+ }
@@ -0,0 +1,108 @@
1
+ import { Dialog as DialogPrimitive } from 'radix-ui'
2
+ import type { ComponentProps } from 'react'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import { CloseIcon } from '../dialog/close-icon'
5
+ import { type SheetVariants, sheetStyles } from './sheet.styles'
6
+
7
+ function useParts() {
8
+ const [, slots] = useComponentConfig('Sheet', {})
9
+ return { s: sheetStyles(), slots }
10
+ }
11
+
12
+ /** A panel that slides in from an edge: filters, details, settings, mobile navigation. */
13
+ export const Sheet = DialogPrimitive.Root
14
+ export const SheetTrigger = DialogPrimitive.Trigger
15
+ export const SheetClose = DialogPrimitive.Close
16
+
17
+ export interface SheetContentProps
18
+ extends ComponentProps<typeof DialogPrimitive.Content>,
19
+ SheetVariants {
20
+ /** Show the close button in the corner. Default true. */
21
+ showClose?: boolean
22
+ overlayClassName?: string
23
+ }
24
+
25
+ export function SheetContent(inProps: SheetContentProps) {
26
+ const [props, slots] = useComponentConfig('Sheet', inProps)
27
+ const { side, size, showClose = true, overlayClassName, className, children, ...rest } = props
28
+ const s = sheetStyles({ side, size })
29
+ return (
30
+ <DialogPrimitive.Portal>
31
+ <DialogPrimitive.Overlay
32
+ data-slot="sheet-overlay"
33
+ className={s.overlay({ class: [slots.overlay, overlayClassName] })}
34
+ />
35
+ <DialogPrimitive.Content
36
+ data-slot="sheet-content"
37
+ data-side={side ?? 'right'}
38
+ className={s.content({ class: [slots.content, className] })}
39
+ {...rest}
40
+ >
41
+ {children}
42
+ {showClose && (
43
+ <DialogPrimitive.Close
44
+ data-slot="sheet-close"
45
+ aria-label="Close"
46
+ className={s.close({ class: slots.close })}
47
+ >
48
+ <CloseIcon />
49
+ </DialogPrimitive.Close>
50
+ )}
51
+ </DialogPrimitive.Content>
52
+ </DialogPrimitive.Portal>
53
+ )
54
+ }
55
+
56
+ export function SheetHeader({ className, ...props }: ComponentProps<'div'>) {
57
+ const { s, slots } = useParts()
58
+ return (
59
+ <div
60
+ data-slot="sheet-header"
61
+ className={s.header({ class: [slots.header, className] })}
62
+ {...props}
63
+ />
64
+ )
65
+ }
66
+
67
+ export function SheetTitle({ className, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
68
+ const { s, slots } = useParts()
69
+ return (
70
+ <DialogPrimitive.Title
71
+ data-slot="sheet-title"
72
+ className={s.title({ class: [slots.title, className] })}
73
+ {...props}
74
+ />
75
+ )
76
+ }
77
+
78
+ export function SheetDescription({
79
+ className,
80
+ ...props
81
+ }: ComponentProps<typeof DialogPrimitive.Description>) {
82
+ const { s, slots } = useParts()
83
+ return (
84
+ <DialogPrimitive.Description
85
+ data-slot="sheet-description"
86
+ className={s.description({ class: [slots.description, className] })}
87
+ {...props}
88
+ />
89
+ )
90
+ }
91
+
92
+ export function SheetBody({ className, ...props }: ComponentProps<'div'>) {
93
+ const { s, slots } = useParts()
94
+ return (
95
+ <div data-slot="sheet-body" className={s.body({ class: [slots.body, className] })} {...props} />
96
+ )
97
+ }
98
+
99
+ export function SheetFooter({ className, ...props }: ComponentProps<'div'>) {
100
+ const { s, slots } = useParts()
101
+ return (
102
+ <div
103
+ data-slot="sheet-footer"
104
+ className={s.footer({ class: [slots.footer, className] })}
105
+ {...props}
106
+ />
107
+ )
108
+ }
@@ -0,0 +1,2 @@
1
+ export { Slider, type SliderMark, type SliderProps, type SliderValue } from './slider'
2
+ export { type SliderVariants, sliderStyles } from './slider.styles'
@@ -0,0 +1,32 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { tv, type VariantProps } from '../../utils/tv'
3
+
4
+ export const sliderStyles = tv({
5
+ slots: {
6
+ wrapper: 'grid w-full gap-1.5',
7
+ root: 'relative flex h-5 w-full touch-none select-none items-center data-disabled:cursor-not-allowed data-disabled:opacity-50',
8
+ track: 'relative h-1.5 w-full grow overflow-hidden rounded-pill bg-muted',
9
+ range: 'absolute h-full bg-primary',
10
+ thumb: [
11
+ 'relative block size-4 cursor-grab rounded-pill border-2 border-primary bg-background shadow-sm outline-none active:cursor-grabbing',
12
+ 'transition-[box-shadow] duration-(--glyph-duration-fast) ease-standard',
13
+ 'hover:ring-4 hover:ring-ring/15 focus-visible:ring-4 focus-visible:ring-ring/30',
14
+ 'data-disabled:cursor-not-allowed',
15
+ ],
16
+ value:
17
+ 'pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-xs bg-secondary px-1.5 py-0.5 font-mono text-caption text-secondary-foreground tabular-nums',
18
+ marks: 'relative h-4 w-full',
19
+ mark: 'absolute top-0 -translate-x-1/2 text-caption text-faint-foreground tabular-nums',
20
+ },
21
+ variants: {
22
+ showValue: { true: { wrapper: 'pt-7' } },
23
+ },
24
+ })
25
+
26
+ export type SliderVariants = VariantProps<typeof sliderStyles>
27
+
28
+ declare module '@glyphui/tokens' {
29
+ interface GlyphComponentRegistry {
30
+ Slider: StylesEntry<typeof sliderStyles>
31
+ }
32
+ }
@@ -0,0 +1,131 @@
1
+ import { Slider as SliderPrimitive } from 'radix-ui'
2
+ import type { ReactNode } from 'react'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import { useFieldControl } from '../field/field-context'
5
+ import { sliderStyles } from './slider.styles'
6
+
7
+ export type SliderValue = number | readonly [number, number]
8
+
9
+ export interface SliderMark {
10
+ value: number
11
+ label?: ReactNode
12
+ }
13
+
14
+ export interface SliderProps<V extends SliderValue> {
15
+ /** A number for one thumb, a `[min, max]` tuple for a range. */
16
+ value?: V
17
+ defaultValue?: V
18
+ onValueChange?: (value: V) => void
19
+ /** Fires once when the user releases the thumb. Use it for expensive updates. */
20
+ onValueCommit?: (value: V) => void
21
+ min?: number
22
+ max?: number
23
+ step?: number
24
+ /** Minimum distance between range thumbs, in steps. */
25
+ minStepsBetweenThumbs?: number
26
+ /** Labels shown under the track at given values. */
27
+ marks?: readonly (number | SliderMark)[]
28
+ /** Show the current value above each thumb. Pass a function to format it. */
29
+ showValue?: boolean | ((value: number) => ReactNode)
30
+ disabled?: boolean
31
+ name?: string
32
+ id?: string
33
+ className?: string
34
+ 'aria-label'?: string
35
+ 'aria-describedby'?: string
36
+ 'aria-invalid'?: boolean
37
+ /** Accessible names for range thumbs. Default: "Minimum", "Maximum". */
38
+ thumbLabels?: readonly [string, string]
39
+ }
40
+
41
+ const toArray = (value: SliderValue | undefined): number[] | undefined =>
42
+ value === undefined ? undefined : typeof value === 'number' ? [value] : [...value]
43
+
44
+ /** Choose a number, or a range with two thumbs, by dragging or with arrow keys. */
45
+ export function Slider<V extends SliderValue = number>(inProps: SliderProps<V>) {
46
+ const [props, slots] = useComponentConfig('Slider', useFieldControl(inProps))
47
+ const {
48
+ value,
49
+ defaultValue,
50
+ onValueChange,
51
+ onValueCommit,
52
+ min = 0,
53
+ max = 100,
54
+ step = 1,
55
+ minStepsBetweenThumbs,
56
+ marks,
57
+ showValue = false,
58
+ disabled,
59
+ name,
60
+ id,
61
+ className,
62
+ thumbLabels = ['Minimum', 'Maximum'],
63
+ 'aria-label': ariaLabel,
64
+ 'aria-describedby': ariaDescribedBy,
65
+ 'aria-invalid': ariaInvalid,
66
+ } = props
67
+ const isRange = Array.isArray(value ?? defaultValue)
68
+ const fromArray = (next: number[]) => (isRange ? [next[0], next[1]] : next[0]) as V
69
+ const current = toArray(value) ?? toArray(defaultValue) ?? [min]
70
+ const s = sliderStyles({ showValue: Boolean(showValue) })
71
+ const format = typeof showValue === 'function' ? showValue : (n: number) => n
72
+
73
+ return (
74
+ <div data-slot="slider" className={s.wrapper({ class: [slots.wrapper, className] })}>
75
+ <SliderPrimitive.Root
76
+ className={s.root({ class: slots.root })}
77
+ min={min}
78
+ max={max}
79
+ step={step}
80
+ disabled={disabled}
81
+ name={name}
82
+ {...(minStepsBetweenThumbs !== undefined && { minStepsBetweenThumbs })}
83
+ {...(value !== undefined && { value: toArray(value) })}
84
+ {...(defaultValue !== undefined && { defaultValue: toArray(defaultValue) })}
85
+ {...(onValueChange && {
86
+ onValueChange: (next: number[]) => onValueChange(fromArray(next)),
87
+ })}
88
+ {...(onValueCommit && {
89
+ onValueCommit: (next: number[]) => onValueCommit(fromArray(next)),
90
+ })}
91
+ >
92
+ <SliderPrimitive.Track className={s.track({ class: slots.track })}>
93
+ <SliderPrimitive.Range className={s.range({ class: slots.range })} />
94
+ </SliderPrimitive.Track>
95
+ {current.map((thumbValue, index) => (
96
+ <SliderPrimitive.Thumb
97
+ key={index}
98
+ id={index === 0 ? id : undefined}
99
+ aria-label={isRange ? thumbLabels[index] : ariaLabel}
100
+ aria-describedby={ariaDescribedBy}
101
+ aria-invalid={ariaInvalid}
102
+ className={s.thumb({ class: slots.thumb })}
103
+ >
104
+ {showValue && (
105
+ <span className={s.value({ class: slots.value })} aria-hidden="true">
106
+ {format(thumbValue)}
107
+ </span>
108
+ )}
109
+ </SliderPrimitive.Thumb>
110
+ ))}
111
+ </SliderPrimitive.Root>
112
+ {marks && marks.length > 0 && (
113
+ <div className={s.marks({ class: slots.marks })} aria-hidden="true">
114
+ {marks.map((mark) => {
115
+ const m = typeof mark === 'number' ? { value: mark } : mark
116
+ const percent = ((m.value - min) / (max - min || 1)) * 100
117
+ return (
118
+ <span
119
+ key={m.value}
120
+ className={s.mark({ class: slots.mark })}
121
+ style={{ left: `${percent}%` }}
122
+ >
123
+ {m.label ?? m.value}
124
+ </span>
125
+ )
126
+ })}
127
+ </div>
128
+ )}
129
+ </div>
130
+ )
131
+ }
@@ -0,0 +1,2 @@
1
+ export { StatCard, type StatCardProps } from './stat-card'
2
+ export { type StatCardVariants, statCardStyles } from './stat-card.styles'
@@ -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
+ }