@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.
Files changed (116) hide show
  1. package/README.md +65 -12
  2. package/dist/chart.d.ts +202 -0
  3. package/dist/chart.js +466 -0
  4. package/dist/color-mode-script-Bf7LYkOj.js +13 -0
  5. package/dist/color-mode-script-KdwItKN2.d.ts +16 -0
  6. package/dist/data-table.js +3 -2
  7. package/dist/{field-CCecIzdq.js → field-CVbywksE.js} +21 -5
  8. package/dist/{field-context-B6WOcK5m.js → field-context-7I-cWvvS.js} +3 -57
  9. package/dist/form.d.ts +11 -4
  10. package/dist/form.js +34 -8
  11. package/dist/glyph-context-DxGJrdvr.d.ts +50 -0
  12. package/dist/{index-BWK4czIG.d.ts → index-Bi7rZ6ba.d.ts} +11 -0
  13. package/dist/index.d.ts +2334 -283
  14. package/dist/index.js +2952 -326
  15. package/dist/react-router.d.ts +18 -0
  16. package/dist/react-router.js +44 -0
  17. package/dist/{states-Rp8wZ_jH.js → states-IvhjBgna.js} +2 -1
  18. package/dist/tv-DLbT66dC.js +70 -0
  19. package/dist/vite.d.ts +27 -0
  20. package/dist/vite.js +29 -0
  21. package/fonts.css +10 -0
  22. package/package.json +47 -5
  23. package/src/components/accordion/accordion.styles.ts +4 -4
  24. package/src/components/badge/badge.styles.ts +11 -16
  25. package/src/components/banner/banner.styles.ts +4 -4
  26. package/src/components/button/button.styles.ts +3 -1
  27. package/src/components/calendar/calendar.styles.ts +51 -0
  28. package/src/components/calendar/calendar.tsx +84 -0
  29. package/src/components/calendar/index.ts +3 -0
  30. package/src/components/carousel/carousel.styles.ts +43 -0
  31. package/src/components/carousel/carousel.tsx +278 -0
  32. package/src/components/carousel/index.ts +13 -0
  33. package/src/components/collapsible/collapsible.tsx +66 -0
  34. package/src/components/collapsible/index.ts +6 -0
  35. package/src/components/context-menu/context-menu.styles.ts +19 -0
  36. package/src/components/context-menu/context-menu.tsx +226 -0
  37. package/src/components/context-menu/index.ts +19 -0
  38. package/src/components/copy-button/copy-button.tsx +117 -0
  39. package/src/components/copy-button/index.ts +1 -0
  40. package/src/components/date-picker/date-picker.styles.ts +42 -0
  41. package/src/components/date-picker/date-picker.tsx +322 -0
  42. package/src/components/date-picker/index.ts +8 -0
  43. package/src/components/dropdown-menu/dropdown-menu.styles.ts +2 -2
  44. package/src/components/eyebrow/eyebrow.styles.ts +1 -1
  45. package/src/components/field/field-context.ts +2 -0
  46. package/src/components/field/field.styles.ts +4 -2
  47. package/src/components/field/field.tsx +25 -4
  48. package/src/components/file-upload/file-upload.styles.ts +43 -0
  49. package/src/components/file-upload/file-upload.tsx +343 -0
  50. package/src/components/file-upload/index.ts +10 -0
  51. package/src/components/hover-card/hover-card.tsx +41 -0
  52. package/src/components/hover-card/index.ts +1 -0
  53. package/src/components/input-otp/index.ts +10 -0
  54. package/src/components/input-otp/input-otp.styles.ts +38 -0
  55. package/src/components/input-otp/input-otp.tsx +116 -0
  56. package/src/components/link/link.styles.ts +1 -1
  57. package/src/components/modal/index.ts +2 -0
  58. package/src/components/modal/modal.styles.ts +20 -0
  59. package/src/components/modal/modal.tsx +111 -0
  60. package/src/components/navigation-menu/index.ts +14 -0
  61. package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
  62. package/src/components/navigation-menu/navigation-menu.tsx +208 -0
  63. package/src/components/number-input/index.ts +2 -0
  64. package/src/components/number-input/number-input.styles.ts +41 -0
  65. package/src/components/number-input/number-input.tsx +211 -0
  66. package/src/components/password-input/index.ts +7 -0
  67. package/src/components/password-input/password-input.styles.ts +30 -0
  68. package/src/components/password-input/password-input.tsx +127 -0
  69. package/src/components/prose/prose.tsx +1 -1
  70. package/src/components/resizable/index.ts +9 -0
  71. package/src/components/resizable/resizable.styles.ts +28 -0
  72. package/src/components/resizable/resizable.tsx +69 -0
  73. package/src/components/scroll-area/scroll-area.tsx +23 -1
  74. package/src/components/segmented-control/segmented-control.tsx +17 -2
  75. package/src/components/sheet/sheet.styles.ts +7 -7
  76. package/src/components/slider/slider.tsx +9 -2
  77. package/src/components/stepper/index.ts +2 -0
  78. package/src/components/stepper/stepper.styles.ts +74 -0
  79. package/src/components/stepper/stepper.tsx +138 -0
  80. package/src/components/tag-input/index.ts +2 -0
  81. package/src/components/tag-input/tag-input.styles.ts +39 -0
  82. package/src/components/tag-input/tag-input.tsx +198 -0
  83. package/src/components/text/text.styles.ts +4 -4
  84. package/src/components/timeline/index.ts +8 -0
  85. package/src/components/timeline/timeline.styles.ts +56 -0
  86. package/src/components/timeline/timeline.tsx +91 -0
  87. package/src/components/toggle/index.ts +9 -0
  88. package/src/components/toggle/toggle.styles.ts +39 -0
  89. package/src/components/toggle/toggle.tsx +61 -0
  90. package/src/components/tree-view/index.ts +2 -0
  91. package/src/components/tree-view/tree-view.styles.ts +42 -0
  92. package/src/components/tree-view/tree-view.tsx +274 -0
  93. package/src/components/visually-hidden/index.ts +1 -0
  94. package/src/components/visually-hidden/visually-hidden.tsx +10 -0
  95. package/src/index.ts +20 -0
  96. package/src/integrations/react-router.tsx +39 -0
  97. package/src/integrations/vite.test-d.ts +6 -0
  98. package/src/integrations/vite.test.ts +28 -0
  99. package/src/integrations/vite.ts +33 -0
  100. package/src/internal/dev.ts +12 -0
  101. package/src/patterns/app-shell/app-shell.styles.ts +1 -1
  102. package/src/patterns/app-shell/navbar.tsx +34 -16
  103. package/src/patterns/app-shell/sidebar.tsx +7 -2
  104. package/src/patterns/chart/chart-parts.tsx +179 -0
  105. package/src/patterns/chart/chart.styles.ts +35 -0
  106. package/src/patterns/chart/chart.tsx +386 -0
  107. package/src/patterns/chart/index.ts +13 -0
  108. package/src/patterns/form/form.tsx +48 -6
  109. package/src/patterns/form/index.ts +1 -0
  110. package/src/provider/color-mode-script.ts +28 -0
  111. package/src/provider/color-mode.tsx +14 -20
  112. package/src/provider/glyph-context.ts +38 -0
  113. package/src/provider/glyph-provider.tsx +16 -4
  114. package/src/provider/index.ts +4 -0
  115. package/src/provider/theme-scope.tsx +7 -3
  116. package/styles.css +88 -0
@@ -0,0 +1,111 @@
1
+ import type { FormEvent, ReactNode } from 'react'
2
+ import { useComponentConfig } from '../../hooks/use-component-config'
3
+ import {
4
+ Dialog,
5
+ DialogBody,
6
+ DialogClose,
7
+ DialogContent,
8
+ type DialogContentProps,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogTrigger,
14
+ } from '../dialog'
15
+ import { modalStyles } from './modal.styles'
16
+
17
+ export interface ModalProps extends Omit<DialogContentProps, 'title' | 'children' | 'onSubmit'> {
18
+ open?: boolean
19
+ defaultOpen?: boolean
20
+ onOpenChange?: (open: boolean) => void
21
+ /** Heading of the modal. Also its accessible name. */
22
+ title: ReactNode
23
+ description?: ReactNode
24
+ /** Element that opens the modal, e.g. a Button. Optional when you control `open`. */
25
+ trigger?: ReactNode
26
+ /** Actions at the bottom. Wrap a button in `ModalClose` to close on click. */
27
+ footer?: ReactNode
28
+ /** Close when the backdrop is clicked. Default true. */
29
+ closeOnOverlayClick?: boolean
30
+ /**
31
+ * Turns the body and footer into one `<form>`, so a `type="submit"` button in the footer
32
+ * submits the fields in the body. The page does not reload. With `@glyphui/ui/form`, use
33
+ * `<Form>` in the body and `<FormSubmit form={form}>` in the footer instead.
34
+ */
35
+ onSubmit?: (event: FormEvent<HTMLFormElement>) => void
36
+ children?: ReactNode
37
+ }
38
+
39
+ /**
40
+ * The everyday modal in one component: title, description, body and footer as props.
41
+ * For custom layouts use the Dialog parts; for yes/no decisions use `useConfirm()`.
42
+ */
43
+ export function Modal(inProps: ModalProps) {
44
+ const [props, slots] = useComponentConfig('Modal', inProps)
45
+ const {
46
+ open,
47
+ defaultOpen,
48
+ onOpenChange,
49
+ title,
50
+ description,
51
+ trigger,
52
+ footer,
53
+ closeOnOverlayClick = true,
54
+ onSubmit,
55
+ onPointerDownOutside,
56
+ className,
57
+ children,
58
+ ...rest
59
+ } = props
60
+ const s = modalStyles()
61
+ const content = (
62
+ <>
63
+ {children && <DialogBody className={s.body({ class: slots.body })}>{children}</DialogBody>}
64
+ {footer && (
65
+ <DialogFooter className={s.footer({ class: slots.footer })}>{footer}</DialogFooter>
66
+ )}
67
+ </>
68
+ )
69
+
70
+ return (
71
+ <Dialog
72
+ {...(open !== undefined && { open })}
73
+ {...(defaultOpen !== undefined && { defaultOpen })}
74
+ {...(onOpenChange && { onOpenChange })}
75
+ >
76
+ {trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
77
+ <DialogContent
78
+ data-slot="modal"
79
+ className={s.content({ class: [slots.content, className] })}
80
+ onPointerDownOutside={(event) => {
81
+ onPointerDownOutside?.(event)
82
+ if (!closeOnOverlayClick) event.preventDefault()
83
+ }}
84
+ {...(!description && { 'aria-describedby': undefined })}
85
+ {...rest}
86
+ >
87
+ <DialogHeader className={s.header({ class: slots.header })}>
88
+ <DialogTitle>{title}</DialogTitle>
89
+ {description && <DialogDescription>{description}</DialogDescription>}
90
+ </DialogHeader>
91
+ {onSubmit ? (
92
+ <form
93
+ data-slot="modal-form"
94
+ className={s.form({ class: slots.form })}
95
+ onSubmit={(event) => {
96
+ event.preventDefault()
97
+ onSubmit(event)
98
+ }}
99
+ >
100
+ {content}
101
+ </form>
102
+ ) : (
103
+ content
104
+ )}
105
+ </DialogContent>
106
+ </Dialog>
107
+ )
108
+ }
109
+
110
+ /** Closes the surrounding Modal. Use with `asChild` around a button. */
111
+ export const ModalClose: typeof DialogClose = DialogClose
@@ -0,0 +1,14 @@
1
+ export {
2
+ NavigationMenu,
3
+ NavigationMenuCard,
4
+ type NavigationMenuCardProps,
5
+ NavigationMenuContent,
6
+ NavigationMenuItem,
7
+ NavigationMenuLink,
8
+ type NavigationMenuLinkProps,
9
+ NavigationMenuList,
10
+ type NavigationMenuProps,
11
+ NavigationMenuTrigger,
12
+ NavigationMenuViewport,
13
+ } from './navigation-menu'
14
+ export { navigationMenuStyles } from './navigation-menu.styles'
@@ -0,0 +1,48 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { focusRing, themedBorder, transition } from '../../utils/styles'
3
+ import { tv } from '../../utils/tv'
4
+
5
+ const topLevel = [
6
+ 'group inline-flex h-9 cursor-pointer select-none items-center justify-center gap-1 rounded-md px-3 text-small font-medium text-muted-foreground',
7
+ transition,
8
+ focusRing,
9
+ 'hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground',
10
+ 'data-active:text-foreground',
11
+ ]
12
+
13
+ export const navigationMenuStyles = tv({
14
+ slots: {
15
+ root: 'relative z-10 flex max-w-max flex-1 items-center justify-center',
16
+ list: 'group flex flex-1 list-none items-center justify-center gap-1',
17
+ item: 'relative',
18
+ trigger: topLevel,
19
+ chevron:
20
+ 'size-3 text-faint-foreground transition-transform duration-(--glyph-duration-base) ease-standard group-data-[state=open]:rotate-180',
21
+ content: 'top-0 left-0 w-full p-2 animate-fade-in md:absolute md:w-auto',
22
+ viewportWrapper: 'absolute top-full left-0 flex justify-center',
23
+ viewport: [
24
+ 'relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-[calc(100vw-2rem)] origin-top overflow-hidden rounded-lg border-border bg-raised text-foreground shadow-lg animate-pop-in',
25
+ 'md:w-(--radix-navigation-menu-viewport-width)',
26
+ 'transition-[width,height] duration-(--glyph-duration-base) ease-standard',
27
+ themedBorder,
28
+ ],
29
+ link: topLevel,
30
+ card: [
31
+ 'flex select-none gap-3 rounded-md p-3 text-foreground no-underline outline-none',
32
+ transition,
33
+ focusRing,
34
+ 'hover:bg-muted focus-visible:bg-muted',
35
+ ],
36
+ cardIcon:
37
+ 'flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted-foreground shadow-xs [&_svg]:size-4',
38
+ cardText: 'grid min-w-0 gap-0.5',
39
+ cardTitle: 'text-small font-medium text-foreground',
40
+ cardDescription: 'text-small text-muted-foreground',
41
+ },
42
+ })
43
+
44
+ declare module '@glyphui/tokens' {
45
+ interface GlyphComponentRegistry {
46
+ NavigationMenu: StylesEntry<typeof navigationMenuStyles>
47
+ }
48
+ }
@@ -0,0 +1,208 @@
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 { useIsActive, 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
+ /**
136
+ * A top-level link (no panel). Marked active automatically from the provider's `router`;
137
+ * set `active` to override.
138
+ */
139
+ export function NavigationMenuLink({
140
+ href,
141
+ active: activeProp,
142
+ className,
143
+ children,
144
+ ...props
145
+ }: NavigationMenuLinkProps) {
146
+ const slots = useSlots()
147
+ const Link = useLinkComponent()
148
+ const active = useIsActive(href, { explicit: activeProp })
149
+ const cls = s.link({ class: [slots.link, className] })
150
+ if (href === undefined) {
151
+ return (
152
+ <NavPrimitive.Link
153
+ data-slot="navigation-menu-link"
154
+ className={cls}
155
+ active={active}
156
+ {...props}
157
+ >
158
+ {children}
159
+ </NavPrimitive.Link>
160
+ )
161
+ }
162
+ return (
163
+ <NavPrimitive.Link data-slot="navigation-menu-link" asChild active={active} {...props}>
164
+ <Link href={href} className={cls}>
165
+ {children}
166
+ </Link>
167
+ </NavPrimitive.Link>
168
+ )
169
+ }
170
+
171
+ export interface NavigationMenuCardProps {
172
+ href: string
173
+ title: ReactNode
174
+ description?: ReactNode
175
+ icon?: ReactNode
176
+ className?: string
177
+ }
178
+
179
+ /** A link tile for mega-menu panels: icon, title and one line of description. */
180
+ export function NavigationMenuCard({
181
+ href,
182
+ title,
183
+ description,
184
+ icon,
185
+ className,
186
+ }: NavigationMenuCardProps) {
187
+ const slots = useSlots()
188
+ const Link = useLinkComponent()
189
+ return (
190
+ <NavPrimitive.Link data-slot="navigation-menu-card" asChild>
191
+ <Link href={href} className={s.card({ class: [slots.card, className] })}>
192
+ {icon && (
193
+ <span className={s.cardIcon({ class: slots.cardIcon })} aria-hidden="true">
194
+ {icon}
195
+ </span>
196
+ )}
197
+ <span className={s.cardText({ class: slots.cardText })}>
198
+ <span className={s.cardTitle({ class: slots.cardTitle })}>{title}</span>
199
+ {description && (
200
+ <span className={s.cardDescription({ class: slots.cardDescription })}>
201
+ {description}
202
+ </span>
203
+ )}
204
+ </span>
205
+ </Link>
206
+ </NavPrimitive.Link>
207
+ )
208
+ }
@@ -0,0 +1,2 @@
1
+ export { NumberInput, type NumberInputProps } from './number-input'
2
+ export { type NumberInputVariants, numberInputStyles } from './number-input.styles'
@@ -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,7 @@
1
+ export {
2
+ PasswordInput,
3
+ type PasswordInputProps,
4
+ type PasswordStrength,
5
+ passwordStrength,
6
+ } from './password-input'
7
+ export { passwordInputStyles } from './password-input.styles'
@@ -0,0 +1,30 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { focusRing } from '../../utils/styles'
3
+ import { tv } from '../../utils/tv'
4
+
5
+ export const passwordInputStyles = tv({
6
+ slots: {
7
+ root: 'grid gap-1.5',
8
+ toggle: [
9
+ '-mr-1 inline-flex size-7 cursor-pointer items-center justify-center rounded-sm text-faint-foreground hover:bg-muted hover:text-foreground [&>svg]:size-4',
10
+ focusRing,
11
+ ],
12
+ meter: 'flex items-center gap-2',
13
+ bars: 'grid flex-1 grid-cols-3 gap-1',
14
+ bar: 'h-1 rounded-pill bg-muted transition-colors duration-(--glyph-duration-base)',
15
+ label: 'w-14 text-right text-caption text-muted-foreground',
16
+ },
17
+ variants: {
18
+ strength: {
19
+ weak: { label: 'text-danger-text' },
20
+ fair: { label: 'text-warning-text' },
21
+ strong: { label: 'text-success-text' },
22
+ },
23
+ },
24
+ })
25
+
26
+ declare module '@glyphui/tokens' {
27
+ interface GlyphComponentRegistry {
28
+ PasswordInput: StylesEntry<typeof passwordInputStyles>
29
+ }
30
+ }