aeon-ui-engine 1.5.1 → 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 (43) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/package.json +2 -2
  3. package/packages/cli/node_modules/.bin/vitest +2 -2
  4. package/packages/core/node_modules/.bin/vitest +2 -2
  5. package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  6. package/packages/panda/styled-system/styles.css +0 -4
  7. package/packages/primitives/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  8. package/packages/react/dist/index.d.ts +29 -4
  9. package/packages/react/dist/index.js +85 -28
  10. package/packages/react/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  11. package/packages/react/src/dialog/dialog.test.tsx +27 -0
  12. package/packages/react/src/dialog/index.tsx +11 -3
  13. package/packages/react/src/floating/FloatingPositioner.tsx +11 -1
  14. package/packages/react/src/menu/index.tsx +8 -3
  15. package/packages/react/src/popover/index.tsx +25 -4
  16. package/packages/react/src/slider/index.tsx +13 -6
  17. package/packages/schemas/dist/index.js +3 -1
  18. package/packages/schemas/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/packages/schemas/prompts/statecharts.md +1 -1
  20. package/packages/schemas/registry/machines/slider.json +18 -1
  21. package/packages/tree/README.md +29 -0
  22. package/packages/tree/dist/index.d.ts +97 -0
  23. package/packages/tree/dist/index.js +107 -0
  24. package/packages/tree/node_modules/.bin/esbuild +21 -0
  25. package/packages/tree/node_modules/.bin/jiti +21 -0
  26. package/packages/tree/node_modules/.bin/tsc +21 -0
  27. package/packages/tree/node_modules/.bin/tsserver +21 -0
  28. package/packages/tree/node_modules/.bin/tsup +21 -0
  29. package/packages/tree/node_modules/.bin/tsup-node +21 -0
  30. package/packages/tree/node_modules/.bin/tsx +21 -0
  31. package/packages/tree/node_modules/.bin/vite +21 -0
  32. package/packages/tree/node_modules/.bin/vitest +21 -0
  33. package/packages/tree/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  34. package/packages/tree/package.json +42 -0
  35. package/packages/tree/src/exclusive-action.test.ts +54 -0
  36. package/packages/tree/src/exclusive-action.ts +104 -0
  37. package/packages/tree/src/index.ts +7 -0
  38. package/packages/tree/src/interpret.tsx +45 -0
  39. package/packages/tree/src/totality.ts +20 -0
  40. package/packages/tree/src/widget.ts +31 -0
  41. package/packages/tree/tsconfig.json +8 -0
  42. package/packages/tree/vitest.config.ts +8 -0
  43. package/scripts/vite-aliases.mjs +3 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,117 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.0] - 2026-09-27
4
+
5
+ ### Added
6
+
7
+ - **`Popover.Positioner` / `FloatingPositioner` `anchorRef` + `matchAnchorWidth`.** Position a surface against the region it belongs to instead of the small trigger pinned to its edge, so a wide panel lands where the eye already is rather than being clamped back from the viewport edge.
8
+ - **Optional philosophy note:** [docs/ANALOGY.md](./docs/ANALOGY.md) — Aeon as metaphor for formless truth and sensory projection (not product doctrine). Linked from [PHILOSOPHY.md](./PHILOSOPHY.md) and the docs index.
9
+ - `@aeon-ui/tree` — widget trees. A widget is immutable data describing one face
10
+ of a chart, interpreted into DOM with `data-aeon-scope|part|state`. Flutter's
11
+ Widget and Element layers only: no RenderObject (layout stays CSS), no
12
+ `StatefulWidget` (the chart is the only state).
13
+ - `exclusiveActionWidget` — the shared face for "confirm, then await" regions
14
+ (`idle → confirming → busy → idle | failure`): pending label, disabled set,
15
+ failure copy and confirm slot are functions of the snapshot.
16
+ - `uncoveredStates(states, project)` — totality as a test: every state the chart
17
+ names must appear on the tree projected for it.
18
+ - `interpret(tree, hosts)` — element layer; hosts slot existing compounds
19
+ (`Prompt`, fields) into names the tree declares.
20
+ - Vite aliases, optimizeDeps exclude and tsconfig paths include `@aeon-ui/tree`.
21
+
22
+ ## [1.7.7] - 2026-08-18
23
+
24
+ ### Added
25
+
26
+ - **Three more primitives in the live section, chosen for chart complexity.** `Slider` is hierarchical — the snapshot reports `interaction.dragging`, so the chart lights the region *and* the leaf inside it while you drag, and an arrow key shows `SET_VALUE` writing context without leaving the state. `FileUpload` runs a real drag-and-drop lifecycle (`idle` / `dragging` / `rejected`), driveable by dragging a file or by sending the same events from buttons. `Toast` is the one chart that moves without you: `visible` carries a delayed `TIMEOUT` that retires it on the machine's own clock.
27
+ - **`useSliderContext`** exported from `@aeon-ui/react`, so a slider's live machine state (including the nested `interaction` value) is readable from inside `Slider.Root` — matching `useAsyncContext` / `useFieldContext` / `useStickyBarContext`.
28
+
29
+ ### Fixed
30
+
31
+ - **The live chart never highlighted a component's *initial* state.** A late SVG swap replaced the diagram after the highlight was painted, so the first snapshot silently went dark until you interacted. The explorer now repaints when the rendered chart changes.
32
+ - **`valid` also lit up `invalid`.** Node matching compared id substrings, so any state whose name contained another's was highlighted too. Matching is now segment-aware, and a region's `[*]` marker no longer inherits its parent's highlight.
33
+ - **Nested snapshot values are highlighted properly.** A dotted path such as `interaction.dragging` now lights every node on the path, since the ancestors are genuinely active.
34
+ - **Live charts are readable.** The explorer box was short enough that parallel and nested diagrams auto-fit to ~30%; it now has room, and the slider's nested region lays out left-to-right.
35
+
36
+ ## [1.7.6] - 2026-08-18
37
+
38
+ ### Changed
39
+
40
+ - **Hero video: resolved an apparent contradiction in the narration.** "Nothing else exists" (about the async machine) could read as conflicting with the later child-machine states (broadcasting, seen, mined). The closed-world line is now scoped — "that's this machine's entire world" — and the hierarchy scene opens with the bridge: "Need more states? Charts nest." Transcript re-aligned (61s).
41
+
42
+ ## [1.7.5] - 2026-08-18
43
+
44
+ ### Changed
45
+
46
+ - **Hero video narration re-recorded for clarity.** "Statechart" is now fed to TTS as "state chart" so it isn't mispronounced (the on-screen subtitle keeps the correct spelling — `speak` overrides audio only, the same mechanism the closing URL already used). The parallel-regions explanation is rebuilt around three plain questions — "Has it been edited? Does it pass? Is it sending?" — and each chart lane now prints its question next to the region name, so the words and the diagram agree. Also clearer: "no in-between states" replaces "no half states", "your CSS targets that attribute" replaces "your styles key off it", and the child machine's middle state is spoken as "seen on the network" rather than a bare "seen". Transcript re-aligned (59s).
47
+
48
+ ## [1.7.4] - 2026-08-18
49
+
50
+ ### Changed
51
+
52
+ - **Hero video intro now shows the pain, and the ending is de-branded.** The opening scene names the actual symptoms of boolean soup — a mystery spinner with no context, two buttons wired to the same job (`Retry` / `Reload`), and "state is a mess" — instead of an abstract truth count. The narration no longer says the word "dirty" (the state node still shows it, since that's the real machine state). The hierarchy scene presents the nested send flow as "a real example / wallet · send" without naming the product. Transcript re-aligned (57s).
53
+
54
+ ## [1.7.3] - 2026-08-18
55
+
56
+ ### Changed
57
+
58
+ - **Hero video: plainer intro, glossed jargon, and a real hierarchical chart from a shipping product.** The intro now shows three plain `loading` / `empty` / `error` toggles with what each means in English, instead of prop-name jargon. The parallel-regions scene explains `dirty` outright ("dirty just means the user typed") and prints a plain-English gloss under the composite value. The machine catalog scene is replaced with the send flow from HandCash Desktop: the `sendPayment` chart (`editing → confirming → handoff`) with the `txLifecycle` child machine (`broadcasting → seenInMempool → mined`) nested inside `handoff` — the wallet surface and the breadcrumb (`handoff › broadcasting`) stay in lockstep with the chart. Also fixed a renderer bug where PIL ignored text alpha, so every text fade-in had been popping at full opacity (visible as floating state labels before their nodes appeared). Transcript re-aligned (55s).
59
+
60
+ ## [1.7.2] - 2026-08-18
61
+
62
+ ### Changed
63
+
64
+ - **New hero video, built from scratch to teach statecharts — not one animated state.** Seven scenes: three booleans producing a screen with a spinner and an error at once; the async machine with every state named and every edge labelled with its event; an event with no matching edge blocked at the door; the field machine's three parallel regions all true at once beside the surface that projects them; `state → data-aeon-state → pixels`; and four components each running their own machine on their own clock. Real system typography (SF / SF Mono), a poster frame from the chart scene, faststart streaming, and re-aligned transcript cues (52s).
65
+
66
+ ## [1.7.1] - 2026-08-18
67
+
68
+ ### Changed
69
+
70
+ - **Hero video now shows the actual thesis.** Previously the statechart node swept on its own float while the phone face animated separately, so the chart and the screen never agreed. The centrepiece is now one coupled demo: the component face and the highlighted chart node are the same state, driven by a single step timeline, with the causing event (`FETCH`, `RESOLVE`, `REJECT`, `REFRESH`) labelled on the transition edge — the same idea as the live section. The closing card points at "See it live" instead of the removed builder; transcript cues re-aligned.
71
+
72
+ ## [1.7.0] - 2026-08-18
73
+
74
+ ### Added
75
+
76
+ - **Live section: real components beside the chart that tracks them.** Async, Button, Field, and Dialog run their shipped machines; the Mermaid chart highlights the state the component is actually in and names the event that caused the move. Field reports all three regions at once, because a field has no single state.
77
+ - Machine-accurate diagrams for `buttonLifecycleMachine`, `dialogMachine`, and `fieldMachine`; the async chart now shows its `REFRESH` and re-fetch edges. `aeonDiagrams.test.ts` fails if a chart misses a machine state or invents one.
78
+
79
+ ### Fixed
80
+
81
+ - **A controlled `Dialog`, `Popover`, or `Menu` could never be opened by its own trigger.** `onOpenChange` reported the resolved prop, so an owner holding `open` never heard the machine's intent — the trigger, escape key, and outside click were all dead. Each root now reports the machine's own transition (and no longer fires on mount).
82
+
83
+ ### Removed
84
+
85
+ - **The generative app builder is gone** — sandbox, chart→schema compiler, live composer, model client, cost meter, and the `/api/aeon-generate` and `/api/aeon-scrape` endpoints, plus the OpenRouter proxy plugin and its env keys (~6,000 lines). Landing nav, hero, and header CTAs point at the live section.
86
+ - The static "Determinism" diagram section, now that the live charts replace it. `expandActiveStates` lost its builder-specific token aliases.
87
+
88
+ ## [1.6.1] - 2026-08-18
89
+
90
+ ### Changed
91
+
92
+ - **Builder previews read like designs, not state dumps.** The first screen now leads with the site's own hero headline and description (scraped, never invented); other screens are titled by their state once. Actions promote one primary button and group the rest instead of stacking equal buttons. The app bar no longer repeats the state label the dock already shows.
93
+ - **Device selector is back** — phone / tablet / desktop toggle above the preview, with the desktop frame showing the scraped host in its address bar.
94
+
95
+ ## [1.6.0] - 2026-08-18
96
+
97
+ ### Added
98
+
99
+ - **Redesign any site from its URL** — paste a domain into the builder and `/api/aeon-scrape` reads the page for its name, description, nav, actions, logo, and `theme-color`. The brand becomes chrome (logo in the app bar, accent palette scoped to the preview) and the page content becomes the prompt. Private and non-HTTP hosts are refused.
100
+ - `builder/projectFaces.ts` — generic chart projection: states become screens, transitions become actions, `collect` demos add a field, `async` demos add an async region.
101
+ - Demo tests now run in CI (`pnpm test`), covering projection, scraping, palette derivation, and a server render of the builder output.
102
+
103
+ ### Changed
104
+
105
+ - **App builder rebuilt around one input.** Prompt or URL, Build, preview. The starter chips, engine bar, cost meter, inspector tabs, device picker, and build loader are gone.
106
+ - **The model returns a chart and nothing else; the UI is projected from it.** Previously ~1,900 lines of prompt keyword matching decided that "match" meant a dating app, "faq" meant an accordion, and "pong" meant paddles, then filled screens with seeded catalogs, listings, and test stimuli. All of it is deleted, along with the preset app schemas it produced.
107
+ - Empty nav tabs are dropped instead of being filled with placeholder screens, and the offline fallback chart is deliberately shapeless rather than a product guess.
108
+
109
+ ## [1.5.2] - 2026-08-18
110
+
111
+ ### Changed
112
+
113
+ - **Landing page trimmed to essentials** — removed the pillars grid, the numbered setup box, and the full rule-file dump; the Cursor section is now one command plus a copy button. Hero shows a single install line, the why section keeps only the same-prompt contrast, specs show one chart, and the FAQ is four questions.
114
+
3
115
  ## [1.5.1] - 2026-08-18
4
116
 
5
117
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aeon-ui-engine",
3
- "version": "1.5.1",
3
+ "version": "1.8.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  "dev": "pnpm --filter @aeon-ui/demo dev",
35
35
  "examples": "pnpm --filter @aeon-ui/examples dev",
36
36
  "typecheck": "pnpm -r run typecheck",
37
- "test": "pnpm run test:headless",
37
+ "test": "pnpm run test:headless && pnpm --filter @aeon-ui/demo run test",
38
38
  "test:headless": "pnpm run build:headless && pnpm --filter @aeon-ui/core --filter @aeon-ui/primitives --filter @aeon-ui/react run test",
39
39
  "changeset": "changeset",
40
40
  "version-packages": "changeset version",
@@ -10,9 +10,9 @@ case `uname` in
10
10
  esac
11
11
 
12
12
  if [ -z "$NODE_PATH" ]; then
13
- export NODE_PATH="/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.28.0_jiti@2.7.0_tsx@4.22.3_/node_modules/vitest/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.28.0_jiti@2.7.0_tsx@4.22.3_/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/node_modules"
13
+ export NODE_PATH="/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.27.7_jiti@2.7.0_tsx@4.22.3_/node_modules/vitest/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.27.7_jiti@2.7.0_tsx@4.22.3_/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/node_modules"
14
14
  else
15
- export NODE_PATH="/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.28.0_jiti@2.7.0_tsx@4.22.3_/node_modules/vitest/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.28.0_jiti@2.7.0_tsx@4.22.3_/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/node_modules:$NODE_PATH"
15
+ export NODE_PATH="/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.27.7_jiti@2.7.0_tsx@4.22.3_/node_modules/vitest/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.27.7_jiti@2.7.0_tsx@4.22.3_/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/node_modules:$NODE_PATH"
16
16
  fi
17
17
  if [ -x "$basedir/node" ]; then
18
18
  exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
@@ -10,9 +10,9 @@ case `uname` in
10
10
  esac
11
11
 
12
12
  if [ -z "$NODE_PATH" ]; then
13
- export NODE_PATH="/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.27.7_jiti@2.7.0_tsx@4.22.3_/node_modules/vitest/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.27.7_jiti@2.7.0_tsx@4.22.3_/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/node_modules"
13
+ export NODE_PATH="/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.28.0_jiti@2.7.0_tsx@4.22.3_/node_modules/vitest/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.28.0_jiti@2.7.0_tsx@4.22.3_/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/node_modules"
14
14
  else
15
- export NODE_PATH="/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.27.7_jiti@2.7.0_tsx@4.22.3_/node_modules/vitest/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.27.7_jiti@2.7.0_tsx@4.22.3_/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/node_modules:$NODE_PATH"
15
+ export NODE_PATH="/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.28.0_jiti@2.7.0_tsx@4.22.3_/node_modules/vitest/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/vitest@4.1.8_@types+node@22.19.19_jsdom@29.1.1_vite@8.0.16_@types+node@22.19.19_esbuild@0.28.0_jiti@2.7.0_tsx@4.22.3_/node_modules:/Users/brandoncryderman/AeonUI/node_modules/.pnpm/node_modules:$NODE_PATH"
16
16
  fi
17
17
  if [ -x "$basedir/node" ]; then
18
18
  exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
@@ -1 +1 @@
1
- {"version":"3.2.4","results":[[":src/machine.test.ts",{"duration":7.0908339999999725,"failed":false}],[":src/theme.test.ts",{"duration":3.866333999999995,"failed":false}],[":../../apps/demo/src/aeonSchemaCompiler.test.ts",{"duration":7.737041999999974,"failed":false}],[":src/identicon.test.ts",{"duration":9.875333000000012,"failed":false}]]}
1
+ {"version":"3.2.4","results":[[":src/machine.test.ts",{"duration":3.2387080000000026,"failed":false}],[":src/theme.test.ts",{"duration":3.8104999999999905,"failed":false}],[":../../apps/demo/src/aeonSchemaCompiler.test.ts",{"duration":7.737041999999974,"failed":false}],[":src/identicon.test.ts",{"duration":5.902916000000005,"failed":false}]]}
@@ -4475,10 +4475,6 @@
4475
4475
  flex: 1 1 0%;
4476
4476
  }
4477
4477
 
4478
- .columns_4 {
4479
- columns: 4;
4480
- }
4481
-
4482
4478
  .stk-w_2 {
4483
4479
  stroke-width: 2;
4484
4480
  }
@@ -1 +1 @@
1
- {"version":"3.2.4","results":[[":src/machines/dialog.test.ts",{"duration":5.771292000000017,"failed":false}],[":src/machines/select.test.ts",{"duration":6.394416000000035,"failed":false}],[":src/__tests__/dialog.test.ts",{"duration":3.686000000000007,"failed":false}],[":src/__tests__/select.test.ts",{"duration":2.8540000000000134,"failed":false}],[":src/__tests__/floating-surface.test.ts",{"duration":13.601374999999962,"failed":false}],[":src/__tests__/app-nav.test.ts",{"duration":9.962167000000022,"failed":false}],[":src/__tests__/file-upload.test.ts",{"duration":13.053917000000013,"failed":false}],[":src/__tests__/range-slider.test.ts",{"duration":13.141374999999982,"failed":false}]]}
1
+ {"version":"3.2.4","results":[[":src/machines/dialog.test.ts",{"duration":5.771292000000017,"failed":false}],[":src/machines/select.test.ts",{"duration":6.394416000000035,"failed":false}],[":src/__tests__/dialog.test.ts",{"duration":3.686000000000007,"failed":false}],[":src/__tests__/select.test.ts",{"duration":2.8540000000000134,"failed":false}],[":src/__tests__/floating-surface.test.ts",{"duration":13.199792000000002,"failed":false}],[":src/__tests__/app-nav.test.ts",{"duration":9.69079099999999,"failed":false}],[":src/__tests__/file-upload.test.ts",{"duration":7.872375000000005,"failed":false}],[":src/__tests__/range-slider.test.ts",{"duration":8.245833000000005,"failed":false}]]}
@@ -194,6 +194,30 @@ declare const RadioGroup: {
194
194
  ItemLabel: react.ForwardRefExoticComponent<HTMLAttributes<HTMLSpanElement> & react.RefAttributes<HTMLSpanElement>>;
195
195
  };
196
196
 
197
+ /** Bridge executable statecharts to React — UI = f(snapshot). */
198
+ declare function useAeonMachine<M extends AnyStateMachine>(machine: M, options?: Parameters<typeof useMachine<M>>[1]): [xstate.StateFrom<M>, (event: xstate.EventFromLogic<M>) => void, xstate.Actor<M>];
199
+
200
+ interface SliderContextValue {
201
+ value: number;
202
+ min: number;
203
+ max: number;
204
+ step: number;
205
+ disabled: boolean;
206
+ dragging: boolean;
207
+ /** Nested region value from the machine: `interaction.idle` | `interaction.dragging`. */
208
+ interaction: 'idle' | 'dragging';
209
+ send: ReturnType<typeof useAeonMachine<typeof sliderMachine>>[1];
210
+ onValueChange?: (value: number) => void;
211
+ /** Snap, store and report a user-intended value. */
212
+ commit: (value: number) => void;
213
+ setValueFromClientX: (clientX: number) => void;
214
+ trackRef: React.RefObject<HTMLDivElement | null>;
215
+ }
216
+ /**
217
+ * Read the slider's live machine state from inside `Slider.Root`.
218
+ * `interaction` is the nested region value — the same thing the snapshot holds.
219
+ */
220
+ declare function useSliderContext(): SliderContextValue;
197
221
  interface SliderRootProps {
198
222
  value?: number;
199
223
  defaultValue?: number;
@@ -237,9 +261,6 @@ declare const RangeSlider: {
237
261
  ValueText: react.ForwardRefExoticComponent<HTMLAttributes<HTMLSpanElement> & react.RefAttributes<HTMLSpanElement>>;
238
262
  };
239
263
 
240
- /** Bridge executable statecharts to React — UI = f(snapshot). */
241
- declare function useAeonMachine<M extends AnyStateMachine>(machine: M, options?: Parameters<typeof useMachine<M>>[1]): [xstate.StateFrom<M>, (event: xstate.EventFromLogic<M>) => void, xstate.Actor<M>];
242
-
243
264
  interface FileUploadContextValue {
244
265
  phase: 'idle' | 'dragging' | 'rejected';
245
266
  accept: string;
@@ -534,6 +555,10 @@ interface PopoverPositionerProps extends HTMLAttributes<HTMLDivElement> {
534
555
  /** Mount panel in document.body (default true). */
535
556
  portalled?: boolean;
536
557
  placement?: AnchorPlacement;
558
+ /** Align to this region rather than the trigger. See `FloatingPositioner`. */
559
+ anchorRef?: React.RefObject<HTMLElement | null>;
560
+ /** Size the panel to the anchor, so it spans the region it belongs to. */
561
+ matchAnchorWidth?: boolean;
537
562
  /** Gap between trigger and panel (default 4). */
538
563
  gutter?: number;
539
564
  /** Stack level for the portalled surface (default 50). */
@@ -1186,4 +1211,4 @@ declare const Slot: react.ForwardRefExoticComponent<HTMLAttributes<HTMLElement>
1186
1211
  children?: ReactNode;
1187
1212
  } & react.RefAttributes<HTMLElement>>;
1188
1213
 
1189
- export { Accordion, type AccordionItemContentProps, type AccordionItemProps, type AccordionItemTriggerProps, type AccordionRootProps, AppNav, type AppNavBreadcrumbProps, type AppNavRootProps, type AppNavSectionProps, AppShell, type AppShellRootProps, Async, AsyncActions, AsyncProvider, AsyncReadout, AsyncRoot, AsyncTrack, Avatar, type AvatarRootProps, type AvatarStatus, Bar, type BarRootProps, Button, type ButtonRootProps, type ButtonStatus, Checkbox, type CheckboxRootProps, Combobox, type ComboboxItemProps, type ComboboxRootProps, Composer, type ComposerRootProps, type ComposerState, Content, type ContentRootProps, Conversation, type ConversationItemProps, type ConversationItemState, type ConversationRootProps, Dialog, type DialogPortalProps, type DialogRootProps, Entry, type EntryListProps, type EntryRootProps, type EntryState, Field, type FieldControlProps, type FieldRootProps, type FieldTextareaProps, FileUpload, type FileUploadRootProps, Identity, type IdentityRootProps, ListRow, type ListRowRootProps, Menu, type MenuItemProps, type MenuPositionerProps, type MenuRootProps, MetricStrip, type MetricStripRootProps, Nav, type NavItemProps, type NavRootProps, Panel, type PanelContentProps, type PanelGroupProps, type PanelLabelProps, type PanelRootProps, type PanelTriggerProps, PinInput, type PinInputInputProps, type PinInputRootProps, Popover, type PopoverPositionerProps, type PopoverRootProps, type PresenceState, ProfileHeader, type ProfileHeaderRootProps, Progress, type ProgressRootProps, Prompt, type PromptRootProps, type PromptStatus, RadioGroup, type RadioGroupItemProps, type RadioGroupRootProps, RangeSlider, type RangeSliderRootProps, type RangeSliderThumbProps, Scroll, type ScrollRootProps, type ScrollViewportProps, Select, type SelectContentProps, type SelectItemProps, type SelectPositionerProps, type SelectRootProps, Separator, type SeparatorRootProps, Slider, type SliderRootProps, Slot, StatusBanner, type StatusBannerRootProps, type StatusBannerTone, StickyBar, type StickyBarRootProps, Switch, type SwitchRootProps, Tabs, type TabsContentProps, type TabsRootProps, type TabsTriggerProps, ThemeSwitcher, type ThemeSwitcherModeBtnProps, type ThemeSwitcherModesProps, type ThemeSwitcherRootProps, type ThemeSwitcherThemeSelectProps, Thread, type ThreadItemFace, type ThreadItemProps, type ThreadMessageState, type ThreadRootProps, Toast, ToastItem, type ToastPlacement, type ToastProviderProps, type ToastPublishOptions, type ToastRecord, type ToastRootProps, type ToastStore, type ToastViewportProps, Tooltip, type TooltipPositionerProps, type TooltipRootProps, asyncStatusToContentState, useAeonMachine, useAppNav, useAppNavMachine, useAppShellContext, useAsyncContext, useContentContext, useFieldContext, useFileUploadCtx as useFileUpload, useFitText, useLayoutMode, useScrollContext, useScrollState, useStickyBarContext, useToast, useToastStore };
1214
+ export { Accordion, type AccordionItemContentProps, type AccordionItemProps, type AccordionItemTriggerProps, type AccordionRootProps, AppNav, type AppNavBreadcrumbProps, type AppNavRootProps, type AppNavSectionProps, AppShell, type AppShellRootProps, Async, AsyncActions, AsyncProvider, AsyncReadout, AsyncRoot, AsyncTrack, Avatar, type AvatarRootProps, type AvatarStatus, Bar, type BarRootProps, Button, type ButtonRootProps, type ButtonStatus, Checkbox, type CheckboxRootProps, Combobox, type ComboboxItemProps, type ComboboxRootProps, Composer, type ComposerRootProps, type ComposerState, Content, type ContentRootProps, Conversation, type ConversationItemProps, type ConversationItemState, type ConversationRootProps, Dialog, type DialogPortalProps, type DialogRootProps, Entry, type EntryListProps, type EntryRootProps, type EntryState, Field, type FieldControlProps, type FieldRootProps, type FieldTextareaProps, FileUpload, type FileUploadRootProps, Identity, type IdentityRootProps, ListRow, type ListRowRootProps, Menu, type MenuItemProps, type MenuPositionerProps, type MenuRootProps, MetricStrip, type MetricStripRootProps, Nav, type NavItemProps, type NavRootProps, Panel, type PanelContentProps, type PanelGroupProps, type PanelLabelProps, type PanelRootProps, type PanelTriggerProps, PinInput, type PinInputInputProps, type PinInputRootProps, Popover, type PopoverPositionerProps, type PopoverRootProps, type PresenceState, ProfileHeader, type ProfileHeaderRootProps, Progress, type ProgressRootProps, Prompt, type PromptRootProps, type PromptStatus, RadioGroup, type RadioGroupItemProps, type RadioGroupRootProps, RangeSlider, type RangeSliderRootProps, type RangeSliderThumbProps, Scroll, type ScrollRootProps, type ScrollViewportProps, Select, type SelectContentProps, type SelectItemProps, type SelectPositionerProps, type SelectRootProps, Separator, type SeparatorRootProps, Slider, type SliderContextValue, type SliderRootProps, Slot, StatusBanner, type StatusBannerRootProps, type StatusBannerTone, StickyBar, type StickyBarRootProps, Switch, type SwitchRootProps, Tabs, type TabsContentProps, type TabsRootProps, type TabsTriggerProps, ThemeSwitcher, type ThemeSwitcherModeBtnProps, type ThemeSwitcherModesProps, type ThemeSwitcherRootProps, type ThemeSwitcherThemeSelectProps, Thread, type ThreadItemFace, type ThreadItemProps, type ThreadMessageState, type ThreadRootProps, Toast, ToastItem, type ToastPlacement, type ToastProviderProps, type ToastPublishOptions, type ToastRecord, type ToastRootProps, type ToastStore, type ToastViewportProps, Tooltip, type TooltipPositionerProps, type TooltipRootProps, asyncStatusToContentState, useAeonMachine, useAppNav, useAppNavMachine, useAppShellContext, useAsyncContext, useContentContext, useFieldContext, useFileUploadCtx as useFileUpload, useFitText, useLayoutMode, useScrollContext, useScrollState, useSliderContext, useStickyBarContext, useToast, useToastStore };
@@ -519,11 +519,15 @@ var Root4 = forwardRef5(function DialogRoot({ open, defaultOpen = false, onOpenC
519
519
  const [snapshot, send] = useAeonMachine(dialogMachine, {
520
520
  input: { open: defaultOpen }
521
521
  });
522
- const resolvedOpen = open ?? snapshot.context.open;
522
+ const machineOpen = snapshot.context.open;
523
+ const resolvedOpen = open ?? machineOpen;
523
524
  const contentRef = useRef(null);
525
+ const reportedOpen = useRef(machineOpen);
524
526
  useEffect6(() => {
525
- onOpenChange?.(resolvedOpen);
526
- }, [resolvedOpen, onOpenChange]);
527
+ if (reportedOpen.current === machineOpen) return;
528
+ reportedOpen.current = machineOpen;
529
+ onOpenChange?.(machineOpen);
530
+ }, [machineOpen, onOpenChange]);
527
531
  useEffect6(() => {
528
532
  if (open !== void 0) {
529
533
  send({ type: "SET_OPEN", open });
@@ -1247,7 +1251,7 @@ import {
1247
1251
  } from "react";
1248
1252
  import { jsx as jsx11 } from "react/jsx-runtime";
1249
1253
  var SliderCtx = createContext8(null);
1250
- function useSliderCtx() {
1254
+ function useSliderContext() {
1251
1255
  const ctx = useContext8(SliderCtx);
1252
1256
  if (!ctx) throw new Error("Slider parts must be used within Slider.Root");
1253
1257
  return ctx;
@@ -1280,12 +1284,24 @@ var Root11 = forwardRef12(function SliderRoot({
1280
1284
  });
1281
1285
  const resolvedValue = value ?? snapshot.context.value;
1282
1286
  const dragging = snapshot.matches({ interaction: "dragging" });
1283
- useEffect9(() => {
1284
- onValueChange?.(resolvedValue);
1285
- }, [resolvedValue, onValueChange]);
1286
1287
  useEffect9(() => {
1287
1288
  if (value !== void 0) send({ type: "SET_VALUE", value });
1288
1289
  }, [value, send]);
1290
+ useEffect9(() => {
1291
+ send({ type: "SET_BOUNDS", min, max, step });
1292
+ }, [min, max, step, send]);
1293
+ useEffect9(() => {
1294
+ send({ type: "SET_DISABLED", disabled });
1295
+ }, [disabled, send]);
1296
+ const commit = useCallback2(
1297
+ (next) => {
1298
+ if (disabled) return;
1299
+ const snapped = snapSliderValue(next, min, max, step);
1300
+ send({ type: "SET_VALUE", value: snapped });
1301
+ onValueChange?.(snapped);
1302
+ },
1303
+ [disabled, min, max, step, send, onValueChange]
1304
+ );
1289
1305
  const setValueFromClientX = useCallback2(
1290
1306
  (clientX) => {
1291
1307
  const track = trackRef.current;
@@ -1293,11 +1309,9 @@ var Root11 = forwardRef12(function SliderRoot({
1293
1309
  const rect = track.getBoundingClientRect();
1294
1310
  if (rect.width <= 0) return;
1295
1311
  const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
1296
- const next = snapSliderValue(min + ratio * (max - min), min, max, step);
1297
- send({ type: "SET_VALUE", value: next });
1298
- if (value === void 0) onValueChange?.(next);
1312
+ commit(min + ratio * (max - min));
1299
1313
  },
1300
- [disabled, min, max, step, send, value, onValueChange]
1314
+ [commit, disabled, min, max]
1301
1315
  );
1302
1316
  useEffect9(() => {
1303
1317
  if (!dragging) return;
@@ -1318,12 +1332,25 @@ var Root11 = forwardRef12(function SliderRoot({
1318
1332
  step,
1319
1333
  disabled,
1320
1334
  dragging,
1335
+ interaction: dragging ? "dragging" : "idle",
1321
1336
  send,
1322
1337
  onValueChange,
1338
+ commit,
1323
1339
  setValueFromClientX,
1324
1340
  trackRef
1325
1341
  }),
1326
- [resolvedValue, min, max, step, disabled, dragging, send, onValueChange, setValueFromClientX]
1342
+ [
1343
+ resolvedValue,
1344
+ min,
1345
+ max,
1346
+ step,
1347
+ disabled,
1348
+ dragging,
1349
+ send,
1350
+ onValueChange,
1351
+ commit,
1352
+ setValueFromClientX
1353
+ ]
1327
1354
  );
1328
1355
  return /* @__PURE__ */ jsx11(SliderCtx.Provider, { value: ctx, children: /* @__PURE__ */ jsx11(
1329
1356
  "div",
@@ -1342,7 +1369,7 @@ var Root11 = forwardRef12(function SliderRoot({
1342
1369
  ) });
1343
1370
  });
1344
1371
  var Track2 = forwardRef12(function SliderTrack({ onPointerDown, ...rest }, ref) {
1345
- const { disabled, send, setValueFromClientX, trackRef } = useSliderCtx();
1372
+ const { disabled, send, setValueFromClientX, trackRef } = useSliderContext();
1346
1373
  return /* @__PURE__ */ jsx11(
1347
1374
  "div",
1348
1375
  {
@@ -1368,7 +1395,7 @@ var Track2 = forwardRef12(function SliderTrack({ onPointerDown, ...rest }, ref)
1368
1395
  );
1369
1396
  });
1370
1397
  var Range2 = forwardRef12(function SliderRange({ style, ...rest }, ref) {
1371
- const { value, min, max } = useSliderCtx();
1398
+ const { value, min, max } = useSliderContext();
1372
1399
  return /* @__PURE__ */ jsx11(
1373
1400
  "div",
1374
1401
  {
@@ -1380,7 +1407,7 @@ var Range2 = forwardRef12(function SliderRange({ style, ...rest }, ref) {
1380
1407
  );
1381
1408
  });
1382
1409
  var Thumb2 = forwardRef12(function SliderThumb({ onKeyDown, onPointerDown, style, ...rest }, ref) {
1383
- const { value, min, max, step, disabled, dragging, send, setValueFromClientX } = useSliderCtx();
1410
+ const { value, min, max, step, disabled, dragging, send, commit, setValueFromClientX } = useSliderContext();
1384
1411
  return /* @__PURE__ */ jsx11(
1385
1412
  "span",
1386
1413
  {
@@ -1408,19 +1435,27 @@ var Thumb2 = forwardRef12(function SliderThumb({ onKeyDown, onPointerDown, style
1408
1435
  if (disabled || e.defaultPrevented) return;
1409
1436
  if (e.key === "ArrowRight" || e.key === "ArrowUp") {
1410
1437
  e.preventDefault();
1411
- send({ type: "STEP", delta: step });
1438
+ commit(value + step);
1412
1439
  }
1413
1440
  if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
1414
1441
  e.preventDefault();
1415
- send({ type: "STEP", delta: -step });
1442
+ commit(value - step);
1443
+ }
1444
+ if (e.key === "PageUp") {
1445
+ e.preventDefault();
1446
+ commit(value + (max - min) / 10);
1447
+ }
1448
+ if (e.key === "PageDown") {
1449
+ e.preventDefault();
1450
+ commit(value - (max - min) / 10);
1416
1451
  }
1417
1452
  if (e.key === "Home") {
1418
1453
  e.preventDefault();
1419
- send({ type: "HOME" });
1454
+ commit(min);
1420
1455
  }
1421
1456
  if (e.key === "End") {
1422
1457
  e.preventDefault();
1423
- send({ type: "END" });
1458
+ commit(max);
1424
1459
  }
1425
1460
  }
1426
1461
  },
@@ -1430,7 +1465,7 @@ var Thumb2 = forwardRef12(function SliderThumb({ onKeyDown, onPointerDown, style
1430
1465
  );
1431
1466
  });
1432
1467
  var ValueText = forwardRef12(function SliderValueText(props, ref) {
1433
- const { value } = useSliderCtx();
1468
+ const { value } = useSliderContext();
1434
1469
  return /* @__PURE__ */ jsx11("span", { ref, ...mergeProps(partOnlyAttrs3(sliderAnatomy.valueText), props), children: props.children ?? value });
1435
1470
  });
1436
1471
  var Slider = { Root: Root11, Track: Track2, Range: Range2, Thumb: Thumb2, ValueText };
@@ -2712,6 +2747,7 @@ var FloatingPositioner = forwardRef23(
2712
2747
  part,
2713
2748
  triggerRef,
2714
2749
  positionerRef,
2750
+ anchorRef,
2715
2751
  portalled = true,
2716
2752
  placement = "bottom-start",
2717
2753
  matchAnchorWidth = false,
@@ -2722,7 +2758,7 @@ var FloatingPositioner = forwardRef23(
2722
2758
  ...rest
2723
2759
  }, ref) {
2724
2760
  const mounted = usePortalledMount();
2725
- useAnchorPosition(triggerRef, positionerRef, open && portalled, placement, {
2761
+ useAnchorPosition(anchorRef ?? triggerRef, positionerRef, open && portalled, placement, {
2726
2762
  matchWidth: matchAnchorWidth,
2727
2763
  gutter,
2728
2764
  zIndex
@@ -3401,15 +3437,19 @@ var Root20 = forwardRef26(function PopoverRoot({ open, defaultOpen = false, onOp
3401
3437
  const [snapshot, send] = useAeonMachine(popoverMachine3, {
3402
3438
  input: { open: defaultOpen }
3403
3439
  });
3404
- const resolvedOpen = open ?? snapshot.context.open;
3440
+ const machineOpen = snapshot.context.open;
3441
+ const resolvedOpen = open ?? machineOpen;
3405
3442
  const rootRef = useRef9(null);
3406
3443
  const triggerRef = useRef9(null);
3407
3444
  const positionerRef = useRef9(null);
3408
3445
  const triggerId = useId5();
3409
3446
  const contentId = useId5();
3447
+ const reportedOpen = useRef9(machineOpen);
3410
3448
  useEffect16(() => {
3411
- onOpenChange?.(resolvedOpen);
3412
- }, [resolvedOpen, onOpenChange]);
3449
+ if (reportedOpen.current === machineOpen) return;
3450
+ reportedOpen.current = machineOpen;
3451
+ onOpenChange?.(machineOpen);
3452
+ }, [machineOpen, onOpenChange]);
3413
3453
  useEffect16(() => {
3414
3454
  if (open !== void 0) send({ type: "SET_OPEN", open });
3415
3455
  }, [open, send]);
@@ -3465,7 +3505,17 @@ var Trigger4 = forwardRef26(function PopoverTrigger({ onClick, ...rest }, ref) {
3465
3505
  }
3466
3506
  );
3467
3507
  });
3468
- var Positioner3 = forwardRef26(function PopoverPositioner({ portalled = true, placement = "bottom-start", gutter, zIndex, children, className, ...rest }, ref) {
3508
+ var Positioner3 = forwardRef26(function PopoverPositioner({
3509
+ portalled = true,
3510
+ placement = "bottom-start",
3511
+ anchorRef,
3512
+ matchAnchorWidth = false,
3513
+ gutter,
3514
+ zIndex,
3515
+ children,
3516
+ className,
3517
+ ...rest
3518
+ }, ref) {
3469
3519
  const { open, triggerRef, positionerRef } = usePopoverCtx();
3470
3520
  return /* @__PURE__ */ jsx26(
3471
3521
  FloatingPositioner,
@@ -3476,6 +3526,8 @@ var Positioner3 = forwardRef26(function PopoverPositioner({ portalled = true, pl
3476
3526
  part: popoverAnatomy.positioner,
3477
3527
  triggerRef,
3478
3528
  positionerRef,
3529
+ anchorRef,
3530
+ matchAnchorWidth,
3479
3531
  portalled,
3480
3532
  placement,
3481
3533
  gutter,
@@ -3562,15 +3614,19 @@ function useMenuCtx() {
3562
3614
  }
3563
3615
  var Root21 = forwardRef27(function MenuRoot({ open, defaultOpen = false, onOpenChange, children, className, ...rest }, ref) {
3564
3616
  const [snapshot, send] = useAeonMachine(popoverMachine4, { input: { open: defaultOpen } });
3565
- const resolvedOpen = open ?? snapshot.context.open;
3617
+ const machineOpen = snapshot.context.open;
3618
+ const resolvedOpen = open ?? machineOpen;
3566
3619
  const rootRef = useRef10(null);
3567
3620
  const triggerRef = useRef10(null);
3568
3621
  const positionerRef = useRef10(null);
3569
3622
  const triggerId = useId6();
3570
3623
  const menuId = useId6();
3624
+ const reportedOpen = useRef10(machineOpen);
3571
3625
  useEffect17(() => {
3572
- onOpenChange?.(resolvedOpen);
3573
- }, [resolvedOpen, onOpenChange]);
3626
+ if (reportedOpen.current === machineOpen) return;
3627
+ reportedOpen.current = machineOpen;
3628
+ onOpenChange?.(machineOpen);
3629
+ }, [machineOpen, onOpenChange]);
3574
3630
  useEffect17(() => {
3575
3631
  if (open !== void 0) send({ type: "SET_OPEN", open });
3576
3632
  }, [open, send]);
@@ -5932,6 +5988,7 @@ export {
5932
5988
  useLayoutMode,
5933
5989
  useScrollContext,
5934
5990
  useScrollState,
5991
+ useSliderContext,
5935
5992
  useStickyBarContext,
5936
5993
  useToast,
5937
5994
  useToastStore
@@ -1 +1 @@
1
- {"version":"3.2.4","results":[[":src/select/select.test.tsx",{"duration":374.59737499999983,"failed":false}],[":src/dialog/dialog.test.tsx",{"duration":302.22662500000024,"failed":false}],[":src/avatar/avatar.test.tsx",{"duration":222.467666,"failed":false}],[":src/tooltip/tooltip.test.tsx",{"duration":394.93274999999994,"failed":false}],[":src/toast/toast.test.tsx",{"duration":290.9687079999999,"failed":false}]]}
1
+ {"version":"3.2.4","results":[[":src/select/select.test.tsx",{"duration":196.07670899999994,"failed":false}],[":src/dialog/dialog.test.tsx",{"duration":181.65270800000008,"failed":false}],[":src/avatar/avatar.test.tsx",{"duration":117.92833299999984,"failed":false}],[":src/tooltip/tooltip.test.tsx",{"duration":210.20837500000016,"failed":false}],[":src/toast/toast.test.tsx",{"duration":134.57233399999996,"failed":false}]]}
@@ -1,5 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react'
2
2
  import userEvent from '@testing-library/user-event'
3
+ import { useState } from 'react'
3
4
  import { describe, expect, it, vi } from 'vitest'
4
5
  import { Dialog } from '../dialog/index.js'
5
6
 
@@ -47,4 +48,30 @@ describe('Dialog', () => {
47
48
  unmount()
48
49
  expect(document.body.style.overflow).not.toBe('hidden')
49
50
  })
51
+
52
+ it('reports the trigger to a controlled owner', async () => {
53
+ const user = userEvent.setup()
54
+
55
+ function Controlled() {
56
+ const [open, setOpen] = useState(false)
57
+ return (
58
+ <Dialog.Root open={open} onOpenChange={setOpen}>
59
+ <Dialog.Trigger>Open</Dialog.Trigger>
60
+ <Dialog.Portal portalled={false}>
61
+ <Dialog.Content>
62
+ <Dialog.Title>Title</Dialog.Title>
63
+ <Dialog.CloseTrigger>Close</Dialog.CloseTrigger>
64
+ </Dialog.Content>
65
+ </Dialog.Portal>
66
+ </Dialog.Root>
67
+ )
68
+ }
69
+
70
+ render(<Controlled />)
71
+ await user.click(screen.getByRole('button', { name: 'Open' }))
72
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
73
+
74
+ await user.click(screen.getByRole('button', { name: 'Close dialog' }))
75
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
76
+ })
50
77
  })
@@ -52,12 +52,20 @@ const Root = forwardRef<HTMLDivElement, DialogRootProps>(function DialogRoot(
52
52
  input: { open: defaultOpen },
53
53
  })
54
54
 
55
- const resolvedOpen = open ?? snapshot.context.open
55
+ const machineOpen = snapshot.context.open
56
+ const resolvedOpen = open ?? machineOpen
56
57
  const contentRef = useRef<HTMLDivElement>(null)
57
58
 
59
+ /**
60
+ * Report the machine's intent, not the resolved value: a controlled owner
61
+ * holding `open` would otherwise never hear about its own trigger or escape.
62
+ */
63
+ const reportedOpen = useRef(machineOpen)
58
64
  useEffect(() => {
59
- onOpenChange?.(resolvedOpen)
60
- }, [resolvedOpen, onOpenChange])
65
+ if (reportedOpen.current === machineOpen) return
66
+ reportedOpen.current = machineOpen
67
+ onOpenChange?.(machineOpen)
68
+ }, [machineOpen, onOpenChange])
61
69
 
62
70
  useEffect(() => {
63
71
  if (open !== undefined) {
@@ -10,6 +10,15 @@ export interface FloatingPositionerProps extends HTMLAttributes<HTMLDivElement>
10
10
  part: string
11
11
  triggerRef: RefObject<HTMLElement | null>
12
12
  positionerRef: RefObject<HTMLDivElement | null>
13
+ /**
14
+ * Position against this element instead of the trigger.
15
+ *
16
+ * A trigger is often a small icon button pinned to one edge of a much larger
17
+ * region. Aligning to the button then throws a wide surface off-screen, and
18
+ * clamping only slides it back to the viewport edge. Anchoring to the region
19
+ * the surface belongs to puts it where the eye already is.
20
+ */
21
+ anchorRef?: RefObject<HTMLElement | null>
13
22
  portalled?: boolean
14
23
  placement?: AnchorPlacement
15
24
  matchAnchorWidth?: boolean
@@ -28,6 +37,7 @@ export const FloatingPositioner = forwardRef<HTMLDivElement, FloatingPositionerP
28
37
  part,
29
38
  triggerRef,
30
39
  positionerRef,
40
+ anchorRef,
31
41
  portalled = true,
32
42
  placement = 'bottom-start',
33
43
  matchAnchorWidth = false,
@@ -40,7 +50,7 @@ export const FloatingPositioner = forwardRef<HTMLDivElement, FloatingPositionerP
40
50
  ref,
41
51
  ) {
42
52
  const mounted = usePortalledMount()
43
- useAnchorPosition(triggerRef, positionerRef, open && portalled, placement, {
53
+ useAnchorPosition(anchorRef ?? triggerRef, positionerRef, open && portalled, placement, {
44
54
  matchWidth: matchAnchorWidth,
45
55
  gutter,
46
56
  zIndex,