@glyphui/ui 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/chart.d.ts +202 -0
- package/dist/chart.js +457 -0
- package/dist/data-table.js +3 -2
- package/dist/{field-DXZD0l8w.js → field-CWZ_FdkD.js} +2 -1
- package/dist/{field-context-B8cYheXz.js → field-context-anGPYXbW.js} +89 -143
- package/dist/form.js +3 -2
- package/dist/index.d.ts +4960 -266
- package/dist/index.js +5336 -146
- package/dist/{states-BxPjCUxz.js → states-DR-OoDT-.js} +2 -1
- package/dist/tv-DBAGlgXJ.js +58 -0
- package/package.json +19 -2
- package/src/components/accordion/accordion.styles.ts +41 -0
- package/src/components/accordion/accordion.tsx +86 -0
- package/src/components/accordion/index.ts +8 -0
- package/src/components/alert/alert-icons.tsx +66 -0
- package/src/components/alert/alert.styles.ts +57 -0
- package/src/components/alert/alert.tsx +86 -0
- package/src/components/alert/index.ts +2 -0
- package/src/components/alert-dialog/alert-dialog.styles.ts +24 -0
- package/src/components/alert-dialog/alert-dialog.tsx +118 -0
- package/src/components/alert-dialog/confirm.tsx +111 -0
- package/src/components/alert-dialog/index.ts +15 -0
- package/src/components/aspect-ratio/aspect-ratio.tsx +40 -0
- package/src/components/aspect-ratio/index.ts +1 -0
- package/src/components/badge/badge.styles.ts +1 -1
- package/src/components/banner/banner.styles.ts +40 -0
- package/src/components/banner/banner.tsx +101 -0
- package/src/components/banner/index.ts +2 -0
- package/src/components/blockquote/blockquote.tsx +59 -0
- package/src/components/blockquote/index.ts +1 -0
- package/src/components/breadcrumb/breadcrumb.styles.ts +20 -0
- package/src/components/breadcrumb/breadcrumb.tsx +135 -0
- package/src/components/breadcrumb/index.ts +12 -0
- package/src/components/button/button.tsx +11 -8
- package/src/components/calendar/calendar.styles.ts +50 -0
- package/src/components/calendar/calendar.tsx +84 -0
- package/src/components/calendar/index.ts +3 -0
- package/src/components/carousel/carousel.styles.ts +43 -0
- package/src/components/carousel/carousel.tsx +278 -0
- package/src/components/carousel/index.ts +13 -0
- package/src/components/code/code.styles.ts +33 -0
- package/src/components/code/code.tsx +96 -0
- package/src/components/code/index.ts +2 -0
- package/src/components/collapsible/collapsible.tsx +66 -0
- package/src/components/collapsible/index.ts +6 -0
- package/src/components/combobox/combobox.styles.ts +50 -0
- package/src/components/combobox/combobox.tsx +203 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command-menu/command-menu.styles.ts +41 -0
- package/src/components/command-menu/command-menu.tsx +217 -0
- package/src/components/command-menu/index.ts +14 -0
- package/src/components/context-menu/context-menu.styles.ts +19 -0
- package/src/components/context-menu/context-menu.tsx +226 -0
- package/src/components/context-menu/index.ts +19 -0
- package/src/components/copy-button/copy-button.tsx +117 -0
- package/src/components/copy-button/index.ts +1 -0
- package/src/components/date-picker/date-picker.styles.ts +42 -0
- package/src/components/date-picker/date-picker.tsx +322 -0
- package/src/components/date-picker/index.ts +8 -0
- package/src/components/description-list/description-list.styles.ts +31 -0
- package/src/components/description-list/description-list.tsx +56 -0
- package/src/components/description-list/index.ts +7 -0
- package/src/components/dialog/close-icon.tsx +22 -0
- package/src/components/dialog/dialog.styles.ts +41 -0
- package/src/components/dialog/dialog.tsx +111 -0
- package/src/components/dialog/index.ts +13 -0
- package/src/components/dropdown-menu/dropdown-menu.styles.ts +45 -0
- package/src/components/dropdown-menu/dropdown-menu.tsx +230 -0
- package/src/components/dropdown-menu/index.ts +19 -0
- package/src/components/file-upload/file-upload.styles.ts +43 -0
- package/src/components/file-upload/file-upload.tsx +343 -0
- package/src/components/file-upload/index.ts +10 -0
- package/src/components/grid/grid.tsx +144 -0
- package/src/components/grid/index.ts +1 -0
- package/src/components/hover-card/hover-card.tsx +41 -0
- package/src/components/hover-card/index.ts +1 -0
- package/src/components/input-otp/index.ts +10 -0
- package/src/components/input-otp/input-otp.styles.ts +38 -0
- package/src/components/input-otp/input-otp.tsx +116 -0
- package/src/components/link/index.ts +2 -0
- package/src/components/link/link.styles.ts +39 -0
- package/src/components/link/link.tsx +62 -0
- package/src/components/modal/index.ts +2 -0
- package/src/components/modal/modal.styles.ts +18 -0
- package/src/components/modal/modal.tsx +86 -0
- package/src/components/navigation-menu/index.ts +14 -0
- package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
- package/src/components/navigation-menu/navigation-menu.tsx +198 -0
- package/src/components/number-input/index.ts +2 -0
- package/src/components/number-input/number-input.styles.ts +41 -0
- package/src/components/number-input/number-input.tsx +211 -0
- package/src/components/pagination/index.ts +3 -0
- package/src/components/pagination/pagination-range.ts +35 -0
- package/src/components/pagination/pagination.styles.ts +41 -0
- package/src/components/pagination/pagination.tsx +130 -0
- package/src/components/password-input/index.ts +7 -0
- package/src/components/password-input/password-input.styles.ts +30 -0
- package/src/components/password-input/password-input.tsx +127 -0
- package/src/components/popover/index.ts +9 -0
- package/src/components/popover/popover.tsx +66 -0
- package/src/components/progress/index.ts +7 -0
- package/src/components/progress/progress.styles.ts +68 -0
- package/src/components/progress/progress.tsx +158 -0
- package/src/components/prose/index.ts +1 -0
- package/src/components/prose/prose.tsx +82 -0
- package/src/components/radio-group/index.ts +7 -0
- package/src/components/radio-group/radio-group.styles.ts +64 -0
- package/src/components/radio-group/radio-group.tsx +128 -0
- package/src/components/resizable/index.ts +9 -0
- package/src/components/resizable/resizable.styles.ts +28 -0
- package/src/components/resizable/resizable.tsx +69 -0
- package/src/components/scroll-area/index.ts +1 -0
- package/src/components/scroll-area/scroll-area.tsx +68 -0
- package/src/components/sheet/index.ts +13 -0
- package/src/components/sheet/sheet.styles.ts +60 -0
- package/src/components/sheet/sheet.tsx +108 -0
- package/src/components/slider/index.ts +2 -0
- package/src/components/slider/slider.styles.ts +32 -0
- package/src/components/slider/slider.tsx +131 -0
- package/src/components/stat-card/index.ts +2 -0
- package/src/components/stat-card/stat-card.styles.ts +24 -0
- package/src/components/stat-card/stat-card.tsx +108 -0
- package/src/components/stepper/index.ts +2 -0
- package/src/components/stepper/stepper.styles.ts +74 -0
- package/src/components/stepper/stepper.tsx +138 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.styles.ts +39 -0
- package/src/components/tag-input/tag-input.tsx +198 -0
- package/src/components/timeline/index.ts +8 -0
- package/src/components/timeline/timeline.styles.ts +56 -0
- package/src/components/timeline/timeline.tsx +91 -0
- package/src/components/toast/index.ts +1 -0
- package/src/components/toast/toast.tsx +91 -0
- package/src/components/toggle/index.ts +9 -0
- package/src/components/toggle/toggle.styles.ts +39 -0
- package/src/components/toggle/toggle.tsx +61 -0
- package/src/components/tree-view/index.ts +2 -0
- package/src/components/tree-view/tree-view.styles.ts +42 -0
- package/src/components/tree-view/tree-view.tsx +274 -0
- package/src/components/visually-hidden/index.ts +1 -0
- package/src/components/visually-hidden/visually-hidden.tsx +10 -0
- package/src/index.ts +45 -0
- package/src/patterns/chart/chart-parts.tsx +171 -0
- package/src/patterns/chart/chart.styles.ts +35 -0
- package/src/patterns/chart/chart.tsx +369 -0
- package/src/patterns/chart/index.ts +13 -0
- package/src/provider/glyph-provider.tsx +16 -2
- package/styles.css +16 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { NavigationMenu as NavPrimitive } from 'radix-ui'
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
3
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { useLinkComponent } from '../../provider/glyph-context'
|
|
5
|
+
import { navigationMenuStyles } from './navigation-menu.styles'
|
|
6
|
+
|
|
7
|
+
const s = navigationMenuStyles()
|
|
8
|
+
|
|
9
|
+
function useSlots() {
|
|
10
|
+
return useComponentConfig('NavigationMenu', {})[1]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ChevronDownIcon = ({ className }: { className?: string }) => (
|
|
14
|
+
<svg
|
|
15
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
16
|
+
viewBox="0 0 24 24"
|
|
17
|
+
fill="none"
|
|
18
|
+
stroke="currentColor"
|
|
19
|
+
strokeWidth="2"
|
|
20
|
+
strokeLinecap="round"
|
|
21
|
+
strokeLinejoin="round"
|
|
22
|
+
aria-hidden="true"
|
|
23
|
+
className={className}
|
|
24
|
+
>
|
|
25
|
+
<path d="m6 9 6 6 6-6" />
|
|
26
|
+
</svg>
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
export interface NavigationMenuProps extends ComponentProps<typeof NavPrimitive.Root> {
|
|
30
|
+
/** Render the shared animated panel below the menu. Default true. */
|
|
31
|
+
viewport?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Site navigation with dropdown panels (mega menus) for marketing headers.
|
|
36
|
+
* Keyboard: Tab between items, arrows inside panels, Escape to close.
|
|
37
|
+
*/
|
|
38
|
+
export function NavigationMenu(inProps: NavigationMenuProps) {
|
|
39
|
+
const [props, slots] = useComponentConfig('NavigationMenu', inProps)
|
|
40
|
+
const { viewport = true, className, children, ...rest } = props
|
|
41
|
+
return (
|
|
42
|
+
<NavPrimitive.Root
|
|
43
|
+
data-slot="navigation-menu"
|
|
44
|
+
className={s.root({ class: [slots.root, className] })}
|
|
45
|
+
{...rest}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
{viewport && <NavigationMenuViewport />}
|
|
49
|
+
</NavPrimitive.Root>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function NavigationMenuList({
|
|
54
|
+
className,
|
|
55
|
+
...props
|
|
56
|
+
}: ComponentProps<typeof NavPrimitive.List>) {
|
|
57
|
+
const slots = useSlots()
|
|
58
|
+
return (
|
|
59
|
+
<NavPrimitive.List
|
|
60
|
+
data-slot="navigation-menu-list"
|
|
61
|
+
className={s.list({ class: [slots.list, className] })}
|
|
62
|
+
{...props}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function NavigationMenuItem({
|
|
68
|
+
className,
|
|
69
|
+
...props
|
|
70
|
+
}: ComponentProps<typeof NavPrimitive.Item>) {
|
|
71
|
+
const slots = useSlots()
|
|
72
|
+
return (
|
|
73
|
+
<NavPrimitive.Item
|
|
74
|
+
data-slot="navigation-menu-item"
|
|
75
|
+
className={s.item({ class: [slots.item, className] })}
|
|
76
|
+
{...props}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function NavigationMenuTrigger({
|
|
82
|
+
className,
|
|
83
|
+
children,
|
|
84
|
+
...props
|
|
85
|
+
}: ComponentProps<typeof NavPrimitive.Trigger>) {
|
|
86
|
+
const slots = useSlots()
|
|
87
|
+
return (
|
|
88
|
+
<NavPrimitive.Trigger
|
|
89
|
+
data-slot="navigation-menu-trigger"
|
|
90
|
+
className={s.trigger({ class: [slots.trigger, className] })}
|
|
91
|
+
{...props}
|
|
92
|
+
>
|
|
93
|
+
{children}
|
|
94
|
+
<ChevronDownIcon className={s.chevron({ class: slots.chevron })} />
|
|
95
|
+
</NavPrimitive.Trigger>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function NavigationMenuContent({
|
|
100
|
+
className,
|
|
101
|
+
...props
|
|
102
|
+
}: ComponentProps<typeof NavPrimitive.Content>) {
|
|
103
|
+
const slots = useSlots()
|
|
104
|
+
return (
|
|
105
|
+
<NavPrimitive.Content
|
|
106
|
+
data-slot="navigation-menu-content"
|
|
107
|
+
className={s.content({ class: [slots.content, className] })}
|
|
108
|
+
{...props}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function NavigationMenuViewport({
|
|
114
|
+
className,
|
|
115
|
+
...props
|
|
116
|
+
}: ComponentProps<typeof NavPrimitive.Viewport>) {
|
|
117
|
+
const slots = useSlots()
|
|
118
|
+
return (
|
|
119
|
+
<div className={s.viewportWrapper({ class: slots.viewportWrapper })}>
|
|
120
|
+
<NavPrimitive.Viewport
|
|
121
|
+
data-slot="navigation-menu-viewport"
|
|
122
|
+
className={s.viewport({ class: [slots.viewport, className] })}
|
|
123
|
+
{...props}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface NavigationMenuLinkProps
|
|
130
|
+
extends Omit<ComponentProps<typeof NavPrimitive.Link>, 'href'> {
|
|
131
|
+
/** Rendered through the provider's `linkComponent`, so it uses your router. */
|
|
132
|
+
href?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A top-level link (no panel). Set `active` for the current page. */
|
|
136
|
+
export function NavigationMenuLink({
|
|
137
|
+
href,
|
|
138
|
+
className,
|
|
139
|
+
children,
|
|
140
|
+
...props
|
|
141
|
+
}: NavigationMenuLinkProps) {
|
|
142
|
+
const slots = useSlots()
|
|
143
|
+
const Link = useLinkComponent()
|
|
144
|
+
const cls = s.link({ class: [slots.link, className] })
|
|
145
|
+
if (href === undefined) {
|
|
146
|
+
return (
|
|
147
|
+
<NavPrimitive.Link data-slot="navigation-menu-link" className={cls} {...props}>
|
|
148
|
+
{children}
|
|
149
|
+
</NavPrimitive.Link>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
return (
|
|
153
|
+
<NavPrimitive.Link data-slot="navigation-menu-link" asChild {...props}>
|
|
154
|
+
<Link href={href} className={cls}>
|
|
155
|
+
{children}
|
|
156
|
+
</Link>
|
|
157
|
+
</NavPrimitive.Link>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface NavigationMenuCardProps {
|
|
162
|
+
href: string
|
|
163
|
+
title: ReactNode
|
|
164
|
+
description?: ReactNode
|
|
165
|
+
icon?: ReactNode
|
|
166
|
+
className?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A link tile for mega-menu panels: icon, title and one line of description. */
|
|
170
|
+
export function NavigationMenuCard({
|
|
171
|
+
href,
|
|
172
|
+
title,
|
|
173
|
+
description,
|
|
174
|
+
icon,
|
|
175
|
+
className,
|
|
176
|
+
}: NavigationMenuCardProps) {
|
|
177
|
+
const slots = useSlots()
|
|
178
|
+
const Link = useLinkComponent()
|
|
179
|
+
return (
|
|
180
|
+
<NavPrimitive.Link data-slot="navigation-menu-card" asChild>
|
|
181
|
+
<Link href={href} className={s.card({ class: [slots.card, className] })}>
|
|
182
|
+
{icon && (
|
|
183
|
+
<span className={s.cardIcon({ class: slots.cardIcon })} aria-hidden="true">
|
|
184
|
+
{icon}
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
<span className={s.cardText({ class: slots.cardText })}>
|
|
188
|
+
<span className={s.cardTitle({ class: slots.cardTitle })}>{title}</span>
|
|
189
|
+
{description && (
|
|
190
|
+
<span className={s.cardDescription({ class: slots.cardDescription })}>
|
|
191
|
+
{description}
|
|
192
|
+
</span>
|
|
193
|
+
)}
|
|
194
|
+
</span>
|
|
195
|
+
</Link>
|
|
196
|
+
</NavPrimitive.Link>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
2
|
+
import { focusRing } from '../../utils/styles'
|
|
3
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
4
|
+
import { fieldControl } from '../input/input.styles'
|
|
5
|
+
|
|
6
|
+
export const numberInputStyles = tv({
|
|
7
|
+
slots: {
|
|
8
|
+
root: [
|
|
9
|
+
...fieldControl,
|
|
10
|
+
'flex items-center gap-2 pl-3',
|
|
11
|
+
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20',
|
|
12
|
+
'data-invalid:focus-within:ring-danger/20',
|
|
13
|
+
'has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50',
|
|
14
|
+
],
|
|
15
|
+
input:
|
|
16
|
+
'h-full w-full min-w-0 bg-transparent tabular-nums text-foreground outline-none placeholder:text-faint-foreground disabled:cursor-not-allowed',
|
|
17
|
+
adornment: 'flex shrink-0 items-center text-faint-foreground [&>svg]:size-4',
|
|
18
|
+
steppers: 'flex h-full shrink-0 items-stretch border-l border-input',
|
|
19
|
+
stepper: [
|
|
20
|
+
'inline-flex w-8 cursor-pointer items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
21
|
+
'disabled:pointer-events-none disabled:opacity-40 [&>svg]:size-3.5 last:rounded-r-[calc(var(--radius-md)-1px)]',
|
|
22
|
+
focusRing,
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
variants: {
|
|
26
|
+
size: {
|
|
27
|
+
sm: { root: 'h-(--glyph-control-sm) text-small' },
|
|
28
|
+
md: { root: 'h-(--glyph-control-md) text-small' },
|
|
29
|
+
lg: { root: 'h-(--glyph-control-lg) text-body', stepper: 'w-10' },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: { size: 'md' },
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export type NumberInputVariants = VariantProps<typeof numberInputStyles>
|
|
36
|
+
|
|
37
|
+
declare module '@glyphui/tokens' {
|
|
38
|
+
interface GlyphComponentRegistry {
|
|
39
|
+
NumberInput: StylesEntry<typeof numberInputStyles>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { type ComponentProps, type KeyboardEvent, type ReactNode, useState } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { useControllableState } from '../../hooks/use-controllable-state'
|
|
4
|
+
import { useFieldControl } from '../field/field-context'
|
|
5
|
+
import { type NumberInputVariants, numberInputStyles } from './number-input.styles'
|
|
6
|
+
|
|
7
|
+
export interface NumberInputProps
|
|
8
|
+
extends Omit<
|
|
9
|
+
ComponentProps<'input'>,
|
|
10
|
+
'value' | 'defaultValue' | 'onChange' | 'size' | 'type' | 'min' | 'max' | 'step' | 'prefix'
|
|
11
|
+
>,
|
|
12
|
+
NumberInputVariants {
|
|
13
|
+
/** `null` means empty. */
|
|
14
|
+
value?: number | null
|
|
15
|
+
defaultValue?: number | null
|
|
16
|
+
onValueChange?: (value: number | null) => void
|
|
17
|
+
min?: number
|
|
18
|
+
max?: number
|
|
19
|
+
/** Amount added or removed by the steppers and arrow keys. Shift multiplies by 10. Default 1. */
|
|
20
|
+
step?: number
|
|
21
|
+
/** Display formatting when the field isn't focused, e.g. `{ style: 'currency', currency: 'USD' }`. */
|
|
22
|
+
formatOptions?: Intl.NumberFormatOptions
|
|
23
|
+
/** Locale for formatting. Defaults to the browser's. */
|
|
24
|
+
locale?: string
|
|
25
|
+
prefix?: ReactNode
|
|
26
|
+
/** Content after the number, before the steppers, e.g. a unit. */
|
|
27
|
+
suffix?: ReactNode
|
|
28
|
+
/** Hide the − / + buttons. */
|
|
29
|
+
hideSteppers?: boolean
|
|
30
|
+
/** Classes for the `<input>` element. `className` styles the outer frame. */
|
|
31
|
+
inputClassName?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clamp(value: number, min?: number, max?: number) {
|
|
35
|
+
return Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, value))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parses user text leniently: keeps digits, one leading minus and the decimal point. */
|
|
39
|
+
function parse(text: string): number | null {
|
|
40
|
+
const cleaned = text.replace(/[^\d.-]/g, '').replace(/(?!^)-/g, '')
|
|
41
|
+
if (cleaned === '' || cleaned === '-' || cleaned === '.') return null
|
|
42
|
+
const n = Number(cleaned)
|
|
43
|
+
return Number.isFinite(n) ? n : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Round away float noise from repeated steps (0.1 + 0.2). */
|
|
47
|
+
function roundToStep(value: number, step: number) {
|
|
48
|
+
const decimals = (String(step).split('.')[1] ?? '').length
|
|
49
|
+
return Number(value.toFixed(Math.min(decimals + 2, 10)))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A number field with − / + steppers, arrow-key stepping, min/max clamping on blur, and
|
|
54
|
+
* locale formatting when not focused. The value is a `number`, never a string.
|
|
55
|
+
*/
|
|
56
|
+
export function NumberInput(inProps: NumberInputProps) {
|
|
57
|
+
const [props, slots] = useComponentConfig('NumberInput', useFieldControl(inProps))
|
|
58
|
+
const {
|
|
59
|
+
value: valueProp,
|
|
60
|
+
defaultValue = null,
|
|
61
|
+
onValueChange,
|
|
62
|
+
min,
|
|
63
|
+
max,
|
|
64
|
+
step = 1,
|
|
65
|
+
formatOptions,
|
|
66
|
+
locale,
|
|
67
|
+
prefix,
|
|
68
|
+
suffix,
|
|
69
|
+
hideSteppers = false,
|
|
70
|
+
size,
|
|
71
|
+
className,
|
|
72
|
+
inputClassName,
|
|
73
|
+
disabled,
|
|
74
|
+
readOnly,
|
|
75
|
+
onBlur,
|
|
76
|
+
onFocus,
|
|
77
|
+
onKeyDown,
|
|
78
|
+
...rest
|
|
79
|
+
} = props
|
|
80
|
+
const [value, setValue] = useControllableState<number | null>({
|
|
81
|
+
value: valueProp,
|
|
82
|
+
defaultValue,
|
|
83
|
+
onChange: onValueChange,
|
|
84
|
+
})
|
|
85
|
+
const [draft, setDraft] = useState<string | null>(null)
|
|
86
|
+
const s = numberInputStyles({ size })
|
|
87
|
+
const invalid = rest['aria-invalid'] === true || rest['aria-invalid'] === 'true'
|
|
88
|
+
|
|
89
|
+
const display =
|
|
90
|
+
draft ??
|
|
91
|
+
(value === null || value === undefined
|
|
92
|
+
? ''
|
|
93
|
+
: formatOptions
|
|
94
|
+
? new Intl.NumberFormat(locale, formatOptions).format(value)
|
|
95
|
+
: String(value))
|
|
96
|
+
|
|
97
|
+
const commit = (next: number | null) => {
|
|
98
|
+
setValue(next === null ? null : roundToStep(clamp(next, min, max), step))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const stepBy = (direction: 1 | -1, multiplier = 1) => {
|
|
102
|
+
const base = draft !== null ? parse(draft) : value
|
|
103
|
+
commit((base ?? min ?? 0) + direction * step * multiplier)
|
|
104
|
+
setDraft(null)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
108
|
+
onKeyDown?.(event)
|
|
109
|
+
if (event.defaultPrevented || readOnly) return
|
|
110
|
+
const multiplier = event.shiftKey ? 10 : 1
|
|
111
|
+
if (event.key === 'ArrowUp') {
|
|
112
|
+
event.preventDefault()
|
|
113
|
+
stepBy(1, multiplier)
|
|
114
|
+
} else if (event.key === 'ArrowDown') {
|
|
115
|
+
event.preventDefault()
|
|
116
|
+
stepBy(-1, multiplier)
|
|
117
|
+
} else if (event.key === 'Home' && min !== undefined) {
|
|
118
|
+
event.preventDefault()
|
|
119
|
+
commit(min)
|
|
120
|
+
setDraft(null)
|
|
121
|
+
} else if (event.key === 'End' && max !== undefined) {
|
|
122
|
+
event.preventDefault()
|
|
123
|
+
commit(max)
|
|
124
|
+
setDraft(null)
|
|
125
|
+
} else if (event.key === 'Enter' && draft !== null) {
|
|
126
|
+
commit(parse(draft))
|
|
127
|
+
setDraft(null)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const atMin = value !== null && value !== undefined && min !== undefined && value <= min
|
|
132
|
+
const atMax = value !== null && value !== undefined && max !== undefined && value >= max
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
data-slot="number-input"
|
|
137
|
+
data-invalid={invalid || undefined}
|
|
138
|
+
className={s.root({ class: [slots.root, className] })}
|
|
139
|
+
>
|
|
140
|
+
{prefix && <span className={s.adornment({ class: slots.adornment })}>{prefix}</span>}
|
|
141
|
+
<input
|
|
142
|
+
type="text"
|
|
143
|
+
inputMode="decimal"
|
|
144
|
+
role="spinbutton"
|
|
145
|
+
aria-valuenow={value ?? undefined}
|
|
146
|
+
aria-valuemin={min}
|
|
147
|
+
aria-valuemax={max}
|
|
148
|
+
autoComplete="off"
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
readOnly={readOnly}
|
|
151
|
+
value={display}
|
|
152
|
+
className={s.input({ class: [slots.input, inputClassName] })}
|
|
153
|
+
onFocus={(event) => {
|
|
154
|
+
setDraft(value === null || value === undefined ? '' : String(value))
|
|
155
|
+
onFocus?.(event)
|
|
156
|
+
}}
|
|
157
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
158
|
+
onBlur={(event) => {
|
|
159
|
+
if (draft !== null) commit(parse(draft))
|
|
160
|
+
setDraft(null)
|
|
161
|
+
onBlur?.(event)
|
|
162
|
+
}}
|
|
163
|
+
onKeyDown={handleKeyDown}
|
|
164
|
+
{...rest}
|
|
165
|
+
/>
|
|
166
|
+
{suffix && <span className={s.adornment({ class: slots.adornment })}>{suffix}</span>}
|
|
167
|
+
{!hideSteppers && (
|
|
168
|
+
<span className={s.steppers({ class: slots.steppers })}>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
tabIndex={-1}
|
|
172
|
+
aria-label="Decrease"
|
|
173
|
+
disabled={disabled || readOnly || atMin}
|
|
174
|
+
className={s.stepper({ class: slots.stepper })}
|
|
175
|
+
onClick={() => stepBy(-1)}
|
|
176
|
+
>
|
|
177
|
+
<svg
|
|
178
|
+
viewBox="0 0 24 24"
|
|
179
|
+
fill="none"
|
|
180
|
+
stroke="currentColor"
|
|
181
|
+
strokeWidth="2"
|
|
182
|
+
strokeLinecap="round"
|
|
183
|
+
aria-hidden="true"
|
|
184
|
+
>
|
|
185
|
+
<path d="M5 12h14" />
|
|
186
|
+
</svg>
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
tabIndex={-1}
|
|
191
|
+
aria-label="Increase"
|
|
192
|
+
disabled={disabled || readOnly || atMax}
|
|
193
|
+
className={s.stepper({ class: slots.stepper })}
|
|
194
|
+
onClick={() => stepBy(1)}
|
|
195
|
+
>
|
|
196
|
+
<svg
|
|
197
|
+
viewBox="0 0 24 24"
|
|
198
|
+
fill="none"
|
|
199
|
+
stroke="currentColor"
|
|
200
|
+
strokeWidth="2"
|
|
201
|
+
strokeLinecap="round"
|
|
202
|
+
aria-hidden="true"
|
|
203
|
+
>
|
|
204
|
+
<path d="M12 5v14M5 12h14" />
|
|
205
|
+
</svg>
|
|
206
|
+
</button>
|
|
207
|
+
</span>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type PaginationItem = number | 'ellipsis'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The page numbers to show, with `'ellipsis'` for gaps. Always includes the first and
|
|
5
|
+
* last page and `siblingCount` pages on each side of the current one, and keeps the
|
|
6
|
+
* number of items constant so the control doesn't jump in width.
|
|
7
|
+
*
|
|
8
|
+
* `paginationRange(6, 20)` → `[1, 'ellipsis', 5, 6, 7, 'ellipsis', 20]`
|
|
9
|
+
*/
|
|
10
|
+
export function paginationRange(
|
|
11
|
+
page: number,
|
|
12
|
+
pageCount: number,
|
|
13
|
+
siblingCount = 1,
|
|
14
|
+
): PaginationItem[] {
|
|
15
|
+
const count = Math.max(0, Math.floor(pageCount))
|
|
16
|
+
if (count === 0) return []
|
|
17
|
+
const current = Math.min(Math.max(1, Math.floor(page)), count)
|
|
18
|
+
// first + last + current + 2 ellipses + siblings on both sides
|
|
19
|
+
const slots = siblingCount * 2 + 5
|
|
20
|
+
if (count <= slots) return range(1, count)
|
|
21
|
+
|
|
22
|
+
const left = Math.max(current - siblingCount, 1)
|
|
23
|
+
const right = Math.min(current + siblingCount, count)
|
|
24
|
+
const showLeftGap = left > 2
|
|
25
|
+
const showRightGap = right < count - 1
|
|
26
|
+
const edge = 3 + siblingCount * 2
|
|
27
|
+
|
|
28
|
+
if (!showLeftGap && showRightGap) return [...range(1, edge), 'ellipsis', count]
|
|
29
|
+
if (showLeftGap && !showRightGap) return [1, 'ellipsis', ...range(count - edge + 1, count)]
|
|
30
|
+
return [1, 'ellipsis', ...range(left, right), 'ellipsis', count]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function range(from: number, to: number): number[] {
|
|
34
|
+
return Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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 paginationStyles = tv({
|
|
6
|
+
slots: {
|
|
7
|
+
root: 'flex',
|
|
8
|
+
list: 'flex flex-wrap items-center gap-1',
|
|
9
|
+
item: [
|
|
10
|
+
'inline-flex shrink-0 cursor-pointer select-none items-center justify-center rounded-md font-medium tabular-nums text-muted-foreground',
|
|
11
|
+
transition,
|
|
12
|
+
focusRing,
|
|
13
|
+
'hover:bg-muted hover:text-foreground',
|
|
14
|
+
'aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:shadow-xs',
|
|
15
|
+
'disabled:pointer-events-none disabled:opacity-40 aria-disabled:pointer-events-none aria-disabled:opacity-40',
|
|
16
|
+
'[&>svg]:size-4',
|
|
17
|
+
],
|
|
18
|
+
ellipsis: 'inline-flex items-center justify-center text-faint-foreground',
|
|
19
|
+
},
|
|
20
|
+
variants: {
|
|
21
|
+
size: {
|
|
22
|
+
sm: {
|
|
23
|
+
item: 'h-(--glyph-control-sm) min-w-(--glyph-control-sm) px-1.5 text-small',
|
|
24
|
+
ellipsis: 'h-(--glyph-control-sm) w-(--glyph-control-sm) text-small',
|
|
25
|
+
},
|
|
26
|
+
md: {
|
|
27
|
+
item: 'h-(--glyph-control-md) min-w-(--glyph-control-md) px-2 text-small',
|
|
28
|
+
ellipsis: 'h-(--glyph-control-md) w-(--glyph-control-md) text-small',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: { size: 'md' },
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export type PaginationVariants = VariantProps<typeof paginationStyles>
|
|
36
|
+
|
|
37
|
+
declare module '@glyphui/tokens' {
|
|
38
|
+
interface GlyphComponentRegistry {
|
|
39
|
+
Pagination: StylesEntry<typeof paginationStyles>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { ChevronLeftIcon, ChevronRightIcon } from '../../internal/icons'
|
|
4
|
+
import { useLinkComponent } from '../../provider/glyph-context'
|
|
5
|
+
import { type PaginationVariants, paginationStyles } from './pagination.styles'
|
|
6
|
+
import { paginationRange } from './pagination-range'
|
|
7
|
+
|
|
8
|
+
const ChevronsLeftIcon = () => (
|
|
9
|
+
<svg
|
|
10
|
+
viewBox="0 0 24 24"
|
|
11
|
+
fill="none"
|
|
12
|
+
stroke="currentColor"
|
|
13
|
+
strokeWidth="2"
|
|
14
|
+
strokeLinecap="round"
|
|
15
|
+
strokeLinejoin="round"
|
|
16
|
+
aria-hidden="true"
|
|
17
|
+
>
|
|
18
|
+
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
|
|
19
|
+
</svg>
|
|
20
|
+
)
|
|
21
|
+
const ChevronsRightIcon = () => (
|
|
22
|
+
<svg
|
|
23
|
+
viewBox="0 0 24 24"
|
|
24
|
+
fill="none"
|
|
25
|
+
stroke="currentColor"
|
|
26
|
+
strokeWidth="2"
|
|
27
|
+
strokeLinecap="round"
|
|
28
|
+
strokeLinejoin="round"
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
>
|
|
31
|
+
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
|
|
32
|
+
</svg>
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
export interface PaginationProps
|
|
36
|
+
extends Omit<ComponentProps<'nav'>, 'onChange'>,
|
|
37
|
+
PaginationVariants {
|
|
38
|
+
/** Current page, starting at 1. */
|
|
39
|
+
page: number
|
|
40
|
+
pageCount: number
|
|
41
|
+
onPageChange?: (page: number) => void
|
|
42
|
+
/** Render pages as links (through the provider's linkComponent) instead of buttons. */
|
|
43
|
+
getHref?: (page: number) => string
|
|
44
|
+
/** Pages shown on each side of the current page. Default 1. */
|
|
45
|
+
siblingCount?: number
|
|
46
|
+
/** Show first / last page buttons. Default true. */
|
|
47
|
+
showEdges?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Page navigation for long lists. Controlled: you own `page`. */
|
|
51
|
+
export function Pagination(inProps: PaginationProps) {
|
|
52
|
+
const [props, slots] = useComponentConfig('Pagination', inProps)
|
|
53
|
+
const {
|
|
54
|
+
page,
|
|
55
|
+
pageCount,
|
|
56
|
+
onPageChange,
|
|
57
|
+
getHref,
|
|
58
|
+
siblingCount = 1,
|
|
59
|
+
showEdges = true,
|
|
60
|
+
size,
|
|
61
|
+
className,
|
|
62
|
+
...rest
|
|
63
|
+
} = props
|
|
64
|
+
const Link = useLinkComponent()
|
|
65
|
+
const s = paginationStyles({ size })
|
|
66
|
+
const itemClass = s.item({ class: slots.item })
|
|
67
|
+
const current = Math.min(Math.max(1, page), Math.max(1, pageCount))
|
|
68
|
+
|
|
69
|
+
const control = (
|
|
70
|
+
target: number,
|
|
71
|
+
label: string,
|
|
72
|
+
content: ReactNode,
|
|
73
|
+
options: { current?: boolean } = {},
|
|
74
|
+
) => {
|
|
75
|
+
const disabled = target < 1 || target > pageCount || (target === current && !options.current)
|
|
76
|
+
const shared = {
|
|
77
|
+
'aria-label': label,
|
|
78
|
+
'aria-current': options.current ? ('page' as const) : undefined,
|
|
79
|
+
className: itemClass,
|
|
80
|
+
}
|
|
81
|
+
if (getHref) {
|
|
82
|
+
if (disabled) {
|
|
83
|
+
return (
|
|
84
|
+
<span aria-disabled="true" {...shared}>
|
|
85
|
+
{content}
|
|
86
|
+
</span>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
<Link href={getHref(target)} onClick={() => onPageChange?.(target)} {...shared}>
|
|
91
|
+
{content}
|
|
92
|
+
</Link>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
return (
|
|
96
|
+
<button type="button" disabled={disabled} onClick={() => onPageChange?.(target)} {...shared}>
|
|
97
|
+
{content}
|
|
98
|
+
</button>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<nav
|
|
104
|
+
aria-label="Pagination"
|
|
105
|
+
data-slot="pagination"
|
|
106
|
+
className={s.root({ class: [slots.root, className] })}
|
|
107
|
+
{...rest}
|
|
108
|
+
>
|
|
109
|
+
<ul className={s.list({ class: slots.list })}>
|
|
110
|
+
{showEdges && <li>{control(1, 'First page', <ChevronsLeftIcon />)}</li>}
|
|
111
|
+
<li>{control(current - 1, 'Previous page', <ChevronLeftIcon />)}</li>
|
|
112
|
+
{paginationRange(current, pageCount, siblingCount).map((item, index) =>
|
|
113
|
+
item === 'ellipsis' ? (
|
|
114
|
+
<li
|
|
115
|
+
key={`gap-${index}`}
|
|
116
|
+
className={s.ellipsis({ class: slots.ellipsis })}
|
|
117
|
+
aria-hidden="true"
|
|
118
|
+
>
|
|
119
|
+
…
|
|
120
|
+
</li>
|
|
121
|
+
) : (
|
|
122
|
+
<li key={item}>{control(item, `Page ${item}`, item, { current: item === current })}</li>
|
|
123
|
+
),
|
|
124
|
+
)}
|
|
125
|
+
<li>{control(current + 1, 'Next page', <ChevronRightIcon />)}</li>
|
|
126
|
+
{showEdges && <li>{control(pageCount, 'Last page', <ChevronsRightIcon />)}</li>}
|
|
127
|
+
</ul>
|
|
128
|
+
</nav>
|
|
129
|
+
)
|
|
130
|
+
}
|