@pantoia/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 (66) hide show
  1. package/accordion/index.module.css +4 -4
  2. package/accordion/index.tsx +30 -58
  3. package/alert-dialog/index.tsx +38 -44
  4. package/badge/index.tsx +2 -2
  5. package/button/index.tsx +31 -30
  6. package/callout/index.tsx +29 -8
  7. package/checkbox/index.tsx +24 -88
  8. package/combobox/index.tsx +133 -323
  9. package/command-palette/index.tsx +55 -34
  10. package/dialog/index.tsx +30 -26
  11. package/dist/index.js +155 -27
  12. package/drawer/index.module.css +2 -2
  13. package/drawer/index.tsx +54 -132
  14. package/dropdown-menu/index.tsx +95 -100
  15. package/field/index.tsx +43 -94
  16. package/input/index.tsx +13 -9
  17. package/kbd/index.tsx +2 -2
  18. package/label/index.tsx +7 -9
  19. package/number-field/index.tsx +129 -176
  20. package/package.json +12 -15
  21. package/palette.css +114 -0
  22. package/popover/index.tsx +62 -33
  23. package/radio-group/index.tsx +36 -39
  24. package/registry.json +65 -169
  25. package/select/index.tsx +154 -76
  26. package/switch/index.tsx +13 -6
  27. package/table/index.tsx +5 -6
  28. package/tabs/index.module.css +5 -3
  29. package/tabs/index.tsx +26 -77
  30. package/tailwind.css +3 -0
  31. package/textarea/index.tsx +25 -13
  32. package/toast/index.module.css +187 -0
  33. package/toast/index.tsx +141 -46
  34. package/toggle/index.tsx +8 -7
  35. package/{styles.css → tokens.css} +82 -303
  36. package/tooltip/index.tsx +62 -31
  37. package/tv.ts +0 -1
  38. package/utilities.css +124 -0
  39. package/accordion/AGENTS.md +0 -60
  40. package/alert-dialog/AGENTS.md +0 -78
  41. package/badge/AGENTS.md +0 -35
  42. package/button/AGENTS.md +0 -39
  43. package/callout/AGENTS.md +0 -41
  44. package/checkbox/AGENTS.md +0 -54
  45. package/combobox/AGENTS.md +0 -150
  46. package/command-palette/AGENTS.md +0 -45
  47. package/dialog/AGENTS.md +0 -81
  48. package/drawer/AGENTS.md +0 -66
  49. package/dropdown-menu/AGENTS.md +0 -133
  50. package/field/AGENTS.md +0 -96
  51. package/input/AGENTS.md +0 -35
  52. package/kbd/AGENTS.md +0 -17
  53. package/label/AGENTS.md +0 -21
  54. package/number-field/AGENTS.md +0 -53
  55. package/popover/AGENTS.md +0 -64
  56. package/press.css +0 -37
  57. package/radio-group/AGENTS.md +0 -81
  58. package/select/AGENTS.md +0 -90
  59. package/switch/AGENTS.md +0 -37
  60. package/table/AGENTS.md +0 -82
  61. package/tabs/AGENTS.md +0 -60
  62. package/test-utils.ts +0 -10
  63. package/textarea/AGENTS.md +0 -35
  64. package/toast/AGENTS.md +0 -34
  65. package/toggle/AGENTS.md +0 -37
  66. package/tooltip/AGENTS.md +0 -50
package/field/index.tsx CHANGED
@@ -1,7 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { CircleAlert, CircleCheck, LoaderCircle } from 'lucide-react'
4
- import { createContext, use, useId, type ReactNode } from 'react'
3
+ import { Field as FieldPrimitive } from '@base-ui/react/field'
5
4
 
6
5
  import { Label } from '../label'
7
6
  import { tv } from '../tv'
@@ -20,90 +19,41 @@ export const field = tv({
20
19
  defaultVariants: { layout: 'stack' },
21
20
  })
22
21
 
23
- export interface FieldContext {
24
- id: string
25
- labelId: string
26
- messageId: string
27
- }
28
-
29
- export interface FieldControl {
30
- id: string
31
- 'aria-labelledby': string
32
- 'aria-invalid': 'true' | undefined
33
- 'aria-describedby': string
34
- }
35
-
36
- const Context = createContext<FieldContext | null>(null)
37
-
38
- export interface FieldProps {
39
- invalid?: boolean
22
+ export type FieldProps = Omit<FieldPrimitive.Root.Props, 'className'> & {
40
23
  layout?: 'stack' | 'inline' | 'split'
41
24
  className?: string
42
- children?: ReactNode | ((args: { control: FieldControl }) => ReactNode)
43
25
  }
44
26
 
45
- export function Field({ invalid = false, layout, className, children }: FieldProps) {
46
- const id = useId()
47
- const ctx: FieldContext = {
48
- id,
49
- labelId: `${id}-label`,
50
- messageId: `${id}-message`,
51
- }
52
-
53
- const control: FieldControl = {
54
- 'id': id,
55
- 'aria-labelledby': ctx.labelId,
56
- 'aria-invalid': invalid ? 'true' : undefined,
57
- 'aria-describedby': ctx.messageId,
58
- }
59
-
60
- return (
61
- <Context value={ctx}>
62
- <div className={field({ layout, className })}>
63
- {typeof children === 'function' ? children({ control }) : children}
64
- </div>
65
- </Context>
66
- )
27
+ export function Field({ layout, className, ...rest }: FieldProps) {
28
+ return <FieldPrimitive.Root {...rest} className={field({ layout, className })} />
67
29
  }
68
30
 
69
- const fieldLabel = tv({ base: 'group-has-disabled:text-foreground-disabled' })
31
+ const fieldLabel = tv({ base: 'group-has-disabled:text-fg-disabled' })
70
32
 
71
- export function FieldLabel({ className, children }: { className?: string, children?: ReactNode }) {
72
- const ctx = use(Context)
33
+ export type FieldLabelProps = Omit<FieldPrimitive.Label.Props, 'className'> & {
34
+ className?: string
35
+ }
73
36
 
74
- return (
75
- <Label
76
- id={ctx?.labelId}
77
- htmlFor={ctx?.id}
78
- className={fieldLabel({ className })}
79
- >
80
- {children}
81
- </Label>
82
- )
37
+ export function FieldLabel({ render = <Label />, className, ...rest }: FieldLabelProps) {
38
+ return <FieldPrimitive.Label {...rest} render={render} className={fieldLabel({ className })} />
83
39
  }
84
40
 
85
41
  const fieldHelp = tv({
86
- base: 'basis-full text-caption text-foreground-subtle'
87
- + ' group-has-disabled:text-foreground-disabled',
42
+ base: 'basis-full text-caption text-fg-subtle'
43
+ + ' group-has-disabled:text-fg-disabled',
88
44
  })
89
45
 
90
- export function FieldHelp({ className, children }: { className?: string, children?: ReactNode }) {
91
- const ctx = use(Context)
46
+ export type FieldHelpProps = Omit<FieldPrimitive.Description.Props, 'className'> & {
47
+ className?: string
48
+ }
92
49
 
93
- return (
94
- <p
95
- id={ctx?.messageId}
96
- className={fieldHelp({ className })}
97
- >
98
- {children}
99
- </p>
100
- )
50
+ export function FieldHelp({ className, ...rest }: FieldHelpProps) {
51
+ return <FieldPrimitive.Description {...rest} className={fieldHelp({ className })} />
101
52
  }
102
53
 
103
- export interface FieldErrorProps {
54
+ export type FieldErrorProps = Omit<FieldPrimitive.Error.Props, 'className'> & {
104
55
  items?: string | string[]
105
56
  className?: string
106
- children?: ReactNode
107
57
  }
108
58
 
109
59
  const fieldError = tv({
@@ -111,39 +61,38 @@ const fieldError = tv({
111
61
  + ' flex items-start gap-tight',
112
62
  })
113
63
 
114
- export function FieldError({ items, className, children }: FieldErrorProps) {
115
- const ctx = use(Context)
116
- const list = items == null ? [] : Array.isArray(items) ? items : [items]
64
+ export function FieldError({ items, match = true, render = <p />, className, children, ...rest }: FieldErrorProps) {
65
+ const list: string[] = items == null ? [] : Array.isArray(items) ? items : [items]
117
66
 
118
67
  return (
119
- <p
120
- id={ctx?.messageId}
121
- className={fieldError({ className })}
122
- >
123
- <CircleAlert className="mt-0.5 size-4 shrink-0" />
68
+ <FieldPrimitive.Error {...rest} match={match} render={render} className={fieldError({ className })}>
69
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="mt-0.5 size-4 shrink-0">
70
+ <circle cx="12" cy="12" r="10" />
71
+ <line x1="12" x2="12" y1="8" y2="12" />
72
+ <line x1="12" x2="12.01" y1="16" y2="16" />
73
+ </svg>
124
74
  <span className="flex min-w-0 flex-col gap-0.5">
125
75
  {children ?? list.map(t => <span key={t}>{t}</span>)}
126
76
  </span>
127
- </p>
77
+ </FieldPrimitive.Error>
128
78
  )
129
79
  }
130
80
 
131
81
  const fieldPending = tv({
132
- base: 'basis-full text-caption text-foreground-subtle'
82
+ base: 'basis-full text-caption text-fg-subtle'
133
83
  + ' flex items-start gap-tight',
134
84
  })
135
85
 
136
- export function FieldPending({ className, children }: { className?: string, children?: ReactNode }) {
137
- const ctx = use(Context)
86
+ export type FieldPendingProps = FieldHelpProps
138
87
 
88
+ export function FieldPending({ className, children, ...rest }: FieldPendingProps) {
139
89
  return (
140
- <p
141
- id={ctx?.messageId}
142
- className={fieldPending({ className })}
143
- >
144
- <LoaderCircle className="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none" />
90
+ <FieldPrimitive.Description {...rest} className={fieldPending({ className })}>
91
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none">
92
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
93
+ </svg>
145
94
  <span className="min-w-0">{children}</span>
146
- </p>
95
+ </FieldPrimitive.Description>
147
96
  )
148
97
  }
149
98
 
@@ -152,16 +101,16 @@ const fieldSuccess = tv({
152
101
  + ' flex items-start gap-tight',
153
102
  })
154
103
 
155
- export function FieldSuccess({ className, children }: { className?: string, children?: ReactNode }) {
156
- const ctx = use(Context)
104
+ export type FieldSuccessProps = FieldHelpProps
157
105
 
106
+ export function FieldSuccess({ className, children, ...rest }: FieldSuccessProps) {
158
107
  return (
159
- <p
160
- id={ctx?.messageId}
161
- className={fieldSuccess({ className })}
162
- >
163
- <CircleCheck className="mt-0.5 size-4 shrink-0" />
108
+ <FieldPrimitive.Description {...rest} className={fieldSuccess({ className })}>
109
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="mt-0.5 size-4 shrink-0">
110
+ <circle cx="12" cy="12" r="10" />
111
+ <path d="m16 9-5.5 5.5L8 12" />
112
+ </svg>
164
113
  <span className="min-w-0">{children}</span>
165
- </p>
114
+ </FieldPrimitive.Description>
166
115
  )
167
116
  }
package/input/index.tsx CHANGED
@@ -1,20 +1,23 @@
1
- import type { InputHTMLAttributes } from 'react'
1
+ 'use client'
2
+
3
+ import { Input as InputPrimitive } from '@base-ui/react/input'
4
+ import type { ComponentProps } from 'react'
2
5
 
3
6
  import { tv } from '../tv'
4
7
 
5
8
  export const input = tv({
6
- base: 'w-full rounded-r8 text-value text-foreground'
7
- + ' placeholder:text-foreground-placeholder'
9
+ base: 'w-full rounded-r8 text-value text-fg'
10
+ + ' placeholder:text-fg-placeholder'
8
11
  + ' invalid:ring-2 invalid:ring-line-danger'
9
- + ' read-only:text-foreground-muted'
12
+ + ' read-only:text-fg-muted'
10
13
  + ' transition-state'
11
14
  + ' focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus-ring'
12
- + ' disabled:bg-bg-neutral-disabled disabled:text-foreground-disabled disabled:cursor-not-allowed',
15
+ + ' disabled:bg-bg-neutral-disabled disabled:text-fg-disabled disabled:cursor-not-allowed',
13
16
 
14
17
  variants: {
15
18
  surface: {
16
- bare: 'bg-transparent ring-1 ring-inset ring-line-strong read-only:bg-background-well',
17
- well: 'bg-background-well',
19
+ bare: 'bg-transparent ring-1 ring-inset ring-line-strong read-only:bg-bg-well',
20
+ well: 'bg-bg-well',
18
21
  },
19
22
  size: {
20
23
  sm: 'h-8 px-3',
@@ -26,11 +29,12 @@ export const input = tv({
26
29
  defaultVariants: { surface: 'bare', size: 'md' },
27
30
  })
28
31
 
29
- export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
32
+ export type InputProps = Omit<ComponentProps<typeof InputPrimitive>, 'className' | 'size'> & {
30
33
  surface?: 'bare' | 'well'
31
34
  size?: 'sm' | 'md' | 'lg'
35
+ className?: string
32
36
  }
33
37
 
34
38
  export function Input({ surface, size, className, ...rest }: InputProps) {
35
- return <input {...rest} className={input({ surface, size, className })} />
39
+ return <InputPrimitive {...rest} className={input({ surface, size, className })} />
36
40
  }
package/kbd/index.tsx CHANGED
@@ -3,8 +3,8 @@ import type { HTMLAttributes } from 'react'
3
3
  import { tv } from '../tv'
4
4
 
5
5
  const kbd = tv({
6
- base: 'bg-background-well text-foreground-muted text-micro rounded-mark inline-flex items-center'
7
- + ' border border-line px-1.5 py-0.5 font-mono',
6
+ base: 'bg-bg-neutral-solid/10 text-fg text-micro rounded-r4 inline-flex items-center'
7
+ + ' border border-transparent px-1.5 py-0.5 font-mono',
8
8
  })
9
9
 
10
10
  export function Kbd({ className, ...rest }: HTMLAttributes<HTMLElement>) {
package/label/index.tsx CHANGED
@@ -1,15 +1,13 @@
1
- import { Label as LabelPrimitive } from 'radix-ui'
2
1
  import type { ComponentProps } from 'react'
3
2
 
4
3
  import { tv } from '../tv'
5
4
 
6
- const labelText = tv({ base: 'text-label text-foreground' })
5
+ const labelText = tv({ base: 'text-label text-fg' })
7
6
 
8
- export function Label({ className, ...rest }: ComponentProps<typeof LabelPrimitive.Root>) {
9
- return (
10
- <LabelPrimitive.Root
11
- {...rest}
12
- className={labelText({ className })}
13
- />
14
- )
7
+ export type LabelProps = Omit<ComponentProps<'label'>, 'className'> & {
8
+ className?: string
9
+ }
10
+
11
+ export function Label({ className, ...rest }: LabelProps) {
12
+ return <label {...rest} className={labelText({ className })} />
15
13
  }
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { Minus, Plus } from 'lucide-react'
4
- import { useEffect, useRef, useState, type InputHTMLAttributes, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'
3
+ import { NumberField as NumberFieldPrimitive } from '@base-ui/react/number-field'
4
+ import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
5
5
 
6
6
  import { tv } from '../tv'
7
7
 
@@ -13,18 +13,18 @@ export const numberField = tv({
13
13
  + ' has-disabled:bg-bg-neutral-disabled has-disabled:cursor-not-allowed'
14
14
  + ' has-focus-visible:outline-2 has-focus-visible:outline-offset-2'
15
15
  + ' has-focus-visible:outline-line-focus-ring',
16
- step: 'text-foreground-muted grid shrink-0 place-items-center rounded-r8'
16
+ step: 'text-fg-muted grid shrink-0 place-items-center rounded-r8'
17
17
  + ' transition-state press press-shrink enabled:not-aria-disabled:hover:bg-bg-neutral-hover'
18
- + ' disabled:text-foreground-disabled disabled:cursor-not-allowed'
19
- + ' aria-disabled:text-foreground-disabled aria-disabled:cursor-not-allowed',
20
- input: 'text-value text-foreground w-full min-w-0 bg-transparent text-center outline-none'
21
- + ' disabled:text-foreground-disabled disabled:cursor-not-allowed',
18
+ + ' disabled:text-fg-disabled disabled:cursor-not-allowed'
19
+ + ' aria-disabled:text-fg-disabled aria-disabled:cursor-not-allowed',
20
+ input: 'text-value text-fg w-full min-w-0 bg-transparent text-center outline-none'
21
+ + ' disabled:text-fg-disabled disabled:cursor-not-allowed',
22
22
  },
23
23
 
24
24
  variants: {
25
25
  surface: {
26
26
  bare: { root: 'bg-transparent ring-1 ring-inset ring-line-strong' },
27
- well: { root: 'bg-background-well' },
27
+ well: { root: 'bg-bg-well' },
28
28
  },
29
29
  size: {
30
30
  sm: { root: 'h-8', step: 'size-8' },
@@ -32,202 +32,155 @@ export const numberField = tv({
32
32
  lg: { root: 'h-10', step: 'size-10', input: 'text-t16' },
33
33
  },
34
34
  readonly: {
35
- true: { root: 'bg-background-well', input: 'text-foreground-muted' },
35
+ true: { root: 'bg-bg-well', input: 'text-fg-muted' },
36
36
  },
37
37
  },
38
38
 
39
39
  defaultVariants: { surface: 'bare', size: 'md' },
40
40
  })
41
41
 
42
- const HOLD_DELAY = 400
43
- const HOLD_EVERY = 60
44
-
45
- const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))
46
-
47
- const GROUPED = new Intl.NumberFormat('en-US')
48
-
49
- const TYPING = /[^0-9.-]/g
50
-
51
- function snap(n: number, min: number, step: number) {
52
- const from = Number.isFinite(min) ? min : 0
53
- return from + Math.round((n - from) / step) * step
42
+ const PAGE_KEYS: Record<string, 'ArrowUp' | 'ArrowDown'> = {
43
+ PageUp: 'ArrowUp',
44
+ PageDown: 'ArrowDown',
54
45
  }
55
46
 
56
- function useHold(step: () => void, blocked: boolean) {
57
- const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
58
- const beat = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
59
- const latest = useRef(step)
60
- const [pressed, setPressed] = useState(false)
61
-
62
- useEffect(() => { latest.current = step })
63
-
64
- const stop = () => {
65
- clearTimeout(timer.current)
66
- clearInterval(beat.current)
67
- setPressed(false)
68
- }
69
-
70
- useEffect(() => stop, [])
71
-
72
- const onPointerDown = (e: ReactPointerEvent<HTMLButtonElement>) => {
73
- if (e.button !== 0 || blocked) return
74
-
75
- latest.current()
76
- setPressed(true)
77
- e.currentTarget.setPointerCapture(e.pointerId)
47
+ function snap(n: number, min: number | undefined, max: number | undefined, step: number) {
48
+ const from = min ?? 0
49
+ const snapped = from + Math.round((n - from) / step) * step
50
+ return Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, snapped))
51
+ }
78
52
 
79
- timer.current = setTimeout(() => {
80
- beat.current = setInterval(() => { latest.current() }, HOLD_EVERY)
81
- }, HOLD_DELAY)
53
+ function blurDetails(event: Event): NumberFieldPrimitive.Root.ChangeEventDetails {
54
+ const details: NumberFieldPrimitive.Root.ChangeEventDetails = {
55
+ reason: 'input-blur',
56
+ event: event as FocusEvent,
57
+ trigger: event.target instanceof Element ? event.target : undefined,
58
+ isCanceled: false,
59
+ isPropagationAllowed: false,
60
+ cancel: () => { details.isCanceled = true },
61
+ allowPropagation: () => { details.isPropagationAllowed = true },
82
62
  }
83
-
84
- return { pressed, onPointerDown, onPointerUp: stop, onPointerCancel: stop, onLostPointerCapture: stop }
63
+ return details
85
64
  }
86
65
 
87
- export interface NumberFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange' | 'size' | 'min' | 'max' | 'step'> {
88
- value?: number | undefined
89
- defaultValue?: number
90
- onValueChange?: (value: number | undefined) => void
91
- decrementLabel: string
92
- incrementLabel: string
93
- min?: number
94
- max?: number
95
- step?: number
96
- surface?: 'bare' | 'well'
97
- size?: 'sm' | 'md' | 'lg'
98
- disabled?: boolean
99
- readOnly?: boolean
100
- className?: string
101
- }
66
+ export type NumberFieldProps = Omit<NumberFieldPrimitive.Root.Props, 'className'>
67
+ & Pick<NumberFieldPrimitive.Input.Props, 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'aria-invalid' | 'placeholder' | 'autoFocus'>
68
+ & {
69
+ decrementLabel: string
70
+ incrementLabel: string
71
+ surface?: 'bare' | 'well'
72
+ size?: 'sm' | 'md' | 'lg'
73
+ className?: string
74
+ }
102
75
 
103
- export function NumberField(props: NumberFieldProps) {
104
- const {
105
- value,
106
- defaultValue,
107
- onValueChange,
108
- decrementLabel,
109
- incrementLabel,
110
- min = Number.NEGATIVE_INFINITY,
111
- max = Number.POSITIVE_INFINITY,
112
- step = 1,
113
- surface,
114
- size,
115
- disabled = false,
116
- readOnly = false,
117
- className,
118
- ...rest
119
- } = props
120
-
121
- const controlled = 'value' in props
122
- const [own, setOwn] = useState<number | undefined>(defaultValue)
123
- const [typing, setTyping] = useState<string | null>(null)
76
+ export function NumberField({
77
+ value,
78
+ defaultValue,
79
+ onValueChange,
80
+ onValueCommitted,
81
+ decrementLabel,
82
+ incrementLabel,
83
+ step = 1,
84
+ largeStep,
85
+ min,
86
+ max,
87
+ surface,
88
+ size,
89
+ disabled = false,
90
+ readOnly = false,
91
+ locale = 'en-US',
92
+ className,
93
+ 'aria-label': ariaLabel,
94
+ 'aria-labelledby': ariaLabelledBy,
95
+ 'aria-describedby': ariaDescribedBy,
96
+ 'aria-invalid': ariaInvalid,
97
+ placeholder,
98
+ autoFocus,
99
+ ...rest
100
+ }: NumberFieldProps) {
101
+ const [own, setOwn] = useState<number | null>(defaultValue ?? null)
102
+ const controlled = value !== undefined
124
103
  const current = controlled ? value : own
104
+ const unit = step === 'any' ? 1 : step
125
105
 
126
106
  const ui = numberField({ surface, size, readonly: readOnly })
107
+ const stepButton = <button type="button" disabled={disabled || readOnly} />
127
108
 
128
- const set = (next: number | undefined) => {
129
- const after = next === undefined ? undefined : clamp(next, min, max)
130
- if (!controlled) setOwn(after)
131
- if (after !== current) onValueChange?.(after)
109
+ const change: NumberFieldPrimitive.Root.Props['onValueChange'] = (next, details) => {
110
+ onValueChange?.(next, details)
111
+ if (!details.isCanceled && !controlled) setOwn(next)
132
112
  }
133
113
 
134
- const blocked = disabled || readOnly
135
- const from = current ?? (Number.isFinite(min) ? min : 0)
136
- const atMin = current !== undefined && current <= min
137
- const atMax = current !== undefined && current >= max
138
-
139
- const down = useHold(() => { setTyping(null); set(from - step) }, blocked || atMin)
140
- const up = useHold(() => { setTyping(null); set(from + step) }, blocked || atMax)
141
-
142
- const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
143
- if (blocked) return
144
-
145
- const moves: Record<string, number> = {
146
- ArrowUp: from + step,
147
- ArrowDown: from - step,
148
- PageUp: from + step * 10,
149
- PageDown: from - step * 10,
150
- Home: min,
151
- End: max,
114
+ const commit: NumberFieldPrimitive.Root.Props['onValueCommitted'] = (committed, details) => {
115
+ if (details.reason !== 'input-blur' || committed === null || step === 'any') {
116
+ onValueCommitted?.(committed, details)
117
+ return
152
118
  }
153
119
 
154
- const next = moves[e.key]
155
- if (next === undefined || !Number.isFinite(next)) return
120
+ const snapped = snap(committed, min, max, unit)
121
+ if (snapped !== committed) {
122
+ const changeDetails = blurDetails(details.event)
123
+ change(snapped, changeDetails)
124
+ if (changeDetails.isCanceled) return
125
+ }
156
126
 
157
- e.preventDefault()
158
- setTyping(null)
159
- set(next)
127
+ onValueCommitted?.(snapped, details)
160
128
  }
161
129
 
162
- const onWheel = (e: React.WheelEvent<HTMLInputElement>) => {
163
- e.currentTarget.blur()
164
- }
130
+ const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
131
+ const arrow = PAGE_KEYS[e.key]
132
+ if (!arrow || e.altKey || e.ctrlKey || e.metaKey) return
165
133
 
166
- const onInput = (e: React.ChangeEvent<HTMLInputElement>) => {
167
- const raw = e.target.value.replace(TYPING, '')
168
- setTyping(min >= 0 ? raw.replace(/-/g, '') : raw)
134
+ e.preventDefault()
135
+ e.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { key: arrow, shiftKey: true, bubbles: true, cancelable: true }))
169
136
  }
170
137
 
171
- const onBlur = () => {
172
- if (typing === null) return
173
-
174
- const parsed = Number(typing)
175
- const empty = typing.trim() === ''
176
- setTyping(null)
177
-
178
- if (empty) set(undefined)
179
- else if (Number.isFinite(parsed)) set(snap(parsed, min, step))
180
- }
138
+ const inputProps = Object.fromEntries(
139
+ Object.entries({
140
+ 'aria-label': ariaLabel,
141
+ 'aria-labelledby': ariaLabelledBy,
142
+ 'aria-describedby': ariaDescribedBy,
143
+ 'aria-invalid': ariaInvalid,
144
+ placeholder,
145
+ autoFocus,
146
+ }).filter(([, v]) => v !== undefined),
147
+ ) as NumberFieldPrimitive.Input.Props
181
148
 
182
149
  return (
183
- <div className={ui.root({ className })}>
184
- <button
185
- type="button"
186
- aria-label={decrementLabel}
187
- disabled={blocked}
188
- aria-disabled={atMin || undefined}
189
- data-pressed={down.pressed ? '' : undefined}
190
- className={ui.step()}
191
- onPointerDown={down.onPointerDown}
192
- onPointerUp={down.onPointerUp}
193
- onPointerCancel={down.onPointerCancel}
194
- onLostPointerCapture={down.onLostPointerCapture}
195
- >
196
- <Minus className="size-4" />
197
- </button>
198
-
199
- <input
200
- {...rest}
201
- type="text"
202
- inputMode="decimal"
203
- role="spinbutton"
204
- aria-valuenow={current}
205
- aria-valuemin={Number.isFinite(min) ? min : undefined}
206
- aria-valuemax={Number.isFinite(max) ? max : undefined}
207
- value={typing ?? (current === undefined ? '' : GROUPED.format(current))}
208
- disabled={disabled}
209
- readOnly={readOnly}
210
- onChange={onInput}
211
- onBlur={onBlur}
212
- onKeyDown={onKeyDown}
213
- onWheel={onWheel}
214
- className={ui.input()}
215
- />
216
-
217
- <button
218
- type="button"
219
- aria-label={incrementLabel}
220
- disabled={blocked}
221
- aria-disabled={atMax || undefined}
222
- data-pressed={up.pressed ? '' : undefined}
223
- className={ui.step()}
224
- onPointerDown={up.onPointerDown}
225
- onPointerUp={up.onPointerUp}
226
- onPointerCancel={up.onPointerCancel}
227
- onLostPointerCapture={up.onLostPointerCapture}
228
- >
229
- <Plus className="size-4" />
230
- </button>
231
- </div>
150
+ <NumberFieldPrimitive.Root
151
+ {...rest}
152
+ value={current}
153
+ onValueChange={change}
154
+ onValueCommitted={commit}
155
+ step={step}
156
+ largeStep={largeStep ?? unit * 10}
157
+ min={min}
158
+ max={max}
159
+ disabled={disabled}
160
+ readOnly={readOnly}
161
+ locale={locale}
162
+ className={className}
163
+ >
164
+ <NumberFieldPrimitive.Group className={ui.root()}>
165
+ <NumberFieldPrimitive.Decrement render={stepButton} aria-label={decrementLabel} className={ui.step()}>
166
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
167
+ <path d="M5 12h14" />
168
+ </svg>
169
+ </NumberFieldPrimitive.Decrement>
170
+
171
+ <NumberFieldPrimitive.Input
172
+ {...inputProps}
173
+ onKeyDown={onKeyDown}
174
+ className={ui.input()}
175
+ />
176
+
177
+ <NumberFieldPrimitive.Increment render={stepButton} aria-label={incrementLabel} className={ui.step()}>
178
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
179
+ <path d="M5 12h14" />
180
+ <path d="M12 5v14" />
181
+ </svg>
182
+ </NumberFieldPrimitive.Increment>
183
+ </NumberFieldPrimitive.Group>
184
+ </NumberFieldPrimitive.Root>
232
185
  )
233
186
  }