@featherk/composables 0.12.4 → 0.13.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.
@@ -6,17 +6,43 @@ export type PopupMenuOffset = {
6
6
  left: number;
7
7
  top: number;
8
8
  };
9
- /** Offset-positioning options for a Popup with a dynamically changing trigger. */
10
- export type PopupMenuAnchorOptions = {
9
+ export type PopupMenuAnchorActivation = {
10
+ triggerType: "click" | "keyboard";
11
+ coordinates?: {
12
+ clientX: number;
13
+ clientY: number;
14
+ };
15
+ };
16
+ export type PopupMenuActivationPointOptions = {
17
+ /** Added to pointer coordinates after they are made trigger-relative. */
18
+ pointerOffset?: {
19
+ x?: number;
20
+ y?: number;
21
+ };
22
+ /** Trigger-relative keyboard position. Defaults to 8px from the left and vertically centered. */
23
+ keyboardPosition?: {
24
+ x?: number;
25
+ y?: number | "top" | "center" | "bottom";
26
+ };
27
+ };
28
+ type PopupMenuAnchorBaseOptions = {
11
29
  /** Scrollable/clipping container used for out-of-view dismissal. */
12
30
  clipRoot?: MaybeRefOrGetter<Element | null>;
13
31
  /** Closes the menu after its trigger leaves the clipping container. */
14
32
  hideWhenAnchorClipped?: boolean;
15
- /** Intersection ratio required to keep the anchor considered visible. Defaults to `0.5`. */
33
+ /** Intersection ratio required to keep the anchor considered visible. */
16
34
  intersectionThreshold?: number | number[];
17
- /** Overrides the trigger rect for pointer-positioned menus. */
18
- getRect?: () => DOMRect | null;
19
35
  };
36
+ /** Offset-positioning options for a Popup with a dynamically changing trigger. */
37
+ export type PopupMenuAnchorOptions = PopupMenuAnchorBaseOptions & ({
38
+ /** Tracks a pointer or keyboard activation point relative to the trigger. */
39
+ activationPoint: true | PopupMenuActivationPointOptions;
40
+ getRect?: never;
41
+ } | {
42
+ activationPoint?: never;
43
+ /** Resolves a custom anchor rect from the current trigger element. */
44
+ getRect?: (trigger: HTMLElement) => DOMRect | null;
45
+ });
20
46
  /** Why the popup was closed, including the focus policy for mouse paths. */
21
47
  export type PopupMenuCloseReason =
22
48
  /** Keyboard-driven menu selection (Enter/Space on a menu item); restores the configured focus target. */
@@ -48,8 +74,8 @@ export type KendoMenuSelectEvent<TItem = {
48
74
  };
49
75
  /** Options for configuring {@link usePopupMenu}. */
50
76
  export type UsePopupMenuOptions = {
51
- /** Reactive flag reflecting whether the popup menu is currently open. */
52
- isOpen: Ref<boolean>;
77
+ /** Read-only reactive flag reflecting whether the popup menu is currently open. */
78
+ isOpen: Readonly<Ref<boolean>>;
53
79
  /** Ref to the popup menu element or component. Used for outside-click detection. */
54
80
  menuRef: PopupMenuElementRef;
55
81
  /** Ref to the trigger button element or component. Ignored by outside-click detection. */
@@ -68,6 +94,12 @@ export type UsePopupMenuOptions = {
68
94
  * Takes precedence over `focusTargetRef`.
69
95
  */
70
96
  resolveFocusTarget?: () => HTMLElement | null;
97
+ /**
98
+ * Resolves the closest matching ancestor of the trigger as the configured
99
+ * keyboard-close focus target. Used after `resolveFocusTarget` and
100
+ * `focusTargetRef`.
101
+ */
102
+ focusTargetContainerSelector?: string;
71
103
  /**
72
104
  * Resolves a fallback focus target for Escape closes when no `focusTargetRef`/
73
105
  * `resolveFocusTarget` is configured, e.g. the trigger's containing grid row so
@@ -110,15 +142,17 @@ export type UsePopupMenuOptions = {
110
142
  };
111
143
  /** Functions returned by {@link usePopupMenu} for the action menu. */
112
144
  export type UsePopupMenuReturn = {
145
+ /** Captures an activation relative to a trigger for activation-point anchoring. */
146
+ setAnchorActivation: (trigger: HTMLElement | ComponentPublicInstance | null, activation: PopupMenuAnchorActivation) => boolean;
113
147
  /** Opens the menu, or closes it when it is already open. */
114
- handleActionButtonClick: (...args: unknown[]) => void;
148
+ handleTriggerClick: (...args: unknown[]) => void;
115
149
  /**
116
- * Handles keyboard input on the action button. Enter and Space open or close
150
+ * Handles keyboard input on the trigger. Enter and Space open or close
117
151
  * the menu. Escape closes an open menu or moves focus to the configured target.
118
152
  */
119
- handleActionButtonKeydown: (...args: unknown[]) => void;
153
+ handleTriggerKeydown: (...args: unknown[]) => void;
120
154
  /** Closes the menu when Escape is pressed while using the menu. */
121
- handleActionMenuEscape: (event: KeyboardEvent) => void;
155
+ handleMenuEscape: (event: KeyboardEvent) => void;
122
156
  /**
123
157
  * Closes the menu after an item is chosen, then runs the optional action
124
158
  * provided by the consuming component.
@@ -143,3 +177,4 @@ export type UsePopupMenuReturn = {
143
177
  * @returns Template event handlers for the action trigger, Kendo Menu, and Popup.
144
178
  */
145
179
  export declare const usePopupMenu: (options: UsePopupMenuOptions) => UsePopupMenuReturn;
180
+ 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,7 @@ 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).
174
173
 
175
174
  ## Sharing One Instance Across Many Triggers (Grid-Wide)
176
175
 
@@ -179,8 +178,8 @@ A single `usePopupMenu` instance can manage an entire list of same-mode triggers
179
178
  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
179
  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
180
  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.
181
+ 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.
182
+ 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
183
 
185
184
  ```ts
186
185
  // Step 1: one registry tracks the active id and resolves it to its DOM element
@@ -212,23 +211,49 @@ const toggleRowMenu = (id: number) => {
212
211
  };
213
212
  ```
214
213
 
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:
214
+ ### Cursor-Positioned Row Menus That Follow Scrolling
215
+
216
+ 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.
217
+
218
+ ```ts
219
+ const rowMenu = usePopupMenu({
220
+ isOpen: rowRegistry.isActive,
221
+ triggerRef: rowRegistry.activeElement,
222
+ menuRef: rowMenuRef,
223
+ triggerMode: "row",
224
+ anchor: {
225
+ activationPoint: { pointerOffset: { x: -20, y: -20 } },
226
+ },
227
+ requestShow: () => {},
228
+ requestHide: () => rowRegistry.deactivate(),
229
+ });
230
+
231
+ const openRowMenu = (id: number, context: RowActionContext) => {
232
+ const row = rowRegistry.resolve(id);
233
+ if (!row || !rowMenu.setAnchorActivation(row, context)) return;
234
+ rowRegistry.activate(id);
235
+ };
236
+ ```
237
+
238
+ 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.
239
+
240
+ 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
241
 
217
242
  ```ts
218
243
  const toggleButtonMenu = (id: number) => {
219
244
  if (buttonRegistry.isIdActive(id)) {
220
- buttonMenu.handleActionButtonClick(); // closes: triggerRef still matches this id
245
+ buttonMenu.handleTriggerClick(); // closes: triggerRef still matches this id
221
246
  return;
222
247
  }
223
248
  if (buttonRegistry.isActive.value) {
224
- buttonMenu.handleActionButtonClick(); // closes the OTHER trigger correctly, before switching
249
+ buttonMenu.handleTriggerClick(); // closes the OTHER trigger correctly, before switching
225
250
  }
226
251
  buttonRegistry.activate(id); // the reactive watcher promotes the new trigger's aria-expanded
227
252
  nextTick(focusFirstMenuItem); // reproduce first-item focus manually; the "open" branch never ran
228
253
  };
229
254
  ```
230
255
 
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.
256
+ 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.
232
257
 
233
258
  ## API
234
259
 
@@ -236,26 +261,28 @@ See [UsePopupMenu.vue](https://github.com/NantHealth/featherk/blob/integration/d
236
261
 
237
262
  #### Options
238
263
 
239
- - `isOpen: Ref<boolean>`: Parent-owned popup visibility state.
264
+ - `isOpen: Readonly<Ref<boolean>>`: Parent-owned popup visibility state observed, but never mutated, by the composable.
240
265
  - `menuRef: PopupMenuElementRef`: Ref to the Kendo `Menu` element or component.
241
266
  - `triggerRef: PopupMenuElementRef`: Ref to the action trigger element or component.
242
267
  - `requestShow(): void`: Opens the parent-owned popup state.
243
268
  - `requestHide(): void`: Closes the parent-owned popup state.
244
269
  - `focusTargetRef?: PopupMenuElementRef`: Optional element/component to focus after a keyboard-driven close.
245
270
  - `resolveFocusTarget?: () => HTMLElement | null`: Dynamic focus-target resolver. It takes precedence over `focusTargetRef` and is useful for virtualized grid rows.
271
+ - `focusTargetContainerSelector?: string`: Closest trigger ancestor to focus after any keyboard-driven close. Used after `resolveFocusTarget` and `focusTargetRef`.
246
272
  - `menuItemSelector?: string`: Selector for the first enabled item. Defaults to `.k-menu-item:not(.k-disabled)`.
247
273
  - `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
274
  - `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
275
  - `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.
276
+ - `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.
251
277
 
252
278
  `PopupMenuElementRef` accepts a normal `HTMLElement` ref or a Vue/Kendo component ref that exposes `$el`.
253
279
 
254
280
  #### Returns
255
281
 
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.
282
+ - `setAnchorActivation(trigger, activation)`: Captures a pointer or keyboard activation for `anchor.activationPoint`; returns `false` when activation anchoring or a trigger is unavailable.
283
+ - `handleTriggerClick()`: Toggles the menu and focuses the first enabled item after opening.
284
+ - `handleTriggerKeydown(event)`: `Enter` and `Space` toggle; `Escape` closes, or focuses the configured target when already closed.
285
+ - `handleMenuEscape(event)`: Closes an open menu with keyboard modality.
259
286
  - `handleMenuSelect(event, onAction?)`: Records the selection modality, requests close, then invokes optional business logic.
260
287
  - `handlePopupClose()`: Restores focus after Kendo Popup has closed.
261
288
  - `offset`: A computed `{ left, top }` value for Kendo Popup's `:offset`; it is `{ left: 0, top: 0 }` while closed.
@@ -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.
@@ -0,0 +1,105 @@
1
+ # usePopupMenu Focus and Exclusivity
2
+
3
+ [Back to usePopupMenu](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenu.md)
4
+
5
+ This guide covers keyboard focus restoration and coordination between independently configured popup menus.
6
+
7
+ ## Focus Target Precedence
8
+
9
+ For keyboard menu selection and Escape, configured focus targets resolve in this order:
10
+
11
+ 1. `resolveFocusTarget()`
12
+ 2. `focusTargetRef`
13
+ 3. `focusTargetContainerSelector`, resolved with `trigger.closest(selector)`
14
+ 4. For Escape only: `escapeFocusTarget()`
15
+ 5. For Escape only: `escapeFocusContainerSelector`
16
+ 6. The trigger
17
+
18
+ Use the selector option for the common case where a button menu should return keyboard focus to its containing row or toolbar group:
19
+
20
+ ```ts
21
+ const menu = usePopupMenu({
22
+ isOpen,
23
+ triggerRef,
24
+ menuRef,
25
+ triggerMode: "button",
26
+ focusTargetContainerSelector: ".k-table-row",
27
+ requestShow: () => (isOpen.value = true),
28
+ requestHide: () => (isOpen.value = false),
29
+ });
30
+ ```
31
+
32
+ The target is captured before `requestHide()` runs, so it remains available when shared state immediately clears `triggerRef`.
33
+
34
+ ## Close Behavior
35
+
36
+ | Close path | Focus behavior |
37
+ | ----------------------- | ------------------------------------------------ |
38
+ | Keyboard menu selection | Configured focus target, then trigger |
39
+ | Escape | Configured target, Escape fallback, then trigger |
40
+ | Pointer menu selection | Trigger |
41
+ | Outside click | Browser retains control |
42
+ | Trigger click | Browser retains control |
43
+ | Anchor fully clipped | No focus restoration |
44
+
45
+ ## Multiple Exclusive Menus
46
+
47
+ Keep exclusivity outside `usePopupMenu`. `useExclusiveGroup` coordinates registries without coupling popup lifecycle to application state.
48
+
49
+ ```ts
50
+ import { ref } from "vue";
51
+ import { usePopupMenu } from "@featherk/composables/menu";
52
+ import {
53
+ useActiveIdRegistry,
54
+ useExclusiveGroup,
55
+ } from "@featherk/composables/registry";
56
+
57
+ const group = useExclusiveGroup();
58
+ const rowRegistry = useActiveIdRegistry<number>();
59
+ const buttonRegistry = useActiveIdRegistry<number>();
60
+ const rowMenuRef = ref<HTMLElement | null>(null);
61
+ const buttonMenuRef = ref<HTMLElement | null>(null);
62
+
63
+ group.register(rowRegistry);
64
+ group.register(buttonRegistry);
65
+
66
+ const rowMenu = usePopupMenu({
67
+ isOpen: rowRegistry.isActive,
68
+ triggerRef: rowRegistry.activeElement,
69
+ menuRef: rowMenuRef,
70
+ triggerMode: "row",
71
+ anchor: { activationPoint: true },
72
+ requestShow: () => {},
73
+ requestHide: () => rowRegistry.deactivate(),
74
+ });
75
+
76
+ const buttonMenu = usePopupMenu({
77
+ isOpen: buttonRegistry.isActive,
78
+ triggerRef: buttonRegistry.activeElement,
79
+ menuRef: buttonMenuRef,
80
+ triggerMode: "button",
81
+ anchor: true,
82
+ focusTargetContainerSelector: ".k-table-row",
83
+ requestShow: () => {},
84
+ requestHide: () => buttonRegistry.deactivate(),
85
+ });
86
+
87
+ const openRowMenu = (id: number, row: HTMLElement, event: MouseEvent) => {
88
+ group.deactivateOthers(rowRegistry);
89
+ if (
90
+ rowMenu.setAnchorActivation(row, {
91
+ triggerType: "click",
92
+ coordinates: { clientX: event.clientX, clientY: event.clientY },
93
+ })
94
+ ) {
95
+ rowRegistry.activate(id);
96
+ }
97
+ };
98
+
99
+ const openButtonMenu = (id: number) => {
100
+ group.deactivateOthers(buttonRegistry);
101
+ buttonRegistry.activate(id);
102
+ };
103
+ ```
104
+
105
+ `useExclusiveGroup` changes only active registry state. Each `usePopupMenu` instance continues to own its own ARIA, positioning, close reason, and focus behavior.