@featherk/composables 0.12.5 → 0.13.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.
@@ -0,0 +1,40 @@
1
+ /** A corner/center alignment pair, independent of any specific composable. */
2
+ export type GeometryAlign = {
3
+ horizontal: "left" | "center" | "right";
4
+ vertical: "top" | "center" | "bottom";
5
+ };
6
+ /** A rectangular clipping region (viewport or a scrollable container's rect). */
7
+ export type GeometryBounds = {
8
+ left: number;
9
+ top: number;
10
+ right: number;
11
+ bottom: number;
12
+ };
13
+ export type ComputeFlippedOffsetInput = {
14
+ /** The anchor rect (or a zero-size point rect for a click/keyboard activation). */
15
+ anchor: DOMRect;
16
+ /** The popup's own measured size, or `null` before it has been measured. */
17
+ popup: DOMRect | null;
18
+ /** Point on the anchor that the popup is positioned from. */
19
+ anchorAlign: GeometryAlign;
20
+ /** Point on the popup that is placed at the computed anchor point. */
21
+ popupAlign: GeometryAlign;
22
+ /** Clipping bounds checked for overflow; same coordinate space as `anchor`. */
23
+ bounds: GeometryBounds;
24
+ };
25
+ /**
26
+ * Computes a popup's top-left corner from an anchor rect and alignment pair,
27
+ * flipping either axis to the anchor's opposite side when the preferred
28
+ * position would overflow `bounds` and flipping actually reduces the
29
+ * overflow. Mirrors Kendo Popup's own `anchorAlign`/`popupAlign`/`collision:
30
+ * "flip"` semantics, which only run when Kendo positions via a real `anchor`
31
+ * DOM ref - this reproduces them for offset-based positioning.
32
+ *
33
+ * A `center` alignment on an axis never flips (there is no opposite side).
34
+ * Returns the un-flipped position when `popup` is `null` (not yet measured)
35
+ * or `bounds` cannot be checked - there is nothing to compare against yet.
36
+ */
37
+ export declare const computeFlippedOffset: ({ anchor, popup, anchorAlign, popupAlign, bounds, }: ComputeFlippedOffsetInput) => {
38
+ left: number;
39
+ top: number;
40
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export * from "./computeFlippedOffset";
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./grid";
2
2
  export * from "./address";
3
3
  export * from "./form";
4
+ export * from "./geometry";
4
5
  export * from "./id";
5
6
  export * from "./menu";
6
7
  export * from "./observer";
@@ -1,4 +1,5 @@
1
1
  import { type ComponentPublicInstance, type ComputedRef, type MaybeRefOrGetter, type Ref } from "vue";
2
+ import { type GeometryAlign } from "../geometry";
2
3
  /** A template ref that may point to an element or a Vue/Kendo component. */
3
4
  export type PopupMenuElementRef = Ref<HTMLElement | ComponentPublicInstance | null>;
4
5
  export type PopupMenuTriggerMode = "button" | "row";
@@ -6,17 +7,64 @@ export type PopupMenuOffset = {
6
7
  left: number;
7
8
  top: number;
8
9
  };
9
- /** Offset-positioning options for a Popup with a dynamically changing trigger. */
10
- export type PopupMenuAnchorOptions = {
10
+ /** Mirrors Kendo Popup's `anchorAlign`/`popupAlign` point shape. */
11
+ export type PopupMenuAlign = GeometryAlign;
12
+ export type PopupMenuAnchorActivation = {
13
+ triggerType: "click" | "keyboard";
14
+ coordinates?: {
15
+ clientX: number;
16
+ clientY: number;
17
+ };
18
+ };
19
+ export type PopupMenuActivationPointOptions = {
20
+ /** Added to pointer coordinates after they are made trigger-relative. */
21
+ pointerOffset?: {
22
+ x?: number;
23
+ y?: number;
24
+ };
25
+ /** Trigger-relative keyboard position. Defaults to 8px from the left and vertically centered. */
26
+ keyboardPosition?: {
27
+ x?: number;
28
+ y?: number | "top" | "center" | "bottom";
29
+ };
30
+ };
31
+ type PopupMenuAnchorBaseOptions = {
11
32
  /** Scrollable/clipping container used for out-of-view dismissal. */
12
33
  clipRoot?: MaybeRefOrGetter<Element | null>;
13
34
  /** Closes the menu after its trigger leaves the clipping container. */
14
35
  hideWhenAnchorClipped?: boolean;
15
- /** Intersection ratio required to keep the anchor considered visible. Defaults to `0.5`. */
36
+ /** Intersection ratio required to keep the anchor considered visible. */
16
37
  intersectionThreshold?: number | number[];
17
- /** Overrides the trigger rect for pointer-positioned menus. */
18
- getRect?: () => DOMRect | null;
38
+ /**
39
+ * Point on the anchor rect that `offset` is computed from. Mirrors Kendo
40
+ * Popup's `anchorAlign` semantics. Defaults to `{ vertical: "bottom",
41
+ * horizontal: "left" }`, matching Kendo's own default. Passing `anchorAlign`/
42
+ * `popupAlign` directly to `<Popup>` has no effect when positioning via
43
+ * `offset` (no `anchor` DOM ref) - Kendo's own alignment engine only runs
44
+ * against a real anchor element - so these options exist to reproduce that
45
+ * alignment ourselves in the computed `offset`.
46
+ */
47
+ anchorAlign?: PopupMenuAlign;
48
+ /**
49
+ * Point on the popup itself that is placed at the computed anchor point.
50
+ * Mirrors Kendo Popup's `popupAlign` semantics. Defaults to `{ vertical:
51
+ * "top", horizontal: "left" }`. Requires measuring the rendered popup
52
+ * content (`popupContentRef`, falling back to `menuRef`); until it has a
53
+ * size (e.g. the first open frame), non-"left"/"top" alignments briefly
54
+ * fall back to "left"/"top".
55
+ */
56
+ popupAlign?: PopupMenuAlign;
19
57
  };
58
+ /** Offset-positioning options for a Popup with a dynamically changing trigger. */
59
+ export type PopupMenuAnchorOptions = PopupMenuAnchorBaseOptions & ({
60
+ /** Tracks a pointer or keyboard activation point relative to the trigger. */
61
+ activationPoint: true | PopupMenuActivationPointOptions;
62
+ getRect?: never;
63
+ } | {
64
+ activationPoint?: never;
65
+ /** Resolves a custom anchor rect from the current trigger element. */
66
+ getRect?: (trigger: HTMLElement) => DOMRect | null;
67
+ });
20
68
  /** Why the popup was closed, including the focus policy for mouse paths. */
21
69
  export type PopupMenuCloseReason =
22
70
  /** Keyboard-driven menu selection (Enter/Space on a menu item); restores the configured focus target. */
@@ -48,10 +96,19 @@ export type KendoMenuSelectEvent<TItem = {
48
96
  };
49
97
  /** Options for configuring {@link usePopupMenu}. */
50
98
  export type UsePopupMenuOptions = {
51
- /** Reactive flag reflecting whether the popup menu is currently open. */
52
- isOpen: Ref<boolean>;
99
+ /** Read-only reactive flag reflecting whether the popup menu is currently open. */
100
+ isOpen: Readonly<Ref<boolean>>;
53
101
  /** Ref to the popup menu element or component. Used for outside-click detection. */
54
102
  menuRef: PopupMenuElementRef;
103
+ /**
104
+ * Ref to the full rendered popup content (e.g. a wrapper containing both a
105
+ * title and the Menu) used to measure size for a non-"left"/"top"
106
+ * `anchor.popupAlign`. Defaults to `menuRef` when omitted - which
107
+ * under-measures the popup whenever it renders anything besides the Menu
108
+ * (e.g. a title), throwing off `popupAlign`'s corner calculation. Not read
109
+ * for outside-click detection; that always uses `menuRef`.
110
+ */
111
+ popupContentRef?: PopupMenuElementRef;
55
112
  /** Ref to the trigger button element or component. Ignored by outside-click detection. */
56
113
  triggerRef: PopupMenuElementRef;
57
114
  /** Called to open the popup menu. Kendo passes its event args as a rest tuple. */
@@ -68,6 +125,12 @@ export type UsePopupMenuOptions = {
68
125
  * Takes precedence over `focusTargetRef`.
69
126
  */
70
127
  resolveFocusTarget?: () => HTMLElement | null;
128
+ /**
129
+ * Resolves the closest matching ancestor of the trigger as the configured
130
+ * keyboard-close focus target. Used after `resolveFocusTarget` and
131
+ * `focusTargetRef`.
132
+ */
133
+ focusTargetContainerSelector?: string;
71
134
  /**
72
135
  * Resolves a fallback focus target for Escape closes when no `focusTargetRef`/
73
136
  * `resolveFocusTarget` is configured, e.g. the trigger's containing grid row so
@@ -110,15 +173,17 @@ export type UsePopupMenuOptions = {
110
173
  };
111
174
  /** Functions returned by {@link usePopupMenu} for the action menu. */
112
175
  export type UsePopupMenuReturn = {
176
+ /** Captures an activation relative to a trigger for activation-point anchoring. */
177
+ setAnchorActivation: (trigger: HTMLElement | ComponentPublicInstance | null, activation: PopupMenuAnchorActivation) => boolean;
113
178
  /** Opens the menu, or closes it when it is already open. */
114
- handleActionButtonClick: (...args: unknown[]) => void;
179
+ handleTriggerClick: (...args: unknown[]) => void;
115
180
  /**
116
- * Handles keyboard input on the action button. Enter and Space open or close
181
+ * Handles keyboard input on the trigger. Enter and Space open or close
117
182
  * the menu. Escape closes an open menu or moves focus to the configured target.
118
183
  */
119
- handleActionButtonKeydown: (...args: unknown[]) => void;
184
+ handleTriggerKeydown: (...args: unknown[]) => void;
120
185
  /** Closes the menu when Escape is pressed while using the menu. */
121
- handleActionMenuEscape: (event: KeyboardEvent) => void;
186
+ handleMenuEscape: (event: KeyboardEvent) => void;
122
187
  /**
123
188
  * Closes the menu after an item is chosen, then runs the optional action
124
189
  * provided by the consuming component.
@@ -143,3 +208,4 @@ export type UsePopupMenuReturn = {
143
208
  * @returns Template event handlers for the action trigger, Kendo Menu, and Popup.
144
209
  */
145
210
  export declare const usePopupMenu: (options: UsePopupMenuOptions) => UsePopupMenuReturn;
211
+ export {};
@@ -2,6 +2,8 @@
2
2
 
3
3
  [Back to Composables README](https://github.com/NantHealth/featherk/blob/integration/packages/composables/README.md)
4
4
 
5
+ New to coordinating row and button menus in a Kendo Grid? Start with [Grid Popup Menu Orchestration](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuGridOrchestration.md).
6
+
5
7
  Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It manages trigger toggling, first-item focus, outside clicks, input-modality tracking, and focus restoration. It does not trap focus or own popup state; keep `usePopupTrap` separate when focus trapping or generic popup lifecycle behavior is needed.
6
8
 
7
9
  ## Quick Start
@@ -22,8 +24,8 @@ Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It man
22
24
  type="button"
23
25
  aria-haspopup="menu"
24
26
  aria-expanded="false"
25
- @click="menu.handleActionButtonClick"
26
- @keydown="menu.handleActionButtonKeydown"
27
+ @click="menu.handleTriggerClick"
28
+ @keydown="menu.handleTriggerKeydown"
27
29
  >
28
30
  Actions
29
31
  </button>
@@ -35,7 +37,7 @@ Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It man
35
37
  </strong>
36
38
  <Menu
37
39
  ref="menuRef"
38
- @keydown.escape="menu.handleActionMenuEscape"
40
+ @keydown.escape="menu.handleMenuEscape"
39
41
  @select="onSelect"
40
42
  />
41
43
  </Popup>
@@ -80,13 +82,13 @@ const onSelect = (event: KendoMenuSelectEvent) => {
80
82
 
81
83
  ## Grid Action Cell
82
84
 
83
- For a virtualized Kendo Grid, use `resolveFocusTarget` to locate the current row when the popup closes by keyboard. The resolver is evaluated at focus time, after the grid and popup have settled.
85
+ For a virtualized Kendo Grid, use `focusTargetContainerSelector` to restore keyboard focus to the trigger's current row without reaching through a registry.
84
86
 
85
87
  1. Add static `aria-haspopup="menu"` and `aria-expanded="false"` to the row's trigger button template.
86
88
  2. Read `showMenu` from the row item and keep open/close state parent-owned.
87
89
  3. Create trigger and menu refs as component refs (`ComponentPublicInstance`) for Kendo wrappers.
88
90
  4. Call `usePopupMenu(...)` with an explicit `triggerMode: "button"` and map `requestShow`/`requestHide` to row-specific emits.
89
- 5. Provide `resolveFocusTarget` so keyboard close restores focus to the current virtualized row.
91
+ 5. Provide `focusTargetContainerSelector` so keyboard close restores focus to the current virtualized row.
90
92
  6. Route menu selection through `handleMenuSelect(event, onAction)` so row action logic can use its active state before the popup closes.
91
93
 
92
94
  ```ts
@@ -118,13 +120,8 @@ const menu = usePopupMenu({
118
120
  triggerMode: "button",
119
121
  requestShow: () => emit("update:showMenu", props.dataItem.id),
120
122
  requestHide: () => emit("update:hideMenu", props.dataItem.id),
121
- // Step 5: keyboard close restores focus to current virtualized grid row
122
- resolveFocusTarget: () => {
123
- const trigger = triggerRef.value?.$el as HTMLElement | undefined;
124
- return (
125
- trigger?.closest(".k-table-row[data-grid-row-index]") ?? null
126
- ) as HTMLElement | null;
127
- },
123
+ // Step 5: keyboard close restores focus to the trigger's current row
124
+ focusTargetContainerSelector: ".k-table-row[data-grid-row-index]",
128
125
  });
129
126
 
130
127
  // Step 6: run row-specific business action before usePopupMenu closes
@@ -149,7 +146,9 @@ A grid row can have both a row-level context menu (`triggerMode: "row"`) and a n
149
146
 
150
147
  ```ts
151
148
  // Step 2-4: independent instances, independent triggerRef, explicit triggerMode
152
- const rowTriggerRef = computed(() => cellRef.value?.closest(".k-table-row") as HTMLElement | null);
149
+ const rowTriggerRef = computed(
150
+ () => cellRef.value?.closest(".k-table-row") as HTMLElement | null,
151
+ );
153
152
 
154
153
  const buttonMenu = usePopupMenu({
155
154
  isOpen: computed(() => props.dataItem.showButtonMenu),
@@ -170,7 +169,15 @@ const rowMenu = usePopupMenu({
170
169
  });
171
170
  ```
172
171
 
173
- See [UsePopupMenu.vue](https://github.com/NantHealth/featherk/blob/integration/demos/src/views/composables/UsePopupMenu.vue) for the full reference implementation: exactly two shared instances (one row-mode, one button-mode) for the whole grid, with per-row/button trigger registries and offset-positioned Popups instead of per-row `usePopupMenu` calls.
172
+ For a complete shared-instance implementation, see [Shared Instance Menus](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuSharedInstance.md).
173
+
174
+ ## Row vs. Button Trigger Differences
175
+
176
+ `triggerMode: "row"` and `triggerMode: "button"` share the same `handleTriggerClick`/`handleTriggerKeydown`/`handleMenuSelect` code paths - opening, closing, and selection logic is identical either way. What actually differs:
177
+
178
+ - **Anchor positioning convention (not enforced, just the natural fit).** A row spans the full grid width with no single natural corner to anchor to, and the same row can be clicked at a different point every time - so row triggers conventionally use `anchor.activationPoint` (dynamic, positioned at the click/keyboard point). A button is a small, fixed-size element with an obvious anchor corner, so button triggers conventionally use plain rect-based anchoring (`anchor: true`, or `anchor` omitted) - the menu simply drops below the button every time, in the same spot. Nothing stops flipping this (e.g. `activationPoint` on a right-click-anywhere button, or a fixed rect anchor for a row menu that should always appear in one corner) - but for a shared/registry-driven trigger, `activationPoint` always requires the consumer to call `setAnchorActivation(trigger, activation)` with real coordinates before activating (see "Cursor-Positioned Row Menus" above), regardless of which mode it's paired with. `usePopupMenu` cannot capture this automatically for a shared instance, because it only ever sees the *currently active* trigger, never the one about to become active.
179
+ - **ARIA element matching is enforced, not just conventional.** `triggerMode: "row"` only manages an element matching `tr.k-table-row`; `triggerMode: "button"` only manages an element that does **not** match `tr.k-table-row`. If the resolved `triggerRef` doesn't match the declared mode (e.g. `triggerMode: "button"` resolves to a `tr`), the composable logs a `console.warn` and skips ARIA management entirely for that resolution, rather than silently managing the wrong element shape.
180
+ - **Escape fallback target differs.** `triggerMode: "button"` defaults `escapeFocusContainerSelector` to `.k-table-row` - pressing Escape with no closer configured focus target walks up from the button to its containing row and focuses that, keeping keyboard users in the grid's row navigation instead of stranding them on a small nested cell. `triggerMode: "row"` has no such default: the trigger *is* the row already, so Escape with no configured target simply refocuses it directly - there's nothing to "walk up" to.
174
181
 
175
182
  ## Sharing One Instance Across Many Triggers (Grid-Wide)
176
183
 
@@ -179,8 +186,8 @@ A single `usePopupMenu` instance can manage an entire list of same-mode triggers
179
186
  1. Track exactly one "active id" for the whole list, instead of a boolean per row item, via `useActiveIdRegistry<Id>()`. Only one trigger in the list can be open at a time. It exposes a read-only `activeId`, a reactive `isActive`/`activeElement` pair, and `register`/`resolve`/`activate`/`deactivate`/`isIdActive` helpers.
180
187
  2. Register each row/button element into the registry as it mounts (e.g. a `row-render` ref callback, or a component `:ref` callback) via `registry.register(id, el)`. The registry's internal element map is reactive, so `activeElement` stays correct even if a keyed/virtualized re-render swaps out the active id's underlying DOM node.
181
188
  3. Feed `isOpen: registry.isActive` and `triggerRef: registry.activeElement` straight into `usePopupMenu`, rather than deriving them from a per-row prop.
182
- 4. Enable `anchor: true` and bind the shared `Popup` to `:offset="menu.offset"`, not `:anchor`. Kendo's `Popup.anchor` only resolves a static template-ref name once at mount; it cannot dynamically retarget a different element after the popup is already mounted. Use Kendo's `anchorAlign` and `popupAlign` props when the popup should align to a specific edge of its button. Use `anchor: { getRect: () => ... }` for cursor-positioned menus.
183
- 5. Drive show/hide through `registry.activate(id)` / `registry.deactivate()` rather than binding `handleActionButtonClick` directly to every trigger's `@click`. A single instance's `isOpen` is one shared boolean, so `handleActionButtonClick()` cannot tell "close this trigger" apart from "open a different trigger" — calling it while another id is active will close the wrong thing.
189
+ 4. Enable `anchor: true` and bind the shared `Popup` to `:offset="menu.offset"`, not `:anchor`. Kendo's `Popup.anchor` only resolves a static template-ref name once at mount; it cannot dynamically retarget a different element after the popup is already mounted. Use `anchor.activationPoint` for pointer/keyboard context menus and `getRect(trigger)` only for custom geometry.
190
+ 5. Drive show/hide through `registry.activate(id)` / `registry.deactivate()` rather than binding `handleTriggerClick` directly to every shared trigger. A single instance's `isOpen` is one shared boolean, so the generic toggle handler cannot distinguish closing the current id from opening another id.
184
191
 
185
192
  ```ts
186
193
  // Step 1: one registry tracks the active id and resolves it to its DOM element
@@ -212,23 +219,74 @@ const toggleRowMenu = (id: number) => {
212
219
  };
213
220
  ```
214
221
 
215
- For a second shared instance driven by a real `@click` handler on each trigger (e.g. a per-row action button, where `handleActionButtonClick` toggle semantics would otherwise misfire on a different trigger), close the currently active trigger explicitly through the composable before switching the active id, so its `triggerRef` still resolves to the correct element at close time:
222
+ ### Cursor-Positioned Row Menus That Follow Scrolling
223
+
224
+ Enable `anchor.activationPoint`, call `setAnchorActivation(row, context)` before activating the shared row id, and bind `offset` to the Popup. The composable owns trigger-relative coordinate conversion, keyboard placement, scroll reconstruction, clipping, and cleanup.
225
+
226
+ ```ts
227
+ const rowMenu = usePopupMenu({
228
+ isOpen: rowRegistry.isActive,
229
+ triggerRef: rowRegistry.activeElement,
230
+ menuRef: rowMenuRef,
231
+ triggerMode: "row",
232
+ anchor: {
233
+ activationPoint: { pointerOffset: { x: -20, y: -20 } },
234
+ },
235
+ requestShow: () => {},
236
+ requestHide: () => rowRegistry.deactivate(),
237
+ });
238
+
239
+ const openRowMenu = (id: number, context: RowActionContext) => {
240
+ const row = rowRegistry.resolve(id);
241
+ if (!row || !rowMenu.setAnchorActivation(row, context)) return;
242
+ rowRegistry.activate(id);
243
+ };
244
+ ```
245
+
246
+ See [Activation-Point Anchors](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuActivationAnchor.md) for the complete standalone setup and custom keyboard placement.
247
+
248
+ For a second shared instance driven by a real `@click` handler on each trigger, close the currently active trigger explicitly before switching the active id, so its `triggerRef` still resolves to the correct element at close time:
216
249
 
217
250
  ```ts
218
251
  const toggleButtonMenu = (id: number) => {
219
252
  if (buttonRegistry.isIdActive(id)) {
220
- buttonMenu.handleActionButtonClick(); // closes: triggerRef still matches this id
253
+ buttonMenu.handleTriggerClick(); // closes: triggerRef still matches this id
221
254
  return;
222
255
  }
223
256
  if (buttonRegistry.isActive.value) {
224
- buttonMenu.handleActionButtonClick(); // closes the OTHER trigger correctly, before switching
257
+ buttonMenu.handleTriggerClick(); // closes the OTHER trigger correctly, before switching
225
258
  }
226
259
  buttonRegistry.activate(id); // the reactive watcher promotes the new trigger's aria-expanded
227
260
  nextTick(focusFirstMenuItem); // reproduce first-item focus manually; the "open" branch never ran
228
261
  };
229
262
  ```
230
263
 
231
- See [UsePopupMenu.vue](https://github.com/NantHealth/featherk/blob/integration/demos/src/views/composables/UsePopupMenu.vue) for the complete working version of both patterns, including the registry-building `row-render` function and composable-owned offset tracking. `useActiveIdRegistry` is now part of `@featherk/composables`, so shared-instance consumers can import it directly instead of keeping a demo-local copy.
264
+ See [Focus and Exclusivity](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuFocusAndExclusivity.md) for complete focus precedence and multi-menu coordination examples.
265
+
266
+ ## Pairing with `usePopupTrap`
267
+
268
+ `usePopupMenu` deliberately does not trap focus (see the intro above) - a real menu should let `Tab` move on to the next element in the page, not cycle inside it. If a specific popup genuinely needs `Tab` trapped (behaving more like a modal than a menu), compose `usePopupTrap` alongside it rather than fighting its own close/focus-restoration logic:
269
+
270
+ 1. Pass the same `isOpen` and the resolved trigger element (`triggerEl`) that you gave `usePopupMenu`.
271
+ 2. Omit `onRequestClose` entirely. `usePopupTrap` has its own outside-click/Escape detection, but without `onRequestClose` it never acts on it - `usePopupMenu` remains the sole decision-maker for when the popup closes. Passing `onRequestClose` here would let two composables independently decide to close on the same interaction.
272
+ 3. Set `initialFocus: () => document.activeElement`. `usePopupMenu` already focuses the first enabled menu item on open; this preserves that instead of refocusing a possibly different element.
273
+ 4. Set `returnFocusToTrigger: false`. This only disables `usePopupTrap`'s own hand-rolled Escape-focus logic.
274
+ 5. **Also set `focusTrapOptions: { returnFocusOnDeactivate: false }`.** This is a separate, easy-to-miss setting: the underlying `focus-trap` library has its own independent `returnFocusOnDeactivate` option that defaults to `true` and fires on every `deactivate()`, restoring focus to whatever it captured as "previously focused" - completely bypassing step 4 and racing `usePopupMenu`'s own (correct) restoration target. Skipping this leaves focus landing on the wrong element after close, even though everything else is wired correctly.
275
+ 6. If more than one `<Popup>` is rendered in the same template (e.g. a shared row menu and a shared button menu, both permanently mounted with only `:show` toggling visibility), pass `resolvePopupEl` pointing at that specific `<Popup>` by a stable `id`. Without it, `usePopupTrap`'s default class-based lookup can't reliably tell multiple `.k-popup` elements apart and may bind the wrong one.
276
+
277
+ `usePopupTrap` already defaults `allowOutsideClick` to `true` internally - no consumer action needed for that one, but see [usePopupTrap](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/trap/usePopupTrap.md) for why it matters: without it, a genuinely active trap swallows every outside click (e.g. a second trigger in the same grid row), not just ones that would close it.
278
+
279
+ ```ts
280
+ usePopupTrap({
281
+ isOpen: registry.isActive,
282
+ triggerEl: registry.activeElement,
283
+ // Step 6: only required when this popup coexists with another in the template.
284
+ resolvePopupEl: () => document.getElementById("my-shared-popup"),
285
+ initialFocus: () => document.activeElement as HTMLElement | null,
286
+ returnFocusToTrigger: false,
287
+ focusTrapOptions: { returnFocusOnDeactivate: false },
288
+ });
289
+ ```
232
290
 
233
291
  ## API
234
292
 
@@ -236,26 +294,29 @@ See [UsePopupMenu.vue](https://github.com/NantHealth/featherk/blob/integration/d
236
294
 
237
295
  #### Options
238
296
 
239
- - `isOpen: Ref<boolean>`: Parent-owned popup visibility state.
297
+ - `isOpen: Readonly<Ref<boolean>>`: Parent-owned popup visibility state observed, but never mutated, by the composable.
240
298
  - `menuRef: PopupMenuElementRef`: Ref to the Kendo `Menu` element or component.
299
+ - `popupContentRef?: PopupMenuElementRef`: What a non-default `anchor.popupAlign` measures for the popup's own size. Defaults to `menuRef`; see "Aligning the Popup to the Anchor" below.
241
300
  - `triggerRef: PopupMenuElementRef`: Ref to the action trigger element or component.
242
301
  - `requestShow(): void`: Opens the parent-owned popup state.
243
302
  - `requestHide(): void`: Closes the parent-owned popup state.
244
303
  - `focusTargetRef?: PopupMenuElementRef`: Optional element/component to focus after a keyboard-driven close.
245
304
  - `resolveFocusTarget?: () => HTMLElement | null`: Dynamic focus-target resolver. It takes precedence over `focusTargetRef` and is useful for virtualized grid rows.
305
+ - `focusTargetContainerSelector?: string`: Closest trigger ancestor to focus after any keyboard-driven close. Used after `resolveFocusTarget` and `focusTargetRef`.
246
306
  - `menuItemSelector?: string`: Selector for the first enabled item. Defaults to `.k-menu-item:not(.k-disabled)`.
247
307
  - `manageMenuTriggerAria?: boolean`: Keeps `aria-expanded` in sync with `isOpen` on the resolved `triggerRef` element. Defaults to `true`. The consumer always owns the static `aria-haspopup="menu"` attribute and the baseline `aria-expanded="false"`; the composable only ever writes `aria-expanded` and never removes it. Set to `false` to own `aria-expanded` yourself too.
248
308
  - `menuLabel?: MaybeRefOrGetter<string | null | undefined>`: Optional accessible name applied as `aria-label` to the rendered `ul[role="menubar"]`. Empty labels remove the managed attribute.
249
309
  - `triggerMode: "button" | "row"`: Required. Declares the trigger semantics so a misrouted `triggerRef` (e.g. one that unexpectedly resolves to a `tr.k-table-row` while `triggerMode: "button"` is set) is never silently managed; a mismatch logs a `console.warn` and skips ARIA management for that resolution. Use `"row"` only when a `tr.k-table-row` owns the menu trigger. Neither mode adds `role`, `tabindex`, or keyboard behavior.
250
- - `anchor?: true | PopupMenuAnchorOptions`: Enables document-relative `offset` tracking for dynamically retargeted Popups. `true` uses the resolved trigger's rectangle and nearest scrollable ancestor. `PopupMenuAnchorOptions` accepts `clipRoot`, `hideWhenAnchorClipped`, `intersectionThreshold`, and `getRect` for pointer-positioned menus. The threshold defaults to `0.5`; use `intersectionThreshold: 0` when any partially visible trigger should remain open. A fully clipped trigger closes with `"anchor-hidden"` without restoring focus.
310
+ - `anchor?: true | PopupMenuAnchorOptions`: Enables document-relative offset tracking. `true` follows the trigger's bottom-left corner. `activationPoint` captures pointer/keyboard positions relative to the trigger and defaults its clipping threshold to `0`; `getRect(trigger)` remains the mutually exclusive custom-geometry escape hatch. Without `clipRoot`, the composable prefers the nearest overflow-enabled ancestor that can actually scroll, skipping styled wrappers with no overflow. Other anchors default to threshold `0.5`. A fully clipped trigger closes with `"anchor-hidden"` without restoring focus. `anchorAlign`/`popupAlign` (see below) configure the computed corner.
251
311
 
252
312
  `PopupMenuElementRef` accepts a normal `HTMLElement` ref or a Vue/Kendo component ref that exposes `$el`.
253
313
 
254
314
  #### Returns
255
315
 
256
- - `handleActionButtonClick()`: Toggles the menu and focuses the first enabled item after opening.
257
- - `handleActionButtonKeydown(event)`: `Enter` and `Space` toggle; `Escape` closes, or focuses the configured target when already closed.
258
- - `handleActionMenuEscape(event)`: Closes an open menu with keyboard modality.
316
+ - `setAnchorActivation(trigger, activation)`: Captures a pointer or keyboard activation for `anchor.activationPoint`; returns `false` when activation anchoring or a trigger is unavailable.
317
+ - `handleTriggerClick()`: Toggles the menu and focuses the first enabled item after opening.
318
+ - `handleTriggerKeydown(event)`: `Enter` and `Space` toggle; `Escape` closes, or focuses the configured target when already closed.
319
+ - `handleMenuEscape(event)`: Closes an open menu with keyboard modality.
259
320
  - `handleMenuSelect(event, onAction?)`: Records the selection modality, requests close, then invokes optional business logic.
260
321
  - `handlePopupClose()`: Restores focus after Kendo Popup has closed.
261
322
  - `offset`: A computed `{ left, top }` value for Kendo Popup's `:offset`; it is `{ left: 0, top: 0 }` while closed.
@@ -271,12 +332,63 @@ export type PopupMenuCloseReason =
271
332
  | "anchor-hidden"
272
333
  | null;
273
334
  export type PopupMenuCloseTrigger = Exclude<PopupMenuCloseReason, null>;
335
+ export type PopupMenuAlign = {
336
+ horizontal: "left" | "center" | "right";
337
+ vertical: "top" | "center" | "bottom";
338
+ };
274
339
  export type KendoMenuSelectEvent = {
275
340
  item?: { text?: string };
276
341
  event?: { type?: string } | null;
277
342
  };
278
343
  ```
279
344
 
345
+ ## Aligning the Popup to the Anchor (`anchorAlign` / `popupAlign`)
346
+
347
+ Kendo's `<Popup>` only runs its own `anchorAlign`/`popupAlign` alignment engine when it is positioned via a real `anchor` DOM ref. `usePopupMenu` always positions through `:offset` instead - because Kendo's `anchor` prop resolves a static template-ref name once at mount and cannot retarget a different element per open (see the shared-instance section above) - so passing `anchor-align`/`popup-align` directly to `<Popup>` is a silent no-op when using `usePopupMenu`. Configure alignment through `anchor.anchorAlign`/`anchor.popupAlign` instead; `usePopupMenu` computes the aligned `offset` itself.
348
+
349
+ - `anchorAlign?: PopupMenuAlign`: The point on the anchor rect that `offset` is computed from. Defaults to `{ horizontal: "left", vertical: "bottom" }`, matching Kendo's own default. Meaningless (a no-op) against an `activationPoint` anchor, since that anchor is already a single point with no width/height.
350
+ - `popupAlign?: PopupMenuAlign`: The point on the popup itself that lands at the computed anchor point. Defaults to `{ horizontal: "left", vertical: "top" }`. A `"center"`/`"right"`/`"bottom"` value requires measuring the rendered popup content (via `popupContentRef`, falling back to `menuRef`), so on the very first open there can be a one-frame snap from the top-left default to the final aligned position while that measurement completes.
351
+ - `popupContentRef?: PopupMenuElementRef`: What `popupAlign` measures for the popup's own size. Defaults to `menuRef`, which **under-measures** the popup whenever it renders anything besides the Menu - e.g. a title `<strong>` above it, as in the Grid Action Cell example - throwing off the computed corner (especially when that extra content is wider or taller than the Menu itself). Point this at a wrapper element containing everything rendered inside the `<Popup>` whenever `popupAlign` is not `{ horizontal: "left", vertical: "top" }` and the popup renders more than just the Menu.
352
+
353
+ ```ts
354
+ const buttonMenu = usePopupMenu({
355
+ isOpen: registry.isActive,
356
+ triggerRef: registry.activeElement,
357
+ menuRef: menuRef,
358
+ // Only needed because the template below renders a title above the Menu -
359
+ // without it, popupAlign would measure just the Menu and mis-align the popup.
360
+ popupContentRef: popupContentRef,
361
+ triggerMode: "button",
362
+ anchor: {
363
+ anchorAlign: { horizontal: "right", vertical: "bottom" },
364
+ popupAlign: { horizontal: "right", vertical: "top" },
365
+ },
366
+ requestShow: () => {},
367
+ requestHide: () => registry.deactivate(),
368
+ });
369
+ ```
370
+
371
+ ```vue
372
+ <Popup :show="isOpen" :offset="buttonMenu.offset.value">
373
+ <!-- popupContentRef wraps everything the Popup renders, not just the Menu. -->
374
+ <div ref="popupContentRef">
375
+ <strong v-if="activeTitle">{{ activeTitle }}</strong>
376
+ <Menu ref="menuRef" :items="activeMenuItems" />
377
+ </div>
378
+ </Popup>
379
+ ```
380
+
381
+ ## Flipping the Popup on Viewport Collision
382
+
383
+ Whenever `anchor` is configured, `usePopupMenu` automatically flips the popup to the opposite side of the anchor, per axis, if the preferred `anchorAlign`/`popupAlign` corner would render outside the viewport (or the configured `clipRoot`, when set). This is always on and requires no configuration - it exists because Kendo's own collision-avoidance engine, like its alignment engine, only runs against a real `anchor` DOM ref and is a no-op against `usePopupMenu`'s `:offset`-based positioning.
384
+
385
+ - Each axis is flipped independently: a popup can flip horizontally, vertically, both, or neither, depending on which edges it would overflow.
386
+ - A flip is only committed if it actually reduces the overflow on that axis; a popup wider or taller than the available space is left at its preferred position rather than flipped back and forth.
387
+ - Flip detection requires measuring the popup's real size, so `popupRect` is now measured (via `ResizeObserver`) whenever `anchor` is configured, not only when `popupAlign` is non-default.
388
+ - `"center"` alignment on either axis never flips - flipping center to center is a no-op by definition.
389
+ - Bounds default to the viewport (`window.innerWidth`/`innerHeight`); pass `anchor.clipRoot` to constrain flipping to a scrollable ancestor instead (the same container used for `hideWhenAnchorClipped`).
390
+ - The underlying math is a pure, stateless function - `computeFlippedOffset` in `@featherk/composables/geometry` - reusable outside `usePopupMenu` for any anchor/popup positioning problem.
391
+
280
392
  ## Behavior
281
393
 
282
394
  - Pointer click, `Enter`, and `Space` toggle the menu.
@@ -0,0 +1,132 @@
1
+ # usePopupMenu Activation-Point Anchors
2
+
3
+ [Back to usePopupMenu](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenu.md)
4
+
5
+ Use an activation-point anchor when a context menu must open near a pointer or at a predictable keyboard position and continue following its trigger during scrolling.
6
+
7
+ ## Quick Start
8
+
9
+ 1. Register each trigger element by id.
10
+ 2. Enable `anchor.activationPoint` and bind `menu.offset` to the Popup.
11
+ 3. Capture the activation against the trigger before activating its id.
12
+ 4. Keep visibility parent-owned through the registry.
13
+
14
+ ```vue
15
+ <template>
16
+ <table>
17
+ <tbody>
18
+ <tr
19
+ v-for="row in rows"
20
+ :key="row.id"
21
+ :ref="(element) => registerRow(row.id, element)"
22
+ class="k-table-row"
23
+ tabindex="0"
24
+ aria-haspopup="menu"
25
+ aria-expanded="false"
26
+ @click="openFromPointer(row.id, $event)"
27
+ @keydown.enter.prevent="openFromKeyboard(row.id)"
28
+ @keydown.space.prevent="openFromKeyboard(row.id)"
29
+ >
30
+ <td>{{ row.name }}</td>
31
+ </tr>
32
+ </tbody>
33
+ </table>
34
+
35
+ <Popup
36
+ :show="rowRegistry.isActive.value"
37
+ :offset="menu.offset.value"
38
+ @close="menu.handlePopupClose"
39
+ >
40
+ <Menu
41
+ ref="menuRef"
42
+ :items="items"
43
+ :vertical="true"
44
+ @keydown.escape="menu.handleMenuEscape"
45
+ @select="menu.handleMenuSelect"
46
+ />
47
+ </Popup>
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import { ref, type ComponentPublicInstance } from "vue";
52
+ import { Popup } from "@progress/kendo-vue-popup";
53
+ import { Menu } from "@progress/kendo-vue-layout";
54
+ import { usePopupMenu } from "@featherk/composables/menu";
55
+ import { useActiveIdRegistry } from "@featherk/composables/registry";
56
+
57
+ const rows = [
58
+ { id: 1, name: "Alpha" },
59
+ { id: 2, name: "Beta" },
60
+ ];
61
+ const items = [{ text: "Open" }, { text: "Archive" }];
62
+ const rowRegistry = useActiveIdRegistry<number>();
63
+ const menuRef = ref<ComponentPublicInstance | null>(null);
64
+
65
+ // Step 2: pointer coordinates receive 12px of visual clearance. Keyboard
66
+ // activation defaults to x=8 and the trigger's vertical center.
67
+ const menu = usePopupMenu({
68
+ isOpen: rowRegistry.isActive,
69
+ triggerRef: rowRegistry.activeElement,
70
+ menuRef,
71
+ triggerMode: "row",
72
+ anchor: {
73
+ activationPoint: { pointerOffset: { x: -12, y: -12 } },
74
+ },
75
+ requestShow: () => {},
76
+ requestHide: () => rowRegistry.deactivate(),
77
+ });
78
+
79
+ // Step 1: the registry resolves an id to the current DOM element, including
80
+ // after keyed or virtualized rendering replaces it.
81
+ const registerRow = (id: number, element: unknown) => {
82
+ rowRegistry.register(id, element as HTMLElement | null);
83
+ };
84
+
85
+ const activate = (
86
+ id: number,
87
+ activation: Parameters<typeof menu.setAnchorActivation>[1],
88
+ ) => {
89
+ const row = rowRegistry.resolve(id);
90
+ // Step 3: capture before activation because triggerRef is still null or may
91
+ // still resolve to the previously active row.
92
+ if (!row || !menu.setAnchorActivation(row, activation)) return;
93
+
94
+ // Step 4: the registry remains the owner of open state.
95
+ rowRegistry.activate(id);
96
+ };
97
+
98
+ const openFromPointer = (id: number, event: MouseEvent) => {
99
+ activate(id, {
100
+ triggerType: "click",
101
+ coordinates: { clientX: event.clientX, clientY: event.clientY },
102
+ });
103
+ };
104
+
105
+ const openFromKeyboard = (id: number) => {
106
+ activate(id, { triggerType: "keyboard" });
107
+ };
108
+ </script>
109
+ ```
110
+
111
+ ## Keyboard Position
112
+
113
+ The default keyboard point is 8px inside the trigger's left edge and vertically centered. Override it when needed:
114
+
115
+ ```ts
116
+ anchor: {
117
+ activationPoint: {
118
+ keyboardPosition: { x: 12, y: "bottom" },
119
+ },
120
+ }
121
+ ```
122
+
123
+ `y` accepts a number or `"top"`, `"center"`, or `"bottom"`.
124
+
125
+ ## Behavior
126
+
127
+ - Pointer coordinates are converted to a trigger-relative point once.
128
+ - Scroll and resize measurements reconstruct that point from the trigger's current rectangle.
129
+ - The clipping threshold defaults to `0`; the menu stays open while any part of the trigger remains visible.
130
+ - A fully clipped trigger closes with `"anchor-hidden"` without restoring focus.
131
+ - Activation metadata is cleared whenever `isOpen` becomes false.
132
+ - Use custom `getRect(trigger)` instead when positioning does not originate from a pointer or keyboard activation.