@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,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.
@@ -0,0 +1,520 @@
1
+ # Grid Popup Menu Orchestration
2
+
3
+ [Back to usePopupMenu](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenu.md)
4
+
5
+ This guide is the starting point for a Kendo Grid that uses `useGridA11y` and
6
+ needs both a row context menu and an action-button menu.
7
+
8
+ ## Big Picture
9
+
10
+ A grid with 100 rows may appear to need 100 row popups and 100 button popups.
11
+ It does not. Those are logical menus: each interaction belongs to a particular
12
+ record, but only one record in each menu family can be active at a time.
13
+
14
+ Render one physical `Popup`/`Menu` pair for all row triggers and one physical
15
+ pair for all equivalent button triggers. A registry connects each shared pair
16
+ to the trigger that is active now.
17
+
18
+ For a grid with both trigger kinds, the final architecture has:
19
+
20
+ - two `useActiveIdRegistry` instances: one for rows and one for buttons;
21
+ - one `useExclusiveGroup` containing those registries;
22
+ - two `usePopupMenu` instances: one with `triggerMode: "row"` and one with
23
+ `triggerMode: "button"`;
24
+ - two rendered `Popup`/`Menu` pairs total, regardless of row count.
25
+
26
+ It is one shared popup controller and template **per menu behavior**, not one
27
+ controller per row. A row trigger and a button trigger should not share the same
28
+ `usePopupMenu` instance because they have different trigger semantics,
29
+ positioning, menu items, and focus behavior.
30
+
31
+ ```mermaid
32
+ flowchart LR
33
+ A[Grid row click or key] --> B[useGridRowAction]
34
+ B --> C[Row registry]
35
+ C --> D[Shared row usePopupMenu]
36
+ D --> E[One row Popup and Menu]
37
+
38
+ F[Action button click or key] --> G[Button registry]
39
+ G --> H[Shared button usePopupMenu]
40
+ H --> I[One button Popup and Menu]
41
+
42
+ C <--> J[useExclusiveGroup]
43
+ G <--> J
44
+ K[Grid keyboard event] --> L[useGridA11y]
45
+ K --> B
46
+ ```
47
+
48
+ ## Why This Orchestration Exists
49
+
50
+ A boolean answers whether a menu is open, but a shared popup also needs to know
51
+ which record owns it and which current DOM element should anchor it. This matters
52
+ when sorting, paging, or virtualization replaces grid rows.
53
+
54
+ Each composable owns one part of that problem:
55
+
56
+ | Composable | Responsibility |
57
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `useGridA11y` | Moves focus through the grid and maintains grid keyboard behavior. It does not open menus. |
59
+ | `useGridRowAction` | Converts valid row clicks and Enter/Space presses into one normalized row action. It ignores buttons and other interactive descendants. |
60
+ | `useActiveIdRegistry` | Tracks the active record id and resolves that id to its currently mounted row or button element. |
61
+ | `useExclusiveGroup` | Closes the other registry before a row menu or button menu opens. It does not decide which id to activate. |
62
+ | `usePopupMenu` | Owns menu toggling, ARIA synchronization, positioning, outside-click handling, selection modality, and focus restoration. |
63
+
64
+ The registry is the bridge. Its `isActive` becomes `usePopupMenu.isOpen`, and
65
+ its `activeElement` becomes `usePopupMenu.triggerRef`. Computed menu items and
66
+ labels use `activeId` to resolve the active record.
67
+
68
+ The exclusivity group coordinates independent registries without coupling
69
+ `usePopupMenu` to grid policy. The consumer calls `deactivateOthers(registry)`
70
+ before activating that registry. This guarantees that the row and button menus
71
+ cannot remain open together.
72
+
73
+ The shared popup uses `offset` positioning because Kendo's `anchor` prop resolves
74
+ a template-ref name when the Popup mounts; it cannot reliably retarget one
75
+ mounted Popup from row A to row B. `usePopupMenu` can remeasure the registry's
76
+ current element after scrolling, resizing, or DOM replacement.
77
+
78
+ ## Quick Start
79
+
80
+ 1. Create one registry for each menu behavior and register both with one
81
+ `useExclusiveGroup`.
82
+ 2. Register every rendered row and button under its record id.
83
+ 3. Create one `usePopupMenu` instance per registry.
84
+ 4. Route row activation through `useGridRowAction` and button activation through
85
+ the button popup controller, deactivating the other group member first.
86
+ 5. Render one Popup/Menu pair per controller and derive its content from the
87
+ active id.
88
+
89
+ ## Complete Example
90
+
91
+ The template contains many triggers but only two popup trees:
92
+
93
+ ```html
94
+ <template>
95
+ <div ref="gridContainerRef" class="grid-container">
96
+ <Grid
97
+ ref="gridRef"
98
+ :data-items="records"
99
+ :columns="columns"
100
+ :data-item-key="'id'"
101
+ :row-render="renderRow"
102
+ @rowclick="handleRowClick"
103
+ @keydown="handleGridKeydown"
104
+ >
105
+ <template #actionsCell="{ props }">
106
+ <td>
107
+ <Button
108
+ :ref="
109
+ (element) =>
110
+ buttonRegistry.register(props.dataItem.id, element)
111
+ "
112
+ :svg-icon="moreVerticalIcon"
113
+ type="button"
114
+ aria-label="Quick actions"
115
+ aria-haspopup="menu"
116
+ aria-expanded="false"
117
+ tabindex="-1"
118
+ @click="toggleButtonMenu(props.dataItem.id)"
119
+ @keydown="handleButtonKeydown(props.dataItem.id, $event)"
120
+ />
121
+ </td>
122
+ </template>
123
+ </Grid>
124
+ </div>
125
+
126
+ <!-- Step 5: one physical row menu for every row trigger. -->
127
+ <Popup
128
+ id="shared-row-menu-popup"
129
+ :show="rowRegistry.isActive.value"
130
+ :offset="rowMenu.offset.value"
131
+ @close="rowMenu.handlePopupClose"
132
+ >
133
+ <strong>{{ rowMenuLabel }}</strong>
134
+ <Menu
135
+ ref="rowMenuRef"
136
+ :items="rowMenuItems"
137
+ :vertical="true"
138
+ @keydown.escape="rowMenu.handleMenuEscape"
139
+ @select="handleRowMenuSelect"
140
+ />
141
+ </Popup>
142
+
143
+ <!-- Step 5: one physical button menu for every action button. -->
144
+ <Popup
145
+ id="shared-button-menu-popup"
146
+ :show="buttonRegistry.isActive.value"
147
+ :offset="buttonMenu.offset.value"
148
+ @close="buttonMenu.handlePopupClose"
149
+ >
150
+ <strong>{{ buttonMenuLabel }}</strong>
151
+ <Menu
152
+ ref="buttonMenuRef"
153
+ :items="buttonMenuItems"
154
+ :vertical="true"
155
+ @keydown.escape="buttonMenu.handleMenuEscape"
156
+ @select="handleButtonMenuSelect"
157
+ />
158
+ </Popup>
159
+ </template>
160
+
161
+ <script setup lang="ts">
162
+ import {
163
+ computed,
164
+ onMounted,
165
+ ref,
166
+ type ComponentPublicInstance,
167
+ } from "vue";
168
+ import { Grid } from "@progress/kendo-vue-grid";
169
+ import { Button } from "@progress/kendo-vue-buttons";
170
+ import { Menu } from "@progress/kendo-vue-layout";
171
+ import { Popup } from "@progress/kendo-vue-popup";
172
+ import { moreVerticalIcon } from "@progress/kendo-svg-icons";
173
+ import {
174
+ useGridA11y,
175
+ useGridRowAction,
176
+ type RowActionContext,
177
+ } from "@featherk/composables/grid";
178
+ import {
179
+ usePopupMenu,
180
+ type KendoMenuSelectEvent,
181
+ } from "@featherk/composables/menu";
182
+ import {
183
+ useActiveIdRegistry,
184
+ useExclusiveGroup,
185
+ } from "@featherk/composables/registry";
186
+
187
+ type RecordItem = {
188
+ id: number;
189
+ name: string;
190
+ status: string;
191
+ };
192
+
193
+ const records = ref<RecordItem[]>([
194
+ { id: 1, name: "Alpha", status: "Active" },
195
+ { id: 2, name: "Beta", status: "Pending" },
196
+ ]);
197
+
198
+ const columns = [
199
+ { field: "name", title: "Name" },
200
+ { field: "status", title: "Status" },
201
+ { cell: "actionsCell", title: "Actions", width: "90px" },
202
+ ];
203
+
204
+ const gridRef = ref<ComponentPublicInstance | null>(null);
205
+ const gridContainerRef = ref<HTMLElement | null>(null);
206
+ const rowMenuRef = ref<ComponentPublicInstance | null>(null);
207
+ const buttonMenuRef = ref<ComponentPublicInstance | null>(null);
208
+
209
+ // Step 1: each menu behavior has one registry. The group coordinates them.
210
+ const rowRegistry = useActiveIdRegistry<number>();
211
+ const buttonRegistry = useActiveIdRegistry<number>();
212
+ const menuGroup = useExclusiveGroup();
213
+ menuGroup.register(rowRegistry);
214
+ menuGroup.register(buttonRegistry);
215
+
216
+ const activeRowRecord = computed(() =>
217
+ records.value.find((record) => record.id === rowRegistry.activeId.value),
218
+ );
219
+ const activeButtonRecord = computed(() =>
220
+ records.value.find((record) => record.id === buttonRegistry.activeId.value),
221
+ );
222
+
223
+ const rowMenuLabel = computed(() =>
224
+ activeRowRecord.value ? `Row actions for ${activeRowRecord.value.name}` : "",
225
+ );
226
+ const buttonMenuLabel = computed(() =>
227
+ activeButtonRecord.value
228
+ ? `Quick actions for ${activeButtonRecord.value.name}`
229
+ : "",
230
+ );
231
+ const rowMenuItems = computed(() => [
232
+ { text: "Open record", id: "open" },
233
+ { text: "View history", id: "history" },
234
+ ]);
235
+ const buttonMenuItems = computed(() => [
236
+ { text: "Edit", id: "edit" },
237
+ { text: "Archive", id: "archive" },
238
+ ]);
239
+
240
+ const closeRowMenu = () => rowRegistry.deactivate();
241
+ const closeButtonMenu = () => buttonRegistry.deactivate();
242
+
243
+ // Step 3: this single instance follows whichever row id is active.
244
+ const rowMenu = usePopupMenu({
245
+ isOpen: rowRegistry.isActive,
246
+ triggerRef: rowRegistry.activeElement,
247
+ menuRef: rowMenuRef,
248
+ triggerMode: "row",
249
+ menuLabel: rowMenuLabel,
250
+ anchor: {
251
+ clipRoot: gridContainerRef,
252
+ activationPoint: true,
253
+ },
254
+ // Row activation captures its point before activating the registry.
255
+ requestShow: () => {},
256
+ requestHide: closeRowMenu,
257
+ });
258
+
259
+ // Step 3: this single instance follows whichever action button is active.
260
+ const buttonMenu = usePopupMenu({
261
+ isOpen: buttonRegistry.isActive,
262
+ triggerRef: buttonRegistry.activeElement,
263
+ menuRef: buttonMenuRef,
264
+ triggerMode: "button",
265
+ menuLabel: buttonMenuLabel,
266
+ anchor: { clipRoot: gridContainerRef },
267
+ requestShow: (id) => {
268
+ if (typeof id === "number") buttonRegistry.activate(id);
269
+ },
270
+ requestHide: closeButtonMenu,
271
+ });
272
+
273
+ // Step 2: Kendo rowRender registers every current row DOM element. Static ARIA
274
+ // remains on every row; usePopupMenu changes only aria-expanded.
275
+ const renderRow = (h: any, tr: any, slots: any, props: any) =>
276
+ h(
277
+ "tr",
278
+ {
279
+ ...tr.props,
280
+ "aria-label": `${props.dataItem.name}, ${props.dataItem.status}`,
281
+ "aria-haspopup": "menu",
282
+ "aria-expanded": "false",
283
+ ref: (element: HTMLElement | null) =>
284
+ rowRegistry.register(props.dataItem.id, element),
285
+ },
286
+ slots,
287
+ );
288
+
289
+ // Step 4: useGridRowAction ignores the nested Button automatically, so a
290
+ // button click cannot also open the row menu.
291
+ const { handleRowClick, handleRowKeyDown } = useGridRowAction<RecordItem>({
292
+ dataItems: records,
293
+ onRowAction: (record, context) => toggleRowMenu(record.id, context),
294
+ });
295
+
296
+ const toggleRowMenu = (
297
+ id: number,
298
+ context: RowActionContext<RecordItem>,
299
+ ) => {
300
+ if (rowRegistry.isIdActive(id)) {
301
+ closeRowMenu();
302
+ return;
303
+ }
304
+
305
+ const row = rowRegistry.resolve(id);
306
+ if (!row || !rowMenu.setAnchorActivation(row, context)) return;
307
+
308
+ // The group owns cross-menu exclusivity; this consumer owns the row id.
309
+ menuGroup.deactivateOthers(rowRegistry);
310
+ rowRegistry.activate(id);
311
+ };
312
+
313
+ const toggleButtonMenu = (id: number) => {
314
+ if (buttonRegistry.isIdActive(id)) {
315
+ buttonMenu.handleTriggerClick(id);
316
+ return;
317
+ }
318
+
319
+ // The group closes the row menu. Switching within this registry still needs
320
+ // a close first so handleTriggerClick takes its open path for the new id.
321
+ menuGroup.deactivateOthers(buttonRegistry);
322
+ if (buttonRegistry.isActive.value) closeButtonMenu();
323
+ buttonMenu.handleTriggerClick(id);
324
+ };
325
+
326
+ const handleButtonKeydown = (id: number, event: KeyboardEvent) => {
327
+ if (event.key === "Enter" || event.code === "Space") {
328
+ event.preventDefault();
329
+ toggleButtonMenu(id);
330
+ return;
331
+ }
332
+
333
+ buttonMenu.handleTriggerKeydown(event);
334
+ };
335
+
336
+ const { handleGridKeyDown, initA11y } = useGridA11y(gridRef);
337
+ const handleGridKeydown = (event: KeyboardEvent) => {
338
+ handleGridKeyDown(event);
339
+ handleRowKeyDown(event);
340
+ };
341
+
342
+ const handleRowMenuSelect = (event: KendoMenuSelectEvent) => {
343
+ rowMenu.handleMenuSelect(event, (selection) => {
344
+ if (selection.item && activeRowRecord.value) {
345
+ console.log(selection.item.id, activeRowRecord.value);
346
+ }
347
+ });
348
+ };
349
+
350
+ const handleButtonMenuSelect = (event: KendoMenuSelectEvent) => {
351
+ buttonMenu.handleMenuSelect(event, (selection) => {
352
+ if (selection.item && activeButtonRecord.value) {
353
+ console.log(selection.item.id, activeButtonRecord.value);
354
+ }
355
+ });
356
+ };
357
+
358
+ onMounted(initA11y);
359
+ </script>
360
+ ```
361
+
362
+ ## What Happens When a Row Opens
363
+
364
+ 1. `useGridRowAction` validates the click or keyboard event and supplies the
365
+ record plus activation coordinates.
366
+ 2. The consumer resolves the row element from `rowRegistry`.
367
+ 3. `setAnchorActivation` stores a trigger-relative pointer or keyboard point.
368
+ 4. `menuGroup.deactivateOthers(rowRegistry)` closes the button menu.
369
+ 5. `rowRegistry.activate(id)` changes both `isOpen` and `activeElement`.
370
+ 6. The one row Popup renders content for that id, and `usePopupMenu` sets the
371
+ active row's `aria-expanded` to `true`.
372
+
373
+ When another row opens, the registry changes identity. `usePopupMenu` demotes the
374
+ old row, follows the new row element, and reuses the same Popup/Menu tree.
375
+
376
+ ## What Happens When a Button Opens
377
+
378
+ 1. The button is already registered under its record id by its template ref.
379
+ 2. `menuGroup.deactivateOthers(buttonRegistry)` closes the row menu.
380
+ 3. `handleTriggerClick(id)` forwards the id to `requestShow`.
381
+ 4. `buttonRegistry.activate(id)` makes that button the active trigger.
382
+ 5. The one button Popup follows the active button and derives its content from
383
+ `buttonRegistry.activeId`.
384
+
385
+ `useGridRowAction` ignores button descendants by default. This prevents a button
386
+ click from also being interpreted as a row activation; no propagation workaround
387
+ is required.
388
+
389
+ ## Multiple Buttons Per Row
390
+
391
+ The button registry is not limited to one button in each row. It can register
392
+ any number of equivalent buttons that all use the same shared Popup/Menu. Give
393
+ each trigger a composite id containing both its button key and row id:
394
+
395
+ ```ts
396
+ type ButtonKey = "edit" | "more";
397
+ type ButtonTriggerId = `${ButtonKey}:${number}`;
398
+
399
+ const buttonRegistry = useActiveIdRegistry<ButtonTriggerId>();
400
+
401
+ const getButtonTriggerId = (
402
+ key: ButtonKey,
403
+ rowId: number,
404
+ ): ButtonTriggerId => `${key}:${rowId}`;
405
+ ```
406
+
407
+ Register and activate each button with that composite id:
408
+
409
+ ```vue
410
+ <Button
411
+ :ref="
412
+ (element) =>
413
+ buttonRegistry.register(
414
+ getButtonTriggerId('edit', props.dataItem.id),
415
+ element,
416
+ )
417
+ "
418
+ aria-label="Edit actions"
419
+ aria-haspopup="menu"
420
+ aria-expanded="false"
421
+ @click="toggleButtonMenu(getButtonTriggerId('edit', props.dataItem.id))"
422
+ />
423
+
424
+ <Button
425
+ :ref="
426
+ (element) =>
427
+ buttonRegistry.register(
428
+ getButtonTriggerId('more', props.dataItem.id),
429
+ element,
430
+ )
431
+ "
432
+ aria-label="More actions"
433
+ aria-haspopup="menu"
434
+ aria-expanded="false"
435
+ @click="toggleButtonMenu(getButtonTriggerId('more', props.dataItem.id))"
436
+ />
437
+ ```
438
+
439
+ The active composite id tells the consumer both which row and which button owns
440
+ the popup. Parse it to derive the shared menu's label and items:
441
+
442
+ ```ts
443
+ const activeButton = computed(() => {
444
+ const id = buttonRegistry.activeId.value;
445
+ if (!id) return null;
446
+
447
+ const [key, rowId] = id.split(":") as [ButtonKey, string];
448
+ const record = records.value.find((item) => item.id === Number(rowId));
449
+ return record ? { key, record } : null;
450
+ });
451
+
452
+ const buttonMenuItems = computed(() =>
453
+ activeButton.value?.key === "edit"
454
+ ? [{ text: "Rename", id: "rename" }]
455
+ : [{ text: "Archive", id: "archive" }],
456
+ );
457
+ ```
458
+
459
+ This still uses one button registry, one `usePopupMenu`, and one Popup/Menu tree
460
+ for the whole grid. Use separate registries and popup controllers only when the
461
+ buttons represent genuinely different menu behaviors, such as different focus,
462
+ positioning, lifecycle, or exclusivity rules. For a reusable collection of
463
+ ActionCell triggers, `@featherk/ui` also provides `useActionCellMenu`, which owns
464
+ this composite-id orchestration.
465
+
466
+ ## Rules to Keep
467
+
468
+ - Keep separate registries and popup controllers for row and button behavior.
469
+ - One button registry may contain many buttons per row when they share one menu
470
+ behavior; use composite ids so every trigger remains unique.
471
+ - Register both registries with `useExclusiveGroup` and call
472
+ `deactivateOthers` before activating either one.
473
+ - Render `aria-haspopup="menu"` and `aria-expanded="false"` on every trigger.
474
+ - Register elements through template refs so rerenders and virtualization update
475
+ the registry.
476
+ - Capture row activation before activating the row id.
477
+ - Bind `usePopupMenu.offset` instead of dynamically changing Kendo's `anchor`.
478
+ - Derive labels, items, and business context from the active registry id.
479
+ - Do not add row roles or focus behavior in popup code; `useGridA11y` and the
480
+ grid's interaction model own those semantics.
481
+
482
+ ## Trapping Focus in a Shared Popup
483
+
484
+ Neither `rowMenu` nor `buttonMenu` above traps `Tab` inside its Popup - that's
485
+ intentional (see `usePopupMenu.md`'s intro). If a specific shared popup needs
486
+ it anyway, pair it with `usePopupTrap` using the same registry, and disable
487
+ both its own Escape-focus logic and the underlying `focus-trap` library's
488
+ independent `returnFocusOnDeactivate` default, so `usePopupMenu` remains the
489
+ sole owner of close/focus-restoration decisions. Both shared Popups above stay
490
+ permanently mounted (only `:show` toggles visibility) with pure `:offset`
491
+ positioning (no real Kendo `anchor` ref), so `usePopupTrap`'s default
492
+ class-based lookup can't reliably tell them apart - pin each trap to its own
493
+ Popup by the `id` it already has:
494
+
495
+ ```ts
496
+ usePopupTrap({
497
+ isOpen: rowRegistry.isActive,
498
+ triggerEl: rowRegistry.activeElement,
499
+ resolvePopupEl: () => document.getElementById("shared-row-menu-popup"),
500
+ initialFocus: () => document.activeElement as HTMLElement | null,
501
+ returnFocusToTrigger: false,
502
+ focusTrapOptions: { returnFocusOnDeactivate: false },
503
+ });
504
+ usePopupTrap({
505
+ isOpen: buttonRegistry.isActive,
506
+ triggerEl: buttonRegistry.activeElement,
507
+ resolvePopupEl: () => document.getElementById("shared-button-menu-popup"),
508
+ initialFocus: () => document.activeElement as HTMLElement | null,
509
+ returnFocusToTrigger: false,
510
+ focusTrapOptions: { returnFocusOnDeactivate: false },
511
+ });
512
+ ```
513
+
514
+ `usePopupTrap` already defaults `allowOutsideClick` to `true` - without it, a
515
+ genuinely active trap on one menu would swallow clicks on the *other* trigger
516
+ in the same row (e.g. the row menu trapping Tab while the user tries to click
517
+ the action button). See `usePopupMenu.md`'s "Pairing with `usePopupTrap`"
518
+ section for why each of these options is required.
519
+
520
+ For deeper details, see [Shared Instances](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuSharedInstance.md), [Activation-Point Anchors](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuActivationAnchor.md), [Focus and Exclusivity](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuFocusAndExclusivity.md), and [useExclusiveGroup](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/registry/useExclusiveGroup.md).