aeon-ui-engine 1.4.0 → 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 (35) hide show
  1. package/CHANGELOG.md +27 -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/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/src/bin.ts +30 -2
  11. package/packages/cli/src/index.ts +1 -0
  12. package/packages/cli/src/init.ts +10 -4
  13. package/packages/cli/src/link.ts +61 -0
  14. package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  15. package/packages/primitives/dist/{chunk-PB3LFHXU.js → chunk-VUPWYBGU.js} +14 -0
  16. package/packages/primitives/dist/index.js +1 -1
  17. package/packages/primitives/dist/machines/range-slider.d.ts +3 -3
  18. package/packages/primitives/dist/machines/slider.d.ts +22 -0
  19. package/packages/primitives/dist/machines/slider.js +1 -1
  20. package/packages/primitives/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  21. package/packages/primitives/src/machines/slider.ts +17 -0
  22. package/packages/react/dist/index.d.ts +15 -1
  23. package/packages/react/dist/index.js +89 -21
  24. package/packages/react/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  25. package/packages/react/src/floating/FloatingPositioner.tsx +8 -0
  26. package/packages/react/src/hooks/use-anchor-position.ts +88 -17
  27. package/packages/react/src/menu/index.tsx +37 -3
  28. package/packages/react/src/popover/index.tsx +7 -1
  29. package/packages/react/src/slider/index.tsx +54 -14
  30. package/packages/react/src/tooltip/index.tsx +7 -1
  31. package/packages/schemas/prompts/cursorrules.md +3 -1
  32. package/packages/schemas/prompts/statecharts.md +1 -1
  33. package/packages/schemas/src/prompts.ts +3 -1
  34. package/scripts/link-scoped-packages.mjs +206 -0
  35. package/scripts/vite-aliases.mjs +6 -8
@@ -30,7 +30,7 @@ import {
30
30
  clamp,
31
31
  sliderMachine,
32
32
  snap
33
- } from "./chunk-PB3LFHXU.js";
33
+ } from "./chunk-VUPWYBGU.js";
34
34
  import {
35
35
  STICKY_BAR_STATES,
36
36
  stickyBarMachine
@@ -83,14 +83,14 @@ declare const rangeSliderMachine: xstate.StateMachine<RangeSliderContext, {
83
83
  type: "END";
84
84
  thumb: 0 | 1;
85
85
  }, {}, never, {
86
- type: "setValues";
87
- params: xstate.NonReducibleUnknown;
88
- } | {
89
86
  type: "setBounds";
90
87
  params: xstate.NonReducibleUnknown;
91
88
  } | {
92
89
  type: "setDisabled";
93
90
  params: xstate.NonReducibleUnknown;
91
+ } | {
92
+ type: "setValues";
93
+ params: xstate.NonReducibleUnknown;
94
94
  } | {
95
95
  type: "pointerDown";
96
96
  params: xstate.NonReducibleUnknown;
@@ -21,6 +21,14 @@ type SliderEvent = {
21
21
  type: 'HOME';
22
22
  } | {
23
23
  type: 'END';
24
+ } | {
25
+ type: 'SET_BOUNDS';
26
+ min: number;
27
+ max: number;
28
+ step: number;
29
+ } | {
30
+ type: 'SET_DISABLED';
31
+ disabled: boolean;
24
32
  };
25
33
  declare function clamp(value: number, min: number, max: number): number;
26
34
  declare function snap(value: number, min: number, max: number, step: number): number;
@@ -39,6 +47,14 @@ declare const sliderMachine: xstate.StateMachine<SliderContext, {
39
47
  type: "HOME";
40
48
  } | {
41
49
  type: "END";
50
+ } | {
51
+ type: "SET_BOUNDS";
52
+ min: number;
53
+ max: number;
54
+ step: number;
55
+ } | {
56
+ type: "SET_DISABLED";
57
+ disabled: boolean;
42
58
  }, {}, never, {
43
59
  type: "setValue";
44
60
  params: xstate.NonReducibleUnknown;
@@ -51,6 +67,12 @@ declare const sliderMachine: xstate.StateMachine<SliderContext, {
51
67
  } | {
52
68
  type: "end";
53
69
  params: xstate.NonReducibleUnknown;
70
+ } | {
71
+ type: "setBounds";
72
+ params: xstate.NonReducibleUnknown;
73
+ } | {
74
+ type: "setDisabled";
75
+ params: xstate.NonReducibleUnknown;
54
76
  }, {
55
77
  type: "notDisabled";
56
78
  params: unknown;
@@ -2,7 +2,7 @@ import {
2
2
  clamp,
3
3
  sliderMachine,
4
4
  snap
5
- } from "../chunk-PB3LFHXU.js";
5
+ } from "../chunk-VUPWYBGU.js";
6
6
  export {
7
7
  clamp as clampSliderValue,
8
8
  sliderMachine,
@@ -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":5.357416999999998,"failed":false}],[":src/__tests__/app-nav.test.ts",{"duration":7.012541999999996,"failed":false}],[":src/__tests__/file-upload.test.ts",{"duration":5.016666000000015,"failed":false}],[":src/__tests__/range-slider.test.ts",{"duration":6.245291999999978,"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.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}]]}
@@ -15,6 +15,8 @@ export type SliderEvent =
15
15
  | { type: 'POINTER_UP' }
16
16
  | { type: 'HOME' }
17
17
  | { type: 'END' }
18
+ | { type: 'SET_BOUNDS'; min: number; max: number; step: number }
19
+ | { type: 'SET_DISABLED'; disabled: boolean }
18
20
 
19
21
  function clamp(value: number, min: number, max: number) {
20
22
  return Math.min(max, Math.max(min, value))
@@ -55,6 +57,15 @@ export const sliderMachine = setup({
55
57
  end: assign({
56
58
  value: ({ context }) => context.max,
57
59
  }),
60
+ setBounds: assign(({ context, event }) => {
61
+ if (event.type !== 'SET_BOUNDS') return context
62
+ const { min, max, step } = event
63
+ return { ...context, min, max, step, value: snap(context.value, min, max, step) }
64
+ }),
65
+ setDisabled: assign({
66
+ disabled: ({ context, event }) =>
67
+ event.type === 'SET_DISABLED' ? event.disabled : context.disabled,
68
+ }),
58
69
  },
59
70
  }).createMachine({
60
71
  id: 'slider',
@@ -66,6 +77,12 @@ export const sliderMachine = setup({
66
77
  disabled: input.disabled ?? false,
67
78
  }),
68
79
  initial: 'interaction',
80
+ // Bounds and disabled follow the host regardless of interaction state, so a
81
+ // range that widens (audio duration arriving) stops clamping to stale limits.
82
+ on: {
83
+ SET_BOUNDS: { actions: 'setBounds' },
84
+ SET_DISABLED: { actions: 'setDisabled' },
85
+ },
69
86
  states: {
70
87
  interaction: {
71
88
  initial: 'idle',
@@ -519,7 +519,9 @@ declare const Combobox: {
519
519
  Empty: react.ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & react.RefAttributes<HTMLDivElement>>;
520
520
  };
521
521
 
522
- type AnchorPlacement = 'bottom-start' | 'top-start';
522
+ type AnchorSide = 'top' | 'bottom';
523
+ type AnchorAlign = 'start' | 'end' | 'center';
524
+ type AnchorPlacement = `${AnchorSide}-${AnchorAlign}`;
523
525
 
524
526
  interface PopoverRootProps {
525
527
  open?: boolean;
@@ -532,6 +534,10 @@ interface PopoverPositionerProps extends HTMLAttributes<HTMLDivElement> {
532
534
  /** Mount panel in document.body (default true). */
533
535
  portalled?: boolean;
534
536
  placement?: AnchorPlacement;
537
+ /** Gap between trigger and panel (default 4). */
538
+ gutter?: number;
539
+ /** Stack level for the portalled surface (default 50). */
540
+ zIndex?: number;
535
541
  children?: ReactNode;
536
542
  }
537
543
  declare const Popover: {
@@ -554,6 +560,10 @@ interface MenuPositionerProps extends HTMLAttributes<HTMLDivElement> {
554
560
  /** Mount menu in document.body (default true). */
555
561
  portalled?: boolean;
556
562
  placement?: AnchorPlacement;
563
+ /** Gap between trigger and menu (default 4). */
564
+ gutter?: number;
565
+ /** Stack level for the portalled surface (default 50). */
566
+ zIndex?: number;
557
567
  children?: ReactNode;
558
568
  }
559
569
  interface MenuItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
@@ -588,6 +598,10 @@ interface TooltipPositionerProps extends HTMLAttributes<HTMLDivElement> {
588
598
  /** Mount tooltip in document.body (default true). */
589
599
  portalled?: boolean;
590
600
  placement?: AnchorPlacement;
601
+ /** Gap between trigger and tip (default 4). */
602
+ gutter?: number;
603
+ /** Stack level for the portalled surface (default 50). */
604
+ zIndex?: number;
591
605
  children?: ReactNode;
592
606
  }
593
607
  declare const Tooltip: {
@@ -2628,20 +2628,35 @@ import { forwardRef as forwardRef23 } from "react";
2628
2628
 
2629
2629
  // src/hooks/use-anchor-position.ts
2630
2630
  import { useEffect as useEffect13 } from "react";
2631
+ var DEFAULT_GUTTER = 4;
2632
+ var DEFAULT_Z_INDEX = 50;
2633
+ var DEFAULT_PADDING = 8;
2634
+ function splitPlacement(placement) {
2635
+ const [side, align] = placement.split("-");
2636
+ return [side, align];
2637
+ }
2631
2638
  function useAnchorPosition(anchorRef, floatingRef, active, placement = "bottom-start", options = {}) {
2632
- const { matchWidth = false } = options;
2639
+ const {
2640
+ matchWidth = false,
2641
+ gutter = DEFAULT_GUTTER,
2642
+ zIndex = DEFAULT_Z_INDEX,
2643
+ padding = DEFAULT_PADDING
2644
+ } = options;
2633
2645
  useEffect13(() => {
2634
2646
  if (!active || !anchorRef.current || !floatingRef.current) return;
2647
+ let frame = 0;
2635
2648
  const update = () => {
2636
2649
  const anchor = anchorRef.current;
2637
2650
  const floating = floatingRef.current;
2638
2651
  if (!anchor || !floating) return;
2652
+ const [preferredSide, align] = splitPlacement(placement);
2639
2653
  const rect = anchor.getBoundingClientRect();
2654
+ const viewportWidth = window.innerWidth;
2655
+ const viewportHeight = window.innerHeight;
2640
2656
  floating.style.position = "fixed";
2641
- floating.style.left = `${rect.left}px`;
2642
2657
  floating.style.bottom = "";
2643
2658
  floating.style.right = "";
2644
- floating.style.zIndex = "50";
2659
+ floating.style.zIndex = String(zIndex);
2645
2660
  if (matchWidth) {
2646
2661
  floating.style.width = `${rect.width}px`;
2647
2662
  floating.style.minWidth = `${rect.width}px`;
@@ -2649,22 +2664,43 @@ function useAnchorPosition(anchorRef, floatingRef, active, placement = "bottom-s
2649
2664
  floating.style.width = "";
2650
2665
  floating.style.minWidth = "";
2651
2666
  }
2652
- if (placement === "top-start") {
2653
- const height = floating.offsetHeight || floating.getBoundingClientRect().height;
2654
- floating.style.top = `${rect.top - height - 4}px`;
2655
- } else {
2656
- floating.style.top = `${rect.bottom + 4}px`;
2657
- }
2667
+ const width = floating.offsetWidth || floating.getBoundingClientRect().width;
2668
+ const height = floating.offsetHeight || floating.getBoundingClientRect().height;
2669
+ const roomBelow = viewportHeight - rect.bottom - gutter - padding;
2670
+ const roomAbove = rect.top - gutter - padding;
2671
+ const room = preferredSide === "top" ? roomAbove : roomBelow;
2672
+ const side = room >= height ? preferredSide : roomAbove > roomBelow ? "top" : "bottom";
2673
+ const available = side === "top" ? roomAbove : roomBelow;
2674
+ let left = rect.left;
2675
+ if (align === "end") left = rect.right - width;
2676
+ else if (align === "center") left = rect.left + rect.width / 2 - width / 2;
2677
+ const maxLeft = Math.max(padding, viewportWidth - width - padding);
2678
+ left = Math.min(Math.max(padding, left), maxLeft);
2679
+ const top = side === "top" ? Math.max(padding, rect.top - gutter - Math.min(height, Math.max(available, 0))) : Math.min(
2680
+ rect.bottom + gutter,
2681
+ Math.max(padding, viewportHeight - height - padding)
2682
+ );
2683
+ floating.style.left = `${Math.round(left)}px`;
2684
+ floating.style.top = `${Math.round(top)}px`;
2685
+ floating.style.setProperty("--aeon-available-height", `${Math.round(Math.max(available, 0))}px`);
2686
+ floating.style.setProperty("--aeon-anchor-width", `${Math.round(rect.width)}px`);
2687
+ floating.dataset.side = side;
2688
+ floating.dataset.align = align;
2689
+ };
2690
+ const schedule = () => {
2691
+ cancelAnimationFrame(frame);
2692
+ frame = requestAnimationFrame(update);
2658
2693
  };
2659
2694
  update();
2660
- requestAnimationFrame(update);
2661
- window.addEventListener("resize", update);
2662
- window.addEventListener("scroll", update, true);
2695
+ schedule();
2696
+ window.addEventListener("resize", schedule);
2697
+ window.addEventListener("scroll", schedule, true);
2663
2698
  return () => {
2664
- window.removeEventListener("resize", update);
2665
- window.removeEventListener("scroll", update, true);
2699
+ cancelAnimationFrame(frame);
2700
+ window.removeEventListener("resize", schedule);
2701
+ window.removeEventListener("scroll", schedule, true);
2666
2702
  };
2667
- }, [active, anchorRef, floatingRef, placement, matchWidth]);
2703
+ }, [active, anchorRef, floatingRef, placement, matchWidth, gutter, zIndex, padding]);
2668
2704
  }
2669
2705
 
2670
2706
  // src/floating/FloatingPositioner.tsx
@@ -2679,13 +2715,17 @@ var FloatingPositioner = forwardRef23(
2679
2715
  portalled = true,
2680
2716
  placement = "bottom-start",
2681
2717
  matchAnchorWidth = false,
2718
+ gutter,
2719
+ zIndex,
2682
2720
  children,
2683
2721
  className,
2684
2722
  ...rest
2685
2723
  }, ref) {
2686
2724
  const mounted = usePortalledMount();
2687
2725
  useAnchorPosition(triggerRef, positionerRef, open && portalled, placement, {
2688
- matchWidth: matchAnchorWidth
2726
+ matchWidth: matchAnchorWidth,
2727
+ gutter,
2728
+ zIndex
2689
2729
  });
2690
2730
  if (!open) return null;
2691
2731
  const node = /* @__PURE__ */ jsx23(
@@ -3425,7 +3465,7 @@ var Trigger4 = forwardRef26(function PopoverTrigger({ onClick, ...rest }, ref) {
3425
3465
  }
3426
3466
  );
3427
3467
  });
3428
- var Positioner3 = forwardRef26(function PopoverPositioner({ portalled = true, placement = "bottom-start", children, className, ...rest }, ref) {
3468
+ var Positioner3 = forwardRef26(function PopoverPositioner({ portalled = true, placement = "bottom-start", gutter, zIndex, children, className, ...rest }, ref) {
3429
3469
  const { open, triggerRef, positionerRef } = usePopoverCtx();
3430
3470
  return /* @__PURE__ */ jsx26(
3431
3471
  FloatingPositioner,
@@ -3438,6 +3478,8 @@ var Positioner3 = forwardRef26(function PopoverPositioner({ portalled = true, pl
3438
3478
  positionerRef,
3439
3479
  portalled,
3440
3480
  placement,
3481
+ gutter,
3482
+ zIndex,
3441
3483
  className,
3442
3484
  ...rest,
3443
3485
  children
@@ -3583,7 +3625,7 @@ var Trigger5 = forwardRef27(function MenuTrigger({ onClick, ...rest }, ref) {
3583
3625
  }
3584
3626
  );
3585
3627
  });
3586
- var Positioner4 = forwardRef27(function MenuPositioner({ portalled = true, placement = "bottom-start", children, className, ...rest }, ref) {
3628
+ var Positioner4 = forwardRef27(function MenuPositioner({ portalled = true, placement = "bottom-start", gutter, zIndex, children, className, ...rest }, ref) {
3587
3629
  const { open, triggerRef, positionerRef } = useMenuCtx();
3588
3630
  return /* @__PURE__ */ jsx27(
3589
3631
  FloatingPositioner,
@@ -3596,6 +3638,8 @@ var Positioner4 = forwardRef27(function MenuPositioner({ portalled = true, place
3596
3638
  positionerRef,
3597
3639
  portalled,
3598
3640
  placement,
3641
+ gutter,
3642
+ zIndex,
3599
3643
  className,
3600
3644
  ...rest,
3601
3645
  children
@@ -3603,14 +3647,36 @@ var Positioner4 = forwardRef27(function MenuPositioner({ portalled = true, place
3603
3647
  );
3604
3648
  });
3605
3649
  var Content7 = forwardRef27(function MenuContent({ onKeyDown, ...rest }, ref) {
3606
- const { open, send, menuId } = useMenuCtx();
3650
+ const { open, send, menuId, triggerRef } = useMenuCtx();
3651
+ const contentRef = useRef10(null);
3652
+ useEffect17(() => {
3653
+ if (!open) return;
3654
+ const trigger = triggerRef.current;
3655
+ contentRef.current?.focus({ preventScroll: true });
3656
+ return () => {
3657
+ if (trigger?.isConnected) trigger.focus({ preventScroll: true });
3658
+ };
3659
+ }, [open, triggerRef]);
3660
+ useEffect17(() => {
3661
+ if (!open) return;
3662
+ const onDocumentKeyDown = (event) => {
3663
+ if (event.key === "Escape") send({ type: "ESCAPE" });
3664
+ };
3665
+ document.addEventListener("keydown", onDocumentKeyDown);
3666
+ return () => document.removeEventListener("keydown", onDocumentKeyDown);
3667
+ }, [open, send]);
3607
3668
  if (!open) return null;
3608
3669
  return /* @__PURE__ */ jsx27(
3609
3670
  "div",
3610
3671
  {
3611
- ref,
3672
+ ref: (node) => {
3673
+ contentRef.current = node;
3674
+ if (typeof ref === "function") ref(node);
3675
+ else if (ref) ref.current = node;
3676
+ },
3612
3677
  id: menuId,
3613
3678
  role: "menu",
3679
+ tabIndex: -1,
3614
3680
  ...mergeProps(
3615
3681
  partAttrs23(menuAnatomy.scope, menuAnatomy.content, { state: "open" }),
3616
3682
  {
@@ -3870,7 +3936,7 @@ var Trigger6 = forwardRef28(function TooltipTrigger({ onClick, onMouseEnter, onM
3870
3936
  }
3871
3937
  );
3872
3938
  });
3873
- var Positioner5 = forwardRef28(function TooltipPositioner({ portalled = true, placement = "top-start", children, className, ...rest }, ref) {
3939
+ var Positioner5 = forwardRef28(function TooltipPositioner({ portalled = true, placement = "top-start", gutter, zIndex, children, className, ...rest }, ref) {
3874
3940
  const { open, triggerRef, positionerRef } = useTooltipCtx();
3875
3941
  return /* @__PURE__ */ jsx28(
3876
3942
  FloatingPositioner,
@@ -3883,6 +3949,8 @@ var Positioner5 = forwardRef28(function TooltipPositioner({ portalled = true, pl
3883
3949
  positionerRef,
3884
3950
  portalled,
3885
3951
  placement,
3952
+ gutter,
3953
+ zIndex,
3886
3954
  className,
3887
3955
  ...rest,
3888
3956
  children
@@ -1 +1 @@
1
- {"version":"3.2.4","results":[[":src/select/select.test.tsx",{"duration":233.12287500000002,"failed":false}],[":src/dialog/dialog.test.tsx",{"duration":197.85458300000005,"failed":false}],[":src/avatar/avatar.test.tsx",{"duration":175.06837500000006,"failed":false}],[":src/tooltip/tooltip.test.tsx",{"duration":236.69991700000014,"failed":false}],[":src/toast/toast.test.tsx",{"duration":174.01087499999994,"failed":false}]]}
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}]]}
@@ -13,6 +13,10 @@ export interface FloatingPositionerProps extends HTMLAttributes<HTMLDivElement>
13
13
  portalled?: boolean
14
14
  placement?: AnchorPlacement
15
15
  matchAnchorWidth?: boolean
16
+ /** Gap between trigger and surface. */
17
+ gutter?: number
18
+ /** Stack level for the portalled surface. */
19
+ zIndex?: number
16
20
  children?: ReactNode
17
21
  }
18
22
 
@@ -27,6 +31,8 @@ export const FloatingPositioner = forwardRef<HTMLDivElement, FloatingPositionerP
27
31
  portalled = true,
28
32
  placement = 'bottom-start',
29
33
  matchAnchorWidth = false,
34
+ gutter,
35
+ zIndex,
30
36
  children,
31
37
  className,
32
38
  ...rest
@@ -36,6 +42,8 @@ export const FloatingPositioner = forwardRef<HTMLDivElement, FloatingPositionerP
36
42
  const mounted = usePortalledMount()
37
43
  useAnchorPosition(triggerRef, positionerRef, open && portalled, placement, {
38
44
  matchWidth: matchAnchorWidth,
45
+ gutter,
46
+ zIndex,
39
47
  })
40
48
 
41
49
  if (!open) return null
@@ -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
  }
@@ -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
  >