aeon-ui-engine 1.4.0 → 1.8.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 (64) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/CONSUMER.md +67 -27
  3. package/INTEGRATION.md +1 -1
  4. package/README.md +17 -8
  5. package/package.json +5 -2
  6. package/packages/cli/dist/bin.js +29 -3
  7. package/packages/cli/dist/{chunk-5L47SLNV.js → chunk-JMNRJCWF.js} +63 -9
  8. package/packages/cli/dist/index.d.ts +15 -1
  9. package/packages/cli/dist/index.js +3 -1
  10. package/packages/cli/node_modules/.bin/vitest +2 -2
  11. package/packages/cli/src/bin.ts +30 -2
  12. package/packages/cli/src/index.ts +1 -0
  13. package/packages/cli/src/init.ts +10 -4
  14. package/packages/cli/src/link.ts +61 -0
  15. package/packages/core/node_modules/.bin/vitest +2 -2
  16. package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  17. package/packages/panda/styled-system/styles.css +0 -4
  18. package/packages/primitives/dist/{chunk-PB3LFHXU.js → chunk-VUPWYBGU.js} +14 -0
  19. package/packages/primitives/dist/index.js +1 -1
  20. package/packages/primitives/dist/machines/range-slider.d.ts +3 -3
  21. package/packages/primitives/dist/machines/slider.d.ts +22 -0
  22. package/packages/primitives/dist/machines/slider.js +1 -1
  23. package/packages/primitives/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/packages/primitives/src/machines/slider.ts +17 -0
  25. package/packages/react/dist/index.d.ts +44 -5
  26. package/packages/react/dist/index.js +173 -48
  27. package/packages/react/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  28. package/packages/react/src/dialog/dialog.test.tsx +27 -0
  29. package/packages/react/src/dialog/index.tsx +11 -3
  30. package/packages/react/src/floating/FloatingPositioner.tsx +19 -1
  31. package/packages/react/src/hooks/use-anchor-position.ts +88 -17
  32. package/packages/react/src/menu/index.tsx +45 -6
  33. package/packages/react/src/popover/index.tsx +31 -4
  34. package/packages/react/src/slider/index.tsx +66 -19
  35. package/packages/react/src/tooltip/index.tsx +7 -1
  36. package/packages/schemas/dist/index.js +3 -1
  37. package/packages/schemas/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  38. package/packages/schemas/prompts/cursorrules.md +3 -1
  39. package/packages/schemas/registry/machines/slider.json +18 -1
  40. package/packages/schemas/src/prompts.ts +3 -1
  41. package/packages/tree/README.md +29 -0
  42. package/packages/tree/dist/index.d.ts +97 -0
  43. package/packages/tree/dist/index.js +107 -0
  44. package/packages/tree/node_modules/.bin/esbuild +21 -0
  45. package/packages/tree/node_modules/.bin/jiti +21 -0
  46. package/packages/tree/node_modules/.bin/tsc +21 -0
  47. package/packages/tree/node_modules/.bin/tsserver +21 -0
  48. package/packages/tree/node_modules/.bin/tsup +21 -0
  49. package/packages/tree/node_modules/.bin/tsup-node +21 -0
  50. package/packages/tree/node_modules/.bin/tsx +21 -0
  51. package/packages/tree/node_modules/.bin/vite +21 -0
  52. package/packages/tree/node_modules/.bin/vitest +21 -0
  53. package/packages/tree/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  54. package/packages/tree/package.json +42 -0
  55. package/packages/tree/src/exclusive-action.test.ts +54 -0
  56. package/packages/tree/src/exclusive-action.ts +104 -0
  57. package/packages/tree/src/index.ts +7 -0
  58. package/packages/tree/src/interpret.tsx +45 -0
  59. package/packages/tree/src/totality.ts +20 -0
  60. package/packages/tree/src/widget.ts +31 -0
  61. package/packages/tree/tsconfig.json +8 -0
  62. package/packages/tree/vitest.config.ts +8 -0
  63. package/scripts/link-scoped-packages.mjs +206 -0
  64. package/scripts/vite-aliases.mjs +9 -8
@@ -1,13 +1,38 @@
1
1
  import { useEffect, type RefObject } from 'react'
2
2
 
3
- export type AnchorPlacement = 'bottom-start' | 'top-start'
3
+ export type AnchorSide = 'top' | 'bottom'
4
+ export type AnchorAlign = 'start' | 'end' | 'center'
5
+ export type AnchorPlacement = `${AnchorSide}-${AnchorAlign}`
4
6
 
5
7
  export interface AnchorPositionOptions {
6
8
  /** Match floating width to anchor (select listboxes). */
7
9
  matchWidth?: boolean
10
+ /** Gap between anchor and floating surface. */
11
+ gutter?: number
12
+ /** Stack level written to the floating surface. */
13
+ zIndex?: number
14
+ /** Viewport padding kept clear when flipping and clamping. */
15
+ padding?: number
8
16
  }
9
17
 
10
- /** Pin floating surface to trigger rect (for portalled overlays). */
18
+ const DEFAULT_GUTTER = 4
19
+ const DEFAULT_Z_INDEX = 50
20
+ const DEFAULT_PADDING = 8
21
+
22
+ function splitPlacement(placement: AnchorPlacement): [AnchorSide, AnchorAlign] {
23
+ const [side, align] = placement.split('-') as [AnchorSide, AnchorAlign]
24
+ return [side, align]
25
+ }
26
+
27
+ /**
28
+ * Pin floating surface to trigger rect (for portalled overlays).
29
+ *
30
+ * Collision aware: the preferred side flips when it has no room, and the
31
+ * surface is clamped inside the viewport so an end-aligned trigger near an edge
32
+ * never renders offscreen. The resolved geometry is published as
33
+ * `data-side` / `data-align` plus `--aeon-available-height` and
34
+ * `--aeon-anchor-width` so recipes can cap their own scroll area.
35
+ */
11
36
  export function useAnchorPosition(
12
37
  anchorRef: RefObject<HTMLElement | null>,
13
38
  floatingRef: RefObject<HTMLElement | null>,
@@ -15,22 +40,32 @@ export function useAnchorPosition(
15
40
  placement: AnchorPlacement = 'bottom-start',
16
41
  options: AnchorPositionOptions = {},
17
42
  ) {
18
- const { matchWidth = false } = options
43
+ const {
44
+ matchWidth = false,
45
+ gutter = DEFAULT_GUTTER,
46
+ zIndex = DEFAULT_Z_INDEX,
47
+ padding = DEFAULT_PADDING,
48
+ } = options
19
49
 
20
50
  useEffect(() => {
21
51
  if (!active || !anchorRef.current || !floatingRef.current) return
22
52
 
53
+ let frame = 0
54
+
23
55
  const update = () => {
24
56
  const anchor = anchorRef.current
25
57
  const floating = floatingRef.current
26
58
  if (!anchor || !floating) return
27
59
 
60
+ const [preferredSide, align] = splitPlacement(placement)
28
61
  const rect = anchor.getBoundingClientRect()
62
+ const viewportWidth = window.innerWidth
63
+ const viewportHeight = window.innerHeight
64
+
29
65
  floating.style.position = 'fixed'
30
- floating.style.left = `${rect.left}px`
31
66
  floating.style.bottom = ''
32
67
  floating.style.right = ''
33
- floating.style.zIndex = '50'
68
+ floating.style.zIndex = String(zIndex)
34
69
 
35
70
  if (matchWidth) {
36
71
  floating.style.width = `${rect.width}px`
@@ -40,22 +75,58 @@ export function useAnchorPosition(
40
75
  floating.style.minWidth = ''
41
76
  }
42
77
 
43
- if (placement === 'top-start') {
44
- const height = floating.offsetHeight || floating.getBoundingClientRect().height
45
- floating.style.top = `${rect.top - height - 4}px`
46
- } else {
47
- floating.style.top = `${rect.bottom + 4}px`
48
- }
78
+ const width = floating.offsetWidth || floating.getBoundingClientRect().width
79
+ const height = floating.offsetHeight || floating.getBoundingClientRect().height
80
+
81
+ const roomBelow = viewportHeight - rect.bottom - gutter - padding
82
+ const roomAbove = rect.top - gutter - padding
83
+ const room = preferredSide === 'top' ? roomAbove : roomBelow
84
+ const side =
85
+ room >= height
86
+ ? preferredSide
87
+ : roomAbove > roomBelow
88
+ ? 'top'
89
+ : 'bottom'
90
+ const available = side === 'top' ? roomAbove : roomBelow
91
+
92
+ let left = rect.left
93
+ if (align === 'end') left = rect.right - width
94
+ else if (align === 'center') left = rect.left + rect.width / 2 - width / 2
95
+
96
+ const maxLeft = Math.max(padding, viewportWidth - width - padding)
97
+ left = Math.min(Math.max(padding, left), maxLeft)
98
+
99
+ const top =
100
+ side === 'top'
101
+ ? Math.max(padding, rect.top - gutter - Math.min(height, Math.max(available, 0)))
102
+ : Math.min(
103
+ rect.bottom + gutter,
104
+ Math.max(padding, viewportHeight - height - padding),
105
+ )
106
+
107
+ floating.style.left = `${Math.round(left)}px`
108
+ floating.style.top = `${Math.round(top)}px`
109
+ floating.style.setProperty('--aeon-available-height', `${Math.round(Math.max(available, 0))}px`)
110
+ floating.style.setProperty('--aeon-anchor-width', `${Math.round(rect.width)}px`)
111
+ floating.dataset.side = side
112
+ floating.dataset.align = align
113
+ }
114
+
115
+ const schedule = () => {
116
+ cancelAnimationFrame(frame)
117
+ frame = requestAnimationFrame(update)
49
118
  }
50
119
 
51
120
  update()
52
- requestAnimationFrame(update)
121
+ // Measure again once the surface has laid out (flip/clamp need real height).
122
+ schedule()
53
123
 
54
- window.addEventListener('resize', update)
55
- window.addEventListener('scroll', update, true)
124
+ window.addEventListener('resize', schedule)
125
+ window.addEventListener('scroll', schedule, true)
56
126
  return () => {
57
- window.removeEventListener('resize', update)
58
- window.removeEventListener('scroll', update, true)
127
+ cancelAnimationFrame(frame)
128
+ window.removeEventListener('resize', schedule)
129
+ window.removeEventListener('scroll', schedule, true)
59
130
  }
60
- }, [active, anchorRef, floatingRef, placement, matchWidth])
131
+ }, [active, anchorRef, floatingRef, placement, matchWidth, gutter, zIndex, padding])
61
132
  }
@@ -51,16 +51,21 @@ const Root = forwardRef<HTMLDivElement, MenuRootProps>(function MenuRoot(
51
51
  ref,
52
52
  ) {
53
53
  const [snapshot, send] = useAeonMachine(popoverMachine, { input: { open: defaultOpen } })
54
- const resolvedOpen = open ?? snapshot.context.open
54
+ const machineOpen = snapshot.context.open
55
+ const resolvedOpen = open ?? machineOpen
55
56
  const rootRef = useRef<HTMLDivElement>(null)
56
57
  const triggerRef = useRef<HTMLButtonElement>(null)
57
58
  const positionerRef = useRef<HTMLDivElement>(null)
58
59
  const triggerId = useId()
59
60
  const menuId = useId()
60
61
 
62
+ /** Machine intent, so a controlled owner still hears its own trigger. */
63
+ const reportedOpen = useRef(machineOpen)
61
64
  useEffect(() => {
62
- onOpenChange?.(resolvedOpen)
63
- }, [resolvedOpen, onOpenChange])
65
+ if (reportedOpen.current === machineOpen) return
66
+ reportedOpen.current = machineOpen
67
+ onOpenChange?.(machineOpen)
68
+ }, [machineOpen, onOpenChange])
64
69
 
65
70
  useEffect(() => {
66
71
  if (open !== undefined) send({ type: 'SET_OPEN', open })
@@ -129,11 +134,15 @@ export interface MenuPositionerProps extends HTMLAttributes<HTMLDivElement> {
129
134
  /** Mount menu in document.body (default true). */
130
135
  portalled?: boolean
131
136
  placement?: AnchorPlacement
137
+ /** Gap between trigger and menu (default 4). */
138
+ gutter?: number
139
+ /** Stack level for the portalled surface (default 50). */
140
+ zIndex?: number
132
141
  children?: ReactNode
133
142
  }
134
143
 
135
144
  const Positioner = forwardRef<HTMLDivElement, MenuPositionerProps>(function MenuPositioner(
136
- { portalled = true, placement = 'bottom-start', children, className, ...rest },
145
+ { portalled = true, placement = 'bottom-start', gutter, zIndex, children, className, ...rest },
137
146
  ref,
138
147
  ) {
139
148
  const { open, triggerRef, positionerRef } = useMenuCtx()
@@ -148,6 +157,8 @@ const Positioner = forwardRef<HTMLDivElement, MenuPositionerProps>(function Menu
148
157
  positionerRef={positionerRef}
149
158
  portalled={portalled}
150
159
  placement={placement}
160
+ gutter={gutter}
161
+ zIndex={zIndex}
151
162
  className={className}
152
163
  {...rest}
153
164
  >
@@ -160,13 +171,41 @@ const Content = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(funct
160
171
  { onKeyDown, ...rest },
161
172
  ref,
162
173
  ) {
163
- const { open, send, menuId } = useMenuCtx()
174
+ const { open, send, menuId, triggerRef } = useMenuCtx()
175
+ const contentRef = useRef<HTMLDivElement | null>(null)
176
+
177
+ // Focus enters the surface while it is open and returns to the trigger on
178
+ // dismiss, so keyboard users stay inside the menu they opened.
179
+ useEffect(() => {
180
+ if (!open) return
181
+ const trigger = triggerRef.current
182
+ contentRef.current?.focus({ preventScroll: true })
183
+ return () => {
184
+ if (trigger?.isConnected) trigger.focus({ preventScroll: true })
185
+ }
186
+ }, [open, triggerRef])
187
+
188
+ // Escape closes wherever focus sits — trigger, item or elsewhere on the page.
189
+ useEffect(() => {
190
+ if (!open) return
191
+ const onDocumentKeyDown = (event: globalThis.KeyboardEvent) => {
192
+ if (event.key === 'Escape') send({ type: 'ESCAPE' })
193
+ }
194
+ document.addEventListener('keydown', onDocumentKeyDown)
195
+ return () => document.removeEventListener('keydown', onDocumentKeyDown)
196
+ }, [open, send])
197
+
164
198
  if (!open) return null
165
199
  return (
166
200
  <div
167
- ref={ref}
201
+ ref={(node) => {
202
+ contentRef.current = node
203
+ if (typeof ref === 'function') ref(node)
204
+ else if (ref) ref.current = node
205
+ }}
168
206
  id={menuId}
169
207
  role="menu"
208
+ tabIndex={-1}
170
209
  {...mergeProps(
171
210
  partAttrs(menuAnatomy.scope, menuAnatomy.content, { state: 'open' }),
172
211
  {
@@ -53,16 +53,21 @@ const Root = forwardRef<HTMLDivElement, PopoverRootProps>(function PopoverRoot(
53
53
  const [snapshot, send] = useAeonMachine(popoverMachine, {
54
54
  input: { open: defaultOpen },
55
55
  })
56
- const resolvedOpen = open ?? snapshot.context.open
56
+ const machineOpen = snapshot.context.open
57
+ const resolvedOpen = open ?? machineOpen
57
58
  const rootRef = useRef<HTMLDivElement>(null)
58
59
  const triggerRef = useRef<HTMLButtonElement>(null)
59
60
  const positionerRef = useRef<HTMLDivElement>(null)
60
61
  const triggerId = useId()
61
62
  const contentId = useId()
62
63
 
64
+ /** Machine intent, so a controlled owner still hears its own trigger. */
65
+ const reportedOpen = useRef(machineOpen)
63
66
  useEffect(() => {
64
- onOpenChange?.(resolvedOpen)
65
- }, [resolvedOpen, onOpenChange])
67
+ if (reportedOpen.current === machineOpen) return
68
+ reportedOpen.current = machineOpen
69
+ onOpenChange?.(machineOpen)
70
+ }, [machineOpen, onOpenChange])
66
71
 
67
72
  useEffect(() => {
68
73
  if (open !== undefined) send({ type: 'SET_OPEN', open })
@@ -132,11 +137,29 @@ export interface PopoverPositionerProps extends HTMLAttributes<HTMLDivElement> {
132
137
  /** Mount panel in document.body (default true). */
133
138
  portalled?: boolean
134
139
  placement?: AnchorPlacement
140
+ /** Align to this region rather than the trigger. See `FloatingPositioner`. */
141
+ anchorRef?: React.RefObject<HTMLElement | null>
142
+ /** Size the panel to the anchor, so it spans the region it belongs to. */
143
+ matchAnchorWidth?: boolean
144
+ /** Gap between trigger and panel (default 4). */
145
+ gutter?: number
146
+ /** Stack level for the portalled surface (default 50). */
147
+ zIndex?: number
135
148
  children?: ReactNode
136
149
  }
137
150
 
138
151
  const Positioner = forwardRef<HTMLDivElement, PopoverPositionerProps>(function PopoverPositioner(
139
- { portalled = true, placement = 'bottom-start', children, className, ...rest },
152
+ {
153
+ portalled = true,
154
+ placement = 'bottom-start',
155
+ anchorRef,
156
+ matchAnchorWidth = false,
157
+ gutter,
158
+ zIndex,
159
+ children,
160
+ className,
161
+ ...rest
162
+ },
140
163
  ref,
141
164
  ) {
142
165
  const { open, triggerRef, positionerRef } = usePopoverCtx()
@@ -149,8 +172,12 @@ const Positioner = forwardRef<HTMLDivElement, PopoverPositionerProps>(function P
149
172
  part={popoverAnatomy.positioner}
150
173
  triggerRef={triggerRef}
151
174
  positionerRef={positionerRef}
175
+ anchorRef={anchorRef}
176
+ matchAnchorWidth={matchAnchorWidth}
152
177
  portalled={portalled}
153
178
  placement={placement}
179
+ gutter={gutter}
180
+ zIndex={zIndex}
154
181
  className={className}
155
182
  {...rest}
156
183
  >
@@ -16,22 +16,30 @@ import {
16
16
  import { useAeonMachine } from '../hooks/use-aeon-machine.js'
17
17
  import { mergeProps } from '../utils/merge-props.js'
18
18
 
19
- interface SliderContextValue {
19
+ export interface SliderContextValue {
20
20
  value: number
21
21
  min: number
22
22
  max: number
23
23
  step: number
24
24
  disabled: boolean
25
25
  dragging: boolean
26
+ /** Nested region value from the machine: `interaction.idle` | `interaction.dragging`. */
27
+ interaction: 'idle' | 'dragging'
26
28
  send: ReturnType<typeof useAeonMachine<typeof sliderMachine>>[1]
27
29
  onValueChange?: (value: number) => void
30
+ /** Snap, store and report a user-intended value. */
31
+ commit: (value: number) => void
28
32
  setValueFromClientX: (clientX: number) => void
29
33
  trackRef: React.RefObject<HTMLDivElement | null>
30
34
  }
31
35
 
32
36
  const SliderCtx = createContext<SliderContextValue | null>(null)
33
37
 
34
- function useSliderCtx() {
38
+ /**
39
+ * Read the slider's live machine state from inside `Slider.Root`.
40
+ * `interaction` is the nested region value — the same thing the snapshot holds.
41
+ */
42
+ export function useSliderContext() {
35
43
  const ctx = useContext(SliderCtx)
36
44
  if (!ctx) throw new Error('Slider parts must be used within Slider.Root')
37
45
  return ctx
@@ -83,14 +91,33 @@ const Root = forwardRef<HTMLDivElement, SliderRootProps>(function SliderRoot(
83
91
  const resolvedValue = value ?? snapshot.context.value
84
92
  const dragging = snapshot.matches({ interaction: 'dragging' })
85
93
 
86
- useEffect(() => {
87
- onValueChange?.(resolvedValue)
88
- }, [resolvedValue, onValueChange])
89
-
90
94
  useEffect(() => {
91
95
  if (value !== undefined) send({ type: 'SET_VALUE', value })
92
96
  }, [value, send])
93
97
 
98
+ useEffect(() => {
99
+ send({ type: 'SET_BOUNDS', min, max, step })
100
+ }, [min, max, step, send])
101
+
102
+ useEffect(() => {
103
+ send({ type: 'SET_DISABLED', disabled })
104
+ }, [disabled, send])
105
+
106
+ /**
107
+ * Interaction is emitted here, never echoed from the resolved value. A
108
+ * controlled slider therefore reports what the user did (Aeon ≤1.4.0 stayed
109
+ * silent while `value` was set) and never fires on mount.
110
+ */
111
+ const commit = useCallback(
112
+ (next: number) => {
113
+ if (disabled) return
114
+ const snapped = snapSliderValue(next, min, max, step)
115
+ send({ type: 'SET_VALUE', value: snapped })
116
+ onValueChange?.(snapped)
117
+ },
118
+ [disabled, min, max, step, send, onValueChange],
119
+ )
120
+
94
121
  const setValueFromClientX = useCallback(
95
122
  (clientX: number) => {
96
123
  const track = trackRef.current
@@ -98,11 +125,9 @@ const Root = forwardRef<HTMLDivElement, SliderRootProps>(function SliderRoot(
98
125
  const rect = track.getBoundingClientRect()
99
126
  if (rect.width <= 0) return
100
127
  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)
128
+ commit(min + ratio * (max - min))
104
129
  },
105
- [disabled, min, max, step, send, value, onValueChange],
130
+ [commit, disabled, min, max],
106
131
  )
107
132
 
108
133
  useEffect(() => {
@@ -125,12 +150,25 @@ const Root = forwardRef<HTMLDivElement, SliderRootProps>(function SliderRoot(
125
150
  step,
126
151
  disabled,
127
152
  dragging,
153
+ interaction: (dragging ? 'dragging' : 'idle') as 'idle' | 'dragging',
128
154
  send,
129
155
  onValueChange,
156
+ commit,
130
157
  setValueFromClientX,
131
158
  trackRef,
132
159
  }),
133
- [resolvedValue, min, max, step, disabled, dragging, send, onValueChange, setValueFromClientX],
160
+ [
161
+ resolvedValue,
162
+ min,
163
+ max,
164
+ step,
165
+ disabled,
166
+ dragging,
167
+ send,
168
+ onValueChange,
169
+ commit,
170
+ setValueFromClientX,
171
+ ],
134
172
  )
135
173
 
136
174
  return (
@@ -156,7 +194,7 @@ const Track = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(functio
156
194
  { onPointerDown, ...rest },
157
195
  ref,
158
196
  ) {
159
- const { disabled, send, setValueFromClientX, trackRef } = useSliderCtx()
197
+ const { disabled, send, setValueFromClientX, trackRef } = useSliderContext()
160
198
 
161
199
  return (
162
200
  <div
@@ -186,7 +224,7 @@ const Range = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(functio
186
224
  { style, ...rest },
187
225
  ref,
188
226
  ) {
189
- const { value, min, max } = useSliderCtx()
227
+ const { value, min, max } = useSliderContext()
190
228
  return (
191
229
  <div
192
230
  ref={ref}
@@ -201,7 +239,8 @@ const Thumb = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(funct
201
239
  { onKeyDown, onPointerDown, style, ...rest },
202
240
  ref,
203
241
  ) {
204
- const { value, min, max, step, disabled, dragging, send, setValueFromClientX } = useSliderCtx()
242
+ const { value, min, max, step, disabled, dragging, send, commit, setValueFromClientX } =
243
+ useSliderContext()
205
244
 
206
245
  return (
207
246
  <span
@@ -229,19 +268,27 @@ const Thumb = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(funct
229
268
  if (disabled || e.defaultPrevented) return
230
269
  if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
231
270
  e.preventDefault()
232
- send({ type: 'STEP', delta: step })
271
+ commit(value + step)
233
272
  }
234
273
  if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
235
274
  e.preventDefault()
236
- send({ type: 'STEP', delta: -step })
275
+ commit(value - step)
276
+ }
277
+ if (e.key === 'PageUp') {
278
+ e.preventDefault()
279
+ commit(value + (max - min) / 10)
280
+ }
281
+ if (e.key === 'PageDown') {
282
+ e.preventDefault()
283
+ commit(value - (max - min) / 10)
237
284
  }
238
285
  if (e.key === 'Home') {
239
286
  e.preventDefault()
240
- send({ type: 'HOME' })
287
+ commit(min)
241
288
  }
242
289
  if (e.key === 'End') {
243
290
  e.preventDefault()
244
- send({ type: 'END' })
291
+ commit(max)
245
292
  }
246
293
  },
247
294
  },
@@ -255,7 +302,7 @@ const ValueText = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(f
255
302
  props,
256
303
  ref,
257
304
  ) {
258
- const { value } = useSliderCtx()
305
+ const { value } = useSliderContext()
259
306
  return (
260
307
  <span ref={ref} {...mergeProps(partOnlyAttrs(sliderAnatomy.valueText), props)}>
261
308
  {props.children ?? value}
@@ -272,11 +272,15 @@ export interface TooltipPositionerProps extends HTMLAttributes<HTMLDivElement> {
272
272
  /** Mount tooltip in document.body (default true). */
273
273
  portalled?: boolean
274
274
  placement?: AnchorPlacement
275
+ /** Gap between trigger and tip (default 4). */
276
+ gutter?: number
277
+ /** Stack level for the portalled surface (default 50). */
278
+ zIndex?: number
275
279
  children?: ReactNode
276
280
  }
277
281
 
278
282
  const Positioner = forwardRef<HTMLDivElement, TooltipPositionerProps>(function TooltipPositioner(
279
- { portalled = true, placement = 'top-start', children, className, ...rest },
283
+ { portalled = true, placement = 'top-start', gutter, zIndex, children, className, ...rest },
280
284
  ref,
281
285
  ) {
282
286
  const { open, triggerRef, positionerRef } = useTooltipCtx()
@@ -291,6 +295,8 @@ const Positioner = forwardRef<HTMLDivElement, TooltipPositionerProps>(function T
291
295
  positionerRef={positionerRef}
292
296
  portalled={portalled}
293
297
  placement={placement}
298
+ gutter={gutter}
299
+ zIndex={zIndex}
294
300
  className={className}
295
301
  {...rest}
296
302
  >
@@ -926,7 +926,9 @@ When generating Aeon UI, constrain props/parts/states to @aeon-ui/schemas catalo
926
926
  Import headless from @aeon-ui/react or styled from @aeon-ui/ui.
927
927
  Install: npm install aeon-ui-engine react react-dom xstate @xstate/react
928
928
  CSS: import 'aeon-ui-engine/aeon.css'
929
- Vite: aeonUiVitePlugin() from aeon-ui-engine/vite
929
+ Link: postinstall + npx aeon-ui link (node_modules/@aeon-ui/*)
930
+ Next.js: transpilePackages for @aeon-ui/* + "use client"; Vite plugin optional
931
+ Vue/Solid/Svelte: thin adapters only \u2014 React is the full catalog
930
932
 
931
933
  ${STATECHART_PROMPTS.async}
932
934
 
@@ -1 +1 @@
1
- {"version":"4.1.8","results":[[":src/index.test.ts",{"duration":5.161209000000014,"failed":false}],[":src/aeon-to-schema.test.ts",{"duration":5.569166999999993,"failed":false}]]}
1
+ {"version":"4.1.8","results":[[":src/index.test.ts",{"duration":42.299125000000004,"failed":false}],[":src/aeon-to-schema.test.ts",{"duration":7.255999999999972,"failed":false}]]}
@@ -35,7 +35,9 @@ When generating Aeon UI, constrain props/parts/states to @aeon-ui/schemas catalo
35
35
  Import headless from @aeon-ui/react or styled from @aeon-ui/ui.
36
36
  Install: npm install aeon-ui-engine react react-dom xstate @xstate/react
37
37
  CSS: import 'aeon-ui-engine/aeon.css'
38
- Vite: aeonUiVitePlugin() from aeon-ui-engine/vite
38
+ Link: postinstall + npx aeon-ui link (node_modules/@aeon-ui/*)
39
+ Next.js: transpilePackages for @aeon-ui/* + "use client"; Vite plugin optional
40
+ Vue/Solid/Svelte: thin adapters only — React is the full catalog
39
41
 
40
42
  ## Async region statechart
41
43
  States: idle → loading → (success | empty | failure); REFRESH from success/empty/failure; STALE flag while success.
@@ -2,6 +2,8 @@
2
2
  "id": "slider",
3
3
  "initial": "interaction",
4
4
  "events": [
5
+ "SET_BOUNDS",
6
+ "SET_DISABLED",
5
7
  "SET_VALUE",
6
8
  "STEP",
7
9
  "HOME",
@@ -14,6 +16,8 @@
14
16
  "id": "slider",
15
17
  "type": "compound",
16
18
  "events": [
19
+ "SET_BOUNDS",
20
+ "SET_DISABLED",
17
21
  "SET_VALUE",
18
22
  "STEP",
19
23
  "HOME",
@@ -21,7 +25,18 @@
21
25
  "POINTER_DOWN",
22
26
  "POINTER_UP"
23
27
  ],
24
- "transitions": [],
28
+ "transitions": [
29
+ {
30
+ "event": "SET_BOUNDS",
31
+ "target": [],
32
+ "guarded": false
33
+ },
34
+ {
35
+ "event": "SET_DISABLED",
36
+ "target": [],
37
+ "guarded": false
38
+ }
39
+ ],
25
40
  "children": [
26
41
  {
27
42
  "key": "interaction",
@@ -110,6 +125,8 @@
110
125
  "states": {
111
126
  "slider": {
112
127
  "events": [
128
+ "SET_BOUNDS",
129
+ "SET_DISABLED",
113
130
  "SET_VALUE",
114
131
  "STEP",
115
132
  "HOME",
@@ -128,7 +128,9 @@ When generating Aeon UI, constrain props/parts/states to @aeon-ui/schemas catalo
128
128
  Import headless from @aeon-ui/react or styled from @aeon-ui/ui.
129
129
  Install: npm install aeon-ui-engine react react-dom xstate @xstate/react
130
130
  CSS: import 'aeon-ui-engine/aeon.css'
131
- Vite: aeonUiVitePlugin() from aeon-ui-engine/vite
131
+ Link: postinstall + npx aeon-ui link (node_modules/@aeon-ui/*)
132
+ Next.js: transpilePackages for @aeon-ui/* + "use client"; Vite plugin optional
133
+ Vue/Solid/Svelte: thin adapters only — React is the full catalog
132
134
 
133
135
  ${STATECHART_PROMPTS.async}
134
136
 
@@ -0,0 +1,29 @@
1
+ # @aeon-ui/tree
2
+
3
+ Widget trees for Aeon. A widget is configuration — immutable data describing one face of a chart — interpreted into DOM that carries `data-aeon-scope`, `data-aeon-part`, and `data-aeon-state`.
4
+
5
+ This is Flutter's Widget and Element layers only. There is no RenderObject (layout stays CSS) and no `StatefulWidget` (the chart is the only state). A tree that stored `busy` itself would be the boolean twin the philosophy rules out.
6
+
7
+ ```ts
8
+ import { exclusiveActionWidget, interpret, uncoveredStates, EXCLUSIVE_ACTION_STATES } from '@aeon-ui/tree'
9
+
10
+ const tree = exclusiveActionWidget({
11
+ scope: 'settings-change-password',
12
+ part: 'change-password-form',
13
+ state, // snapshot token
14
+ busy,
15
+ error,
16
+ body: { type: 'host', id: 'body' },
17
+ idleLabel: 'Update password',
18
+ pendingLabel: 'Updating…',
19
+ onSubmit,
20
+ confirm: confirm !== null,
21
+ })
22
+
23
+ uncoveredStates(EXCLUSIVE_ACTION_STATES, (s) => exclusiveActionWidget({ ...args, state: s }))
24
+ // [] — every state of the chart has a face
25
+ ```
26
+
27
+ `interpret(tree, hosts)` is the element layer. Hosts are where an existing compound (a field, a `Prompt`) fills a slot the tree names. Product CSS restyles the anatomy attributes; class names are passed in by the app, not invented here.
28
+
29
+ `exclusiveActionWidget` is the one face shared by every "confirm, then await" region: `idle → confirming → busy → idle | failure`.