aeon-ui-engine 1.3.5 → 1.5.1

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 (104) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/CONSUMER.md +67 -27
  3. package/INTEGRATION.md +1 -1
  4. package/README.md +17 -8
  5. package/package.json +4 -1
  6. package/packages/cli/README.md +13 -1
  7. package/packages/cli/dist/bin.js +50 -6
  8. package/packages/cli/dist/chunk-JMNRJCWF.js +301 -0
  9. package/packages/cli/dist/index.d.ts +31 -1
  10. package/packages/cli/dist/index.js +7 -3
  11. package/packages/cli/src/bin.ts +50 -4
  12. package/packages/cli/src/index.ts +2 -0
  13. package/packages/cli/src/init.ts +10 -4
  14. package/packages/cli/src/link.ts +61 -0
  15. package/packages/cli/src/migrate.ts +170 -0
  16. package/packages/core/dist/index.d.ts +35 -7
  17. package/packages/core/dist/index.js +102 -2
  18. package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/packages/core/src/anatomy.ts +39 -3
  20. package/packages/core/src/identicon.test.ts +39 -0
  21. package/packages/core/src/identicon.ts +79 -0
  22. package/packages/core/src/index.ts +1 -0
  23. package/packages/panda/panda.config.ts +4 -0
  24. package/packages/panda/src/recipes/composer.ts +58 -4
  25. package/packages/panda/src/recipes/file-upload.ts +100 -0
  26. package/packages/panda/src/recipes/index.ts +2 -0
  27. package/packages/panda/src/recipes/range-slider.ts +59 -0
  28. package/packages/panda/src/recipes/thread.ts +77 -2
  29. package/packages/panda/styled-system/recipes/aeon-composer.d.ts +1 -1
  30. package/packages/panda/styled-system/recipes/aeon-composer.mjs +12 -0
  31. package/packages/panda/styled-system/recipes/aeon-file-upload.d.ts +31 -0
  32. package/packages/panda/styled-system/recipes/aeon-file-upload.mjs +61 -0
  33. package/packages/panda/styled-system/recipes/aeon-range-slider.d.ts +31 -0
  34. package/packages/panda/styled-system/recipes/aeon-range-slider.mjs +49 -0
  35. package/packages/panda/styled-system/recipes/aeon-thread.d.ts +1 -1
  36. package/packages/panda/styled-system/recipes/aeon-thread.mjs +20 -0
  37. package/packages/panda/styled-system/recipes/index.d.ts +2 -0
  38. package/packages/panda/styled-system/recipes/index.mjs +2 -0
  39. package/packages/panda/styled-system/styles.css +292 -0
  40. package/packages/primitives/dist/chunk-CV4UXXZF.js +44 -0
  41. package/packages/primitives/dist/chunk-IZKQSCYU.js +164 -0
  42. package/packages/primitives/dist/{chunk-PB3LFHXU.js → chunk-VUPWYBGU.js} +14 -0
  43. package/packages/primitives/dist/index.d.ts +2 -0
  44. package/packages/primitives/dist/index.js +23 -7
  45. package/packages/primitives/dist/machines/file-upload.d.ts +39 -0
  46. package/packages/primitives/dist/machines/file-upload.js +8 -0
  47. package/packages/primitives/dist/machines/range-slider.d.ts +129 -0
  48. package/packages/primitives/dist/machines/range-slider.js +12 -0
  49. package/packages/primitives/dist/machines/slider.d.ts +22 -0
  50. package/packages/primitives/dist/machines/slider.js +1 -1
  51. package/packages/primitives/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  52. package/packages/primitives/src/__tests__/file-upload.test.ts +25 -0
  53. package/packages/primitives/src/__tests__/range-slider.test.ts +39 -0
  54. package/packages/primitives/src/index.ts +2 -0
  55. package/packages/primitives/src/machines/file-upload.ts +55 -0
  56. package/packages/primitives/src/machines/range-slider.ts +209 -0
  57. package/packages/primitives/src/machines/slider.ts +17 -0
  58. package/packages/react/dist/index.d.ts +132 -26
  59. package/packages/react/dist/index.js +1822 -1029
  60. package/packages/react/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  61. package/packages/react/src/button/index.tsx +50 -15
  62. package/packages/react/src/composer/index.tsx +58 -3
  63. package/packages/react/src/file-upload/index.tsx +322 -0
  64. package/packages/react/src/floating/FloatingPositioner.tsx +8 -0
  65. package/packages/react/src/hooks/use-anchor-position.ts +88 -17
  66. package/packages/react/src/index.ts +3 -0
  67. package/packages/react/src/menu/index.tsx +37 -3
  68. package/packages/react/src/popover/index.tsx +7 -1
  69. package/packages/react/src/prompt/index.tsx +45 -0
  70. package/packages/react/src/range-slider/index.tsx +322 -0
  71. package/packages/react/src/slider/index.tsx +54 -14
  72. package/packages/react/src/thread/index.tsx +74 -3
  73. package/packages/react/src/tooltip/index.tsx +7 -1
  74. package/packages/react/src/utils/slot.tsx +47 -0
  75. package/packages/schemas/dist/index.d.ts +4 -0
  76. package/packages/schemas/dist/index.js +67 -1
  77. package/packages/schemas/json/button.json +6 -0
  78. package/packages/schemas/json/catalog.json +333 -0
  79. package/packages/schemas/json/fileUpload.json +168 -0
  80. package/packages/schemas/json/rangeSlider.json +157 -0
  81. package/packages/schemas/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  82. package/packages/schemas/prompts/cursorrules.md +3 -1
  83. package/packages/schemas/prompts/statecharts.md +1 -1
  84. package/packages/schemas/registry/catalog.json +18 -0
  85. package/packages/schemas/registry/components/button.json +6 -0
  86. package/packages/schemas/registry/components/fileUpload.json +168 -0
  87. package/packages/schemas/registry/components/rangeSlider.json +157 -0
  88. package/packages/schemas/registry/machines/fileUpload.json +182 -0
  89. package/packages/schemas/registry/machines/rangeSlider.json +201 -0
  90. package/packages/schemas/scripts/emit.ts +4 -0
  91. package/packages/schemas/src/components.ts +70 -0
  92. package/packages/schemas/src/prompts.ts +3 -1
  93. package/packages/schemas/tsconfig.json +1 -1
  94. package/packages/ui/dist/index.d.ts +100 -46
  95. package/packages/ui/dist/index.js +463 -341
  96. package/packages/ui/src/composer.tsx +37 -11
  97. package/packages/ui/src/file-upload.tsx +36 -0
  98. package/packages/ui/src/index.ts +2 -0
  99. package/packages/ui/src/prompt.tsx +3 -0
  100. package/packages/ui/src/range-slider.tsx +24 -0
  101. package/packages/ui/src/thread.tsx +40 -0
  102. package/scripts/link-scoped-packages.mjs +206 -0
  103. package/scripts/vite-aliases.mjs +6 -8
  104. package/packages/cli/dist/chunk-IW5NT2Z2.js +0 -103
@@ -129,11 +129,15 @@ export interface MenuPositionerProps extends HTMLAttributes<HTMLDivElement> {
129
129
  /** Mount menu in document.body (default true). */
130
130
  portalled?: boolean
131
131
  placement?: AnchorPlacement
132
+ /** Gap between trigger and menu (default 4). */
133
+ gutter?: number
134
+ /** Stack level for the portalled surface (default 50). */
135
+ zIndex?: number
132
136
  children?: ReactNode
133
137
  }
134
138
 
135
139
  const Positioner = forwardRef<HTMLDivElement, MenuPositionerProps>(function MenuPositioner(
136
- { portalled = true, placement = 'bottom-start', children, className, ...rest },
140
+ { portalled = true, placement = 'bottom-start', gutter, zIndex, children, className, ...rest },
137
141
  ref,
138
142
  ) {
139
143
  const { open, triggerRef, positionerRef } = useMenuCtx()
@@ -148,6 +152,8 @@ const Positioner = forwardRef<HTMLDivElement, MenuPositionerProps>(function Menu
148
152
  positionerRef={positionerRef}
149
153
  portalled={portalled}
150
154
  placement={placement}
155
+ gutter={gutter}
156
+ zIndex={zIndex}
151
157
  className={className}
152
158
  {...rest}
153
159
  >
@@ -160,13 +166,41 @@ const Content = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(funct
160
166
  { onKeyDown, ...rest },
161
167
  ref,
162
168
  ) {
163
- const { open, send, menuId } = useMenuCtx()
169
+ const { open, send, menuId, triggerRef } = useMenuCtx()
170
+ const contentRef = useRef<HTMLDivElement | null>(null)
171
+
172
+ // Focus enters the surface while it is open and returns to the trigger on
173
+ // dismiss, so keyboard users stay inside the menu they opened.
174
+ useEffect(() => {
175
+ if (!open) return
176
+ const trigger = triggerRef.current
177
+ contentRef.current?.focus({ preventScroll: true })
178
+ return () => {
179
+ if (trigger?.isConnected) trigger.focus({ preventScroll: true })
180
+ }
181
+ }, [open, triggerRef])
182
+
183
+ // Escape closes wherever focus sits — trigger, item or elsewhere on the page.
184
+ useEffect(() => {
185
+ if (!open) return
186
+ const onDocumentKeyDown = (event: globalThis.KeyboardEvent) => {
187
+ if (event.key === 'Escape') send({ type: 'ESCAPE' })
188
+ }
189
+ document.addEventListener('keydown', onDocumentKeyDown)
190
+ return () => document.removeEventListener('keydown', onDocumentKeyDown)
191
+ }, [open, send])
192
+
164
193
  if (!open) return null
165
194
  return (
166
195
  <div
167
- ref={ref}
196
+ ref={(node) => {
197
+ contentRef.current = node
198
+ if (typeof ref === 'function') ref(node)
199
+ else if (ref) ref.current = node
200
+ }}
168
201
  id={menuId}
169
202
  role="menu"
203
+ tabIndex={-1}
170
204
  {...mergeProps(
171
205
  partAttrs(menuAnatomy.scope, menuAnatomy.content, { state: 'open' }),
172
206
  {
@@ -132,11 +132,15 @@ export interface PopoverPositionerProps extends HTMLAttributes<HTMLDivElement> {
132
132
  /** Mount panel in document.body (default true). */
133
133
  portalled?: boolean
134
134
  placement?: AnchorPlacement
135
+ /** Gap between trigger and panel (default 4). */
136
+ gutter?: number
137
+ /** Stack level for the portalled surface (default 50). */
138
+ zIndex?: number
135
139
  children?: ReactNode
136
140
  }
137
141
 
138
142
  const Positioner = forwardRef<HTMLDivElement, PopoverPositionerProps>(function PopoverPositioner(
139
- { portalled = true, placement = 'bottom-start', children, className, ...rest },
143
+ { portalled = true, placement = 'bottom-start', gutter, zIndex, children, className, ...rest },
140
144
  ref,
141
145
  ) {
142
146
  const { open, triggerRef, positionerRef } = usePopoverCtx()
@@ -151,6 +155,8 @@ const Positioner = forwardRef<HTMLDivElement, PopoverPositionerProps>(function P
151
155
  positionerRef={positionerRef}
152
156
  portalled={portalled}
153
157
  placement={placement}
158
+ gutter={gutter}
159
+ zIndex={zIndex}
154
160
  className={className}
155
161
  {...rest}
156
162
  >
@@ -97,6 +97,48 @@ const Amount = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(functi
97
97
  )
98
98
  })
99
99
 
100
+ /** Command verb face — e.g. `/pay` (BRC-218 §4 confirmation). */
101
+ const Verb = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
102
+ function PromptVerb(props, ref) {
103
+ const { status } = usePromptCtx()
104
+ return (
105
+ <p
106
+ ref={ref}
107
+ {...mergeProps(partAttrs(promptAnatomy.scope, promptAnatomy.verb, { state: status }), props)}
108
+ />
109
+ )
110
+ },
111
+ )
112
+
113
+ /** Fully-qualified recipient `@handle@domain` — never alias alone. */
114
+ const Recipient = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
115
+ function PromptRecipient(props, ref) {
116
+ const { status } = usePromptCtx()
117
+ return (
118
+ <p
119
+ ref={ref}
120
+ {...mergeProps(
121
+ partAttrs(promptAnatomy.scope, promptAnatomy.recipient, { state: status }),
122
+ props,
123
+ )}
124
+ />
125
+ )
126
+ },
127
+ )
128
+
129
+ /** Plain-language effect statement for the confirmation sheet. */
130
+ const Effect = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
131
+ function PromptEffect(props, ref) {
132
+ const { status } = usePromptCtx()
133
+ return (
134
+ <p
135
+ ref={ref}
136
+ {...mergeProps(partAttrs(promptAnatomy.scope, promptAnatomy.effect, { state: status }), props)}
137
+ />
138
+ )
139
+ },
140
+ )
141
+
100
142
  const Actions = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function PromptActions(
101
143
  props,
102
144
  ref,
@@ -155,6 +197,9 @@ export const Prompt = {
155
197
  Eyebrow,
156
198
  Meta,
157
199
  Amount,
200
+ Verb,
201
+ Recipient,
202
+ Effect,
158
203
  Actions,
159
204
  Primary,
160
205
  Secondary,
@@ -0,0 +1,322 @@
1
+ import { partAttrs, partOnlyAttrs, rangeSliderAnatomy } from '@aeon-ui/core'
2
+ import {
3
+ rangeSliderMachine,
4
+ snapRangeSliderValue,
5
+ } from '@aeon-ui/primitives'
6
+ import {
7
+ createContext,
8
+ forwardRef,
9
+ useCallback,
10
+ useContext,
11
+ useEffect,
12
+ useMemo,
13
+ useRef,
14
+ type HTMLAttributes,
15
+ type KeyboardEvent,
16
+ type PointerEvent,
17
+ type ReactNode,
18
+ } from 'react'
19
+ import { useAeonMachine } from '../hooks/use-aeon-machine.js'
20
+ import { mergeProps } from '../utils/merge-props.js'
21
+
22
+ type ThumbIndex = 0 | 1
23
+
24
+ interface RangeSliderContextValue {
25
+ values: [number, number]
26
+ min: number
27
+ max: number
28
+ step: number
29
+ disabled: boolean
30
+ dragging: boolean
31
+ activeThumb: ThumbIndex | null
32
+ send: ReturnType<typeof useAeonMachine<typeof rangeSliderMachine>>[1]
33
+ setThumbFromClientX: (thumb: ThumbIndex, clientX: number) => void
34
+ trackRef: React.RefObject<HTMLDivElement | null>
35
+ }
36
+
37
+ const RangeSliderCtx = createContext<RangeSliderContextValue | null>(null)
38
+
39
+ function useRangeSliderCtx() {
40
+ const ctx = useContext(RangeSliderCtx)
41
+ if (!ctx) throw new Error('RangeSlider parts must be used within RangeSlider.Root')
42
+ return ctx
43
+ }
44
+
45
+ function percent(value: number, min: number, max: number) {
46
+ if (max <= min) return 0
47
+ return ((value - min) / (max - min)) * 100
48
+ }
49
+
50
+ export interface RangeSliderRootProps {
51
+ value?: [number, number]
52
+ defaultValue?: [number, number]
53
+ min?: number
54
+ max?: number
55
+ step?: number
56
+ minStepsBetween?: number
57
+ disabled?: boolean
58
+ onValueChange?: (value: [number, number]) => void
59
+ children?: ReactNode
60
+ className?: string
61
+ }
62
+
63
+ const Root = forwardRef<HTMLDivElement, RangeSliderRootProps>(function RangeSliderRoot(
64
+ {
65
+ value,
66
+ defaultValue,
67
+ min = 0,
68
+ max = 100,
69
+ step = 1,
70
+ minStepsBetween = 0,
71
+ disabled = false,
72
+ onValueChange,
73
+ children,
74
+ className,
75
+ ...rest
76
+ },
77
+ ref,
78
+ ) {
79
+ const trackRef = useRef<HTMLDivElement>(null)
80
+ const [snapshot, send] = useAeonMachine(rangeSliderMachine, {
81
+ input: {
82
+ values: defaultValue ?? [min, max],
83
+ min,
84
+ max,
85
+ step,
86
+ minStepsBetween,
87
+ disabled,
88
+ },
89
+ })
90
+
91
+ const resolvedValues = value ?? snapshot.context.values
92
+ const dragging = snapshot.matches({ interaction: 'dragging' })
93
+ const activeThumb = snapshot.context.activeThumb
94
+
95
+ useEffect(() => {
96
+ if (value !== undefined) send({ type: 'SET_VALUES', values: value })
97
+ }, [value, send])
98
+
99
+ useEffect(() => {
100
+ send({ type: 'SET_BOUNDS', min, max, step, minStepsBetween })
101
+ }, [min, max, step, minStepsBetween, send])
102
+
103
+ useEffect(() => {
104
+ send({ type: 'SET_DISABLED', disabled })
105
+ }, [disabled, send])
106
+
107
+ const setThumbFromClientX = useCallback(
108
+ (thumb: ThumbIndex, clientX: number) => {
109
+ const track = trackRef.current
110
+ if (!track || disabled) return
111
+ const rect = track.getBoundingClientRect()
112
+ if (rect.width <= 0) return
113
+ const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
114
+ const next = snapRangeSliderValue(min + ratio * (max - min), min, max, step)
115
+ send({ type: 'POINTER_MOVE', value: next })
116
+ if (value === undefined) {
117
+ // Machine updates async via snapshot; notify after POINTER_MOVE by reading next tick
118
+ // Consumers in uncontrolled mode get onValueChange from effect below.
119
+ }
120
+ void thumb
121
+ },
122
+ [disabled, min, max, step, send, value],
123
+ )
124
+
125
+ const lastNotified = useRef<string>('')
126
+ useEffect(() => {
127
+ const key = resolvedValues.join(',')
128
+ if (key === lastNotified.current) return
129
+ lastNotified.current = key
130
+ onValueChange?.(resolvedValues)
131
+ }, [resolvedValues, onValueChange])
132
+
133
+ useEffect(() => {
134
+ if (!dragging) return
135
+ const onMove = (e: globalThis.PointerEvent) => {
136
+ if (activeThumb == null) return
137
+ setThumbFromClientX(activeThumb, e.clientX)
138
+ }
139
+ const onUp = () => send({ type: 'POINTER_UP' })
140
+ window.addEventListener('pointermove', onMove)
141
+ window.addEventListener('pointerup', onUp)
142
+ return () => {
143
+ window.removeEventListener('pointermove', onMove)
144
+ window.removeEventListener('pointerup', onUp)
145
+ }
146
+ }, [dragging, activeThumb, setThumbFromClientX, send])
147
+
148
+ const ctx = useMemo(
149
+ () => ({
150
+ values: resolvedValues,
151
+ min,
152
+ max,
153
+ step,
154
+ disabled,
155
+ dragging,
156
+ activeThumb,
157
+ send,
158
+ setThumbFromClientX,
159
+ trackRef,
160
+ }),
161
+ [
162
+ resolvedValues,
163
+ min,
164
+ max,
165
+ step,
166
+ disabled,
167
+ dragging,
168
+ activeThumb,
169
+ send,
170
+ setThumbFromClientX,
171
+ ],
172
+ )
173
+
174
+ return (
175
+ <RangeSliderCtx.Provider value={ctx}>
176
+ <div
177
+ ref={ref}
178
+ className={className}
179
+ {...mergeProps(
180
+ partAttrs(rangeSliderAnatomy.scope, rangeSliderAnatomy.root, {
181
+ state: dragging ? 'dragging' : 'idle',
182
+ disabled,
183
+ }),
184
+ rest as HTMLAttributes<HTMLDivElement>,
185
+ )}
186
+ >
187
+ {children}
188
+ </div>
189
+ </RangeSliderCtx.Provider>
190
+ )
191
+ })
192
+
193
+ const Track = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function RangeSliderTrack(
194
+ { onPointerDown, ...rest },
195
+ ref,
196
+ ) {
197
+ const { disabled, values, send, setThumbFromClientX, trackRef, min, max } = useRangeSliderCtx()
198
+
199
+ return (
200
+ <div
201
+ ref={(node) => {
202
+ trackRef.current = node
203
+ if (typeof ref === 'function') ref(node)
204
+ else if (ref) ref.current = node
205
+ }}
206
+ {...mergeProps(
207
+ partOnlyAttrs(rangeSliderAnatomy.track),
208
+ {
209
+ onPointerDown: (e: PointerEvent<HTMLDivElement>) => {
210
+ onPointerDown?.(e)
211
+ if (disabled || e.button !== 0) return
212
+ e.currentTarget.setPointerCapture(e.pointerId)
213
+ const rect = e.currentTarget.getBoundingClientRect()
214
+ const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
215
+ const pointerValue = min + ratio * (max - min)
216
+ const dist0 = Math.abs(pointerValue - values[0])
217
+ const dist1 = Math.abs(pointerValue - values[1])
218
+ const thumb: ThumbIndex = dist0 <= dist1 ? 0 : 1
219
+ send({ type: 'POINTER_DOWN', thumb })
220
+ setThumbFromClientX(thumb, e.clientX)
221
+ },
222
+ },
223
+ rest,
224
+ )}
225
+ />
226
+ )
227
+ })
228
+
229
+ const Range = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function RangeSliderRange(
230
+ { style, ...rest },
231
+ ref,
232
+ ) {
233
+ const { values, min, max } = useRangeSliderCtx()
234
+ const left = percent(values[0], min, max)
235
+ const right = percent(values[1], min, max)
236
+ return (
237
+ <div
238
+ ref={ref}
239
+ aria-hidden
240
+ {...mergeProps(partOnlyAttrs(rangeSliderAnatomy.range), rest, {
241
+ style: { ...style, left: `${left}%`, width: `${Math.max(0, right - left)}%` },
242
+ })}
243
+ />
244
+ )
245
+ })
246
+
247
+ export interface RangeSliderThumbProps extends HTMLAttributes<HTMLSpanElement> {
248
+ index: ThumbIndex
249
+ }
250
+
251
+ const Thumb = forwardRef<HTMLSpanElement, RangeSliderThumbProps>(function RangeSliderThumb(
252
+ { index, onKeyDown, onPointerDown, style, ...rest },
253
+ ref,
254
+ ) {
255
+ const { values, min, max, step, disabled, dragging, activeThumb, send, setThumbFromClientX } =
256
+ useRangeSliderCtx()
257
+ const value = values[index]
258
+ const isActive = dragging && activeThumb === index
259
+
260
+ return (
261
+ <span
262
+ ref={ref}
263
+ role="slider"
264
+ tabIndex={disabled ? -1 : 0}
265
+ aria-valuemin={min}
266
+ aria-valuemax={max}
267
+ aria-valuenow={value}
268
+ aria-disabled={disabled || undefined}
269
+ data-aeon-index={index}
270
+ {...mergeProps(
271
+ partOnlyAttrs(rangeSliderAnatomy.thumb, {
272
+ state: isActive ? 'dragging' : disabled ? 'disabled' : 'idle',
273
+ }),
274
+ {
275
+ style: { ...style, left: `${percent(value, min, max)}%` },
276
+ onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
277
+ onPointerDown?.(e)
278
+ if (disabled || e.button !== 0) return
279
+ e.stopPropagation()
280
+ e.currentTarget.setPointerCapture(e.pointerId)
281
+ send({ type: 'POINTER_DOWN', thumb: index })
282
+ setThumbFromClientX(index, e.clientX)
283
+ },
284
+ onKeyDown: (e: KeyboardEvent<HTMLSpanElement>) => {
285
+ onKeyDown?.(e)
286
+ if (disabled || e.defaultPrevented) return
287
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
288
+ e.preventDefault()
289
+ send({ type: 'STEP', thumb: index, delta: step })
290
+ }
291
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
292
+ e.preventDefault()
293
+ send({ type: 'STEP', thumb: index, delta: -step })
294
+ }
295
+ if (e.key === 'Home') {
296
+ e.preventDefault()
297
+ send({ type: 'HOME', thumb: index })
298
+ }
299
+ if (e.key === 'End') {
300
+ e.preventDefault()
301
+ send({ type: 'END', thumb: index })
302
+ }
303
+ },
304
+ },
305
+ rest,
306
+ )}
307
+ />
308
+ )
309
+ })
310
+
311
+ const ValueText = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(
312
+ function RangeSliderValueText(props, ref) {
313
+ const { values } = useRangeSliderCtx()
314
+ return (
315
+ <span ref={ref} {...mergeProps(partOnlyAttrs(rangeSliderAnatomy.valueText), props)}>
316
+ {props.children ?? `${values[0]} – ${values[1]}`}
317
+ </span>
318
+ )
319
+ },
320
+ )
321
+
322
+ export const RangeSlider = { Root, Track, Range, Thumb, ValueText }
@@ -25,6 +25,8 @@ interface SliderContextValue {
25
25
  dragging: boolean
26
26
  send: ReturnType<typeof useAeonMachine<typeof sliderMachine>>[1]
27
27
  onValueChange?: (value: number) => void
28
+ /** Snap, store and report a user-intended value. */
29
+ commit: (value: number) => void
28
30
  setValueFromClientX: (clientX: number) => void
29
31
  trackRef: React.RefObject<HTMLDivElement | null>
30
32
  }
@@ -83,14 +85,33 @@ const Root = forwardRef<HTMLDivElement, SliderRootProps>(function SliderRoot(
83
85
  const resolvedValue = value ?? snapshot.context.value
84
86
  const dragging = snapshot.matches({ interaction: 'dragging' })
85
87
 
86
- useEffect(() => {
87
- onValueChange?.(resolvedValue)
88
- }, [resolvedValue, onValueChange])
89
-
90
88
  useEffect(() => {
91
89
  if (value !== undefined) send({ type: 'SET_VALUE', value })
92
90
  }, [value, send])
93
91
 
92
+ useEffect(() => {
93
+ send({ type: 'SET_BOUNDS', min, max, step })
94
+ }, [min, max, step, send])
95
+
96
+ useEffect(() => {
97
+ send({ type: 'SET_DISABLED', disabled })
98
+ }, [disabled, send])
99
+
100
+ /**
101
+ * Interaction is emitted here, never echoed from the resolved value. A
102
+ * controlled slider therefore reports what the user did (Aeon ≤1.4.0 stayed
103
+ * silent while `value` was set) and never fires on mount.
104
+ */
105
+ const commit = useCallback(
106
+ (next: number) => {
107
+ if (disabled) return
108
+ const snapped = snapSliderValue(next, min, max, step)
109
+ send({ type: 'SET_VALUE', value: snapped })
110
+ onValueChange?.(snapped)
111
+ },
112
+ [disabled, min, max, step, send, onValueChange],
113
+ )
114
+
94
115
  const setValueFromClientX = useCallback(
95
116
  (clientX: number) => {
96
117
  const track = trackRef.current
@@ -98,11 +119,9 @@ const Root = forwardRef<HTMLDivElement, SliderRootProps>(function SliderRoot(
98
119
  const rect = track.getBoundingClientRect()
99
120
  if (rect.width <= 0) return
100
121
  const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
101
- const next = snapSliderValue(min + ratio * (max - min), min, max, step)
102
- send({ type: 'SET_VALUE', value: next })
103
- if (value === undefined) onValueChange?.(next)
122
+ commit(min + ratio * (max - min))
104
123
  },
105
- [disabled, min, max, step, send, value, onValueChange],
124
+ [commit, disabled, min, max],
106
125
  )
107
126
 
108
127
  useEffect(() => {
@@ -127,10 +146,22 @@ const Root = forwardRef<HTMLDivElement, SliderRootProps>(function SliderRoot(
127
146
  dragging,
128
147
  send,
129
148
  onValueChange,
149
+ commit,
130
150
  setValueFromClientX,
131
151
  trackRef,
132
152
  }),
133
- [resolvedValue, min, max, step, disabled, dragging, send, onValueChange, setValueFromClientX],
153
+ [
154
+ resolvedValue,
155
+ min,
156
+ max,
157
+ step,
158
+ disabled,
159
+ dragging,
160
+ send,
161
+ onValueChange,
162
+ commit,
163
+ setValueFromClientX,
164
+ ],
134
165
  )
135
166
 
136
167
  return (
@@ -201,7 +232,8 @@ const Thumb = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(funct
201
232
  { onKeyDown, onPointerDown, style, ...rest },
202
233
  ref,
203
234
  ) {
204
- const { value, min, max, step, disabled, dragging, send, setValueFromClientX } = useSliderCtx()
235
+ const { value, min, max, step, disabled, dragging, send, commit, setValueFromClientX } =
236
+ useSliderCtx()
205
237
 
206
238
  return (
207
239
  <span
@@ -229,19 +261,27 @@ const Thumb = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(funct
229
261
  if (disabled || e.defaultPrevented) return
230
262
  if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
231
263
  e.preventDefault()
232
- send({ type: 'STEP', delta: step })
264
+ commit(value + step)
233
265
  }
234
266
  if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
235
267
  e.preventDefault()
236
- send({ type: 'STEP', delta: -step })
268
+ commit(value - step)
269
+ }
270
+ if (e.key === 'PageUp') {
271
+ e.preventDefault()
272
+ commit(value + (max - min) / 10)
273
+ }
274
+ if (e.key === 'PageDown') {
275
+ e.preventDefault()
276
+ commit(value - (max - min) / 10)
237
277
  }
238
278
  if (e.key === 'Home') {
239
279
  e.preventDefault()
240
- send({ type: 'HOME' })
280
+ commit(min)
241
281
  }
242
282
  if (e.key === 'End') {
243
283
  e.preventDefault()
244
- send({ type: 'END' })
284
+ commit(max)
245
285
  }
246
286
  },
247
287
  },