@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.
@@ -0,0 +1,480 @@
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
+ :show="rowRegistry.isActive.value"
129
+ :offset="rowMenu.offset.value"
130
+ @close="rowMenu.handlePopupClose"
131
+ >
132
+ <strong>{{ rowMenuLabel }}</strong>
133
+ <Menu
134
+ ref="rowMenuRef"
135
+ :items="rowMenuItems"
136
+ :vertical="true"
137
+ @keydown.escape="rowMenu.handleMenuEscape"
138
+ @select="handleRowMenuSelect"
139
+ />
140
+ </Popup>
141
+
142
+ <!-- Step 5: one physical button menu for every action button. -->
143
+ <Popup
144
+ :show="buttonRegistry.isActive.value"
145
+ :offset="buttonMenu.offset.value"
146
+ @close="buttonMenu.handlePopupClose"
147
+ >
148
+ <strong>{{ buttonMenuLabel }}</strong>
149
+ <Menu
150
+ ref="buttonMenuRef"
151
+ :items="buttonMenuItems"
152
+ :vertical="true"
153
+ @keydown.escape="buttonMenu.handleMenuEscape"
154
+ @select="handleButtonMenuSelect"
155
+ />
156
+ </Popup>
157
+ </template>
158
+
159
+ <script setup lang="ts">
160
+ import {
161
+ computed,
162
+ onMounted,
163
+ ref,
164
+ type ComponentPublicInstance,
165
+ } from "vue";
166
+ import { Grid } from "@progress/kendo-vue-grid";
167
+ import { Button } from "@progress/kendo-vue-buttons";
168
+ import { Menu } from "@progress/kendo-vue-layout";
169
+ import { Popup } from "@progress/kendo-vue-popup";
170
+ import { moreVerticalIcon } from "@progress/kendo-svg-icons";
171
+ import {
172
+ useGridA11y,
173
+ useGridRowAction,
174
+ type RowActionContext,
175
+ } from "@featherk/composables/grid";
176
+ import {
177
+ usePopupMenu,
178
+ type KendoMenuSelectEvent,
179
+ } from "@featherk/composables/menu";
180
+ import {
181
+ useActiveIdRegistry,
182
+ useExclusiveGroup,
183
+ } from "@featherk/composables/registry";
184
+
185
+ type RecordItem = {
186
+ id: number;
187
+ name: string;
188
+ status: string;
189
+ };
190
+
191
+ const records = ref<RecordItem[]>([
192
+ { id: 1, name: "Alpha", status: "Active" },
193
+ { id: 2, name: "Beta", status: "Pending" },
194
+ ]);
195
+
196
+ const columns = [
197
+ { field: "name", title: "Name" },
198
+ { field: "status", title: "Status" },
199
+ { cell: "actionsCell", title: "Actions", width: "90px" },
200
+ ];
201
+
202
+ const gridRef = ref<ComponentPublicInstance | null>(null);
203
+ const gridContainerRef = ref<HTMLElement | null>(null);
204
+ const rowMenuRef = ref<ComponentPublicInstance | null>(null);
205
+ const buttonMenuRef = ref<ComponentPublicInstance | null>(null);
206
+
207
+ // Step 1: each menu behavior has one registry. The group coordinates them.
208
+ const rowRegistry = useActiveIdRegistry<number>();
209
+ const buttonRegistry = useActiveIdRegistry<number>();
210
+ const menuGroup = useExclusiveGroup();
211
+ menuGroup.register(rowRegistry);
212
+ menuGroup.register(buttonRegistry);
213
+
214
+ const activeRowRecord = computed(() =>
215
+ records.value.find((record) => record.id === rowRegistry.activeId.value),
216
+ );
217
+ const activeButtonRecord = computed(() =>
218
+ records.value.find((record) => record.id === buttonRegistry.activeId.value),
219
+ );
220
+
221
+ const rowMenuLabel = computed(() =>
222
+ activeRowRecord.value ? `Row actions for ${activeRowRecord.value.name}` : "",
223
+ );
224
+ const buttonMenuLabel = computed(() =>
225
+ activeButtonRecord.value
226
+ ? `Quick actions for ${activeButtonRecord.value.name}`
227
+ : "",
228
+ );
229
+ const rowMenuItems = computed(() => [
230
+ { text: "Open record", id: "open" },
231
+ { text: "View history", id: "history" },
232
+ ]);
233
+ const buttonMenuItems = computed(() => [
234
+ { text: "Edit", id: "edit" },
235
+ { text: "Archive", id: "archive" },
236
+ ]);
237
+
238
+ const closeRowMenu = () => rowRegistry.deactivate();
239
+ const closeButtonMenu = () => buttonRegistry.deactivate();
240
+
241
+ // Step 3: this single instance follows whichever row id is active.
242
+ const rowMenu = usePopupMenu({
243
+ isOpen: rowRegistry.isActive,
244
+ triggerRef: rowRegistry.activeElement,
245
+ menuRef: rowMenuRef,
246
+ triggerMode: "row",
247
+ menuLabel: rowMenuLabel,
248
+ anchor: {
249
+ clipRoot: gridContainerRef,
250
+ activationPoint: true,
251
+ },
252
+ // Row activation captures its point before activating the registry.
253
+ requestShow: () => {},
254
+ requestHide: closeRowMenu,
255
+ });
256
+
257
+ // Step 3: this single instance follows whichever action button is active.
258
+ const buttonMenu = usePopupMenu({
259
+ isOpen: buttonRegistry.isActive,
260
+ triggerRef: buttonRegistry.activeElement,
261
+ menuRef: buttonMenuRef,
262
+ triggerMode: "button",
263
+ menuLabel: buttonMenuLabel,
264
+ anchor: { clipRoot: gridContainerRef },
265
+ requestShow: (id) => {
266
+ if (typeof id === "number") buttonRegistry.activate(id);
267
+ },
268
+ requestHide: closeButtonMenu,
269
+ });
270
+
271
+ // Step 2: Kendo rowRender registers every current row DOM element. Static ARIA
272
+ // remains on every row; usePopupMenu changes only aria-expanded.
273
+ const renderRow = (h: any, tr: any, slots: any, props: any) =>
274
+ h(
275
+ "tr",
276
+ {
277
+ ...tr.props,
278
+ "aria-label": `${props.dataItem.name}, ${props.dataItem.status}`,
279
+ "aria-haspopup": "menu",
280
+ "aria-expanded": "false",
281
+ ref: (element: HTMLElement | null) =>
282
+ rowRegistry.register(props.dataItem.id, element),
283
+ },
284
+ slots,
285
+ );
286
+
287
+ // Step 4: useGridRowAction ignores the nested Button automatically, so a
288
+ // button click cannot also open the row menu.
289
+ const { handleRowClick, handleRowKeyDown } = useGridRowAction<RecordItem>({
290
+ dataItems: records,
291
+ onRowAction: (record, context) => toggleRowMenu(record.id, context),
292
+ });
293
+
294
+ const toggleRowMenu = (
295
+ id: number,
296
+ context: RowActionContext<RecordItem>,
297
+ ) => {
298
+ if (rowRegistry.isIdActive(id)) {
299
+ closeRowMenu();
300
+ return;
301
+ }
302
+
303
+ const row = rowRegistry.resolve(id);
304
+ if (!row || !rowMenu.setAnchorActivation(row, context)) return;
305
+
306
+ // The group owns cross-menu exclusivity; this consumer owns the row id.
307
+ menuGroup.deactivateOthers(rowRegistry);
308
+ rowRegistry.activate(id);
309
+ };
310
+
311
+ const toggleButtonMenu = (id: number) => {
312
+ if (buttonRegistry.isIdActive(id)) {
313
+ buttonMenu.handleTriggerClick(id);
314
+ return;
315
+ }
316
+
317
+ // The group closes the row menu. Switching within this registry still needs
318
+ // a close first so handleTriggerClick takes its open path for the new id.
319
+ menuGroup.deactivateOthers(buttonRegistry);
320
+ if (buttonRegistry.isActive.value) closeButtonMenu();
321
+ buttonMenu.handleTriggerClick(id);
322
+ };
323
+
324
+ const handleButtonKeydown = (id: number, event: KeyboardEvent) => {
325
+ if (event.key === "Enter" || event.code === "Space") {
326
+ event.preventDefault();
327
+ toggleButtonMenu(id);
328
+ return;
329
+ }
330
+
331
+ buttonMenu.handleTriggerKeydown(event);
332
+ };
333
+
334
+ const { handleGridKeyDown, initA11y } = useGridA11y(gridRef);
335
+ const handleGridKeydown = (event: KeyboardEvent) => {
336
+ handleGridKeyDown(event);
337
+ handleRowKeyDown(event);
338
+ };
339
+
340
+ const handleRowMenuSelect = (event: KendoMenuSelectEvent) => {
341
+ rowMenu.handleMenuSelect(event, (selection) => {
342
+ if (selection.item && activeRowRecord.value) {
343
+ console.log(selection.item.id, activeRowRecord.value);
344
+ }
345
+ });
346
+ };
347
+
348
+ const handleButtonMenuSelect = (event: KendoMenuSelectEvent) => {
349
+ buttonMenu.handleMenuSelect(event, (selection) => {
350
+ if (selection.item && activeButtonRecord.value) {
351
+ console.log(selection.item.id, activeButtonRecord.value);
352
+ }
353
+ });
354
+ };
355
+
356
+ onMounted(initA11y);
357
+ </script>
358
+ ```
359
+
360
+ ## What Happens When a Row Opens
361
+
362
+ 1. `useGridRowAction` validates the click or keyboard event and supplies the
363
+ record plus activation coordinates.
364
+ 2. The consumer resolves the row element from `rowRegistry`.
365
+ 3. `setAnchorActivation` stores a trigger-relative pointer or keyboard point.
366
+ 4. `menuGroup.deactivateOthers(rowRegistry)` closes the button menu.
367
+ 5. `rowRegistry.activate(id)` changes both `isOpen` and `activeElement`.
368
+ 6. The one row Popup renders content for that id, and `usePopupMenu` sets the
369
+ active row's `aria-expanded` to `true`.
370
+
371
+ When another row opens, the registry changes identity. `usePopupMenu` demotes the
372
+ old row, follows the new row element, and reuses the same Popup/Menu tree.
373
+
374
+ ## What Happens When a Button Opens
375
+
376
+ 1. The button is already registered under its record id by its template ref.
377
+ 2. `menuGroup.deactivateOthers(buttonRegistry)` closes the row menu.
378
+ 3. `handleTriggerClick(id)` forwards the id to `requestShow`.
379
+ 4. `buttonRegistry.activate(id)` makes that button the active trigger.
380
+ 5. The one button Popup follows the active button and derives its content from
381
+ `buttonRegistry.activeId`.
382
+
383
+ `useGridRowAction` ignores button descendants by default. This prevents a button
384
+ click from also being interpreted as a row activation; no propagation workaround
385
+ is required.
386
+
387
+ ## Multiple Buttons Per Row
388
+
389
+ The button registry is not limited to one button in each row. It can register
390
+ any number of equivalent buttons that all use the same shared Popup/Menu. Give
391
+ each trigger a composite id containing both its button key and row id:
392
+
393
+ ```ts
394
+ type ButtonKey = "edit" | "more";
395
+ type ButtonTriggerId = `${ButtonKey}:${number}`;
396
+
397
+ const buttonRegistry = useActiveIdRegistry<ButtonTriggerId>();
398
+
399
+ const getButtonTriggerId = (
400
+ key: ButtonKey,
401
+ rowId: number,
402
+ ): ButtonTriggerId => `${key}:${rowId}`;
403
+ ```
404
+
405
+ Register and activate each button with that composite id:
406
+
407
+ ```vue
408
+ <Button
409
+ :ref="
410
+ (element) =>
411
+ buttonRegistry.register(
412
+ getButtonTriggerId('edit', props.dataItem.id),
413
+ element,
414
+ )
415
+ "
416
+ aria-label="Edit actions"
417
+ aria-haspopup="menu"
418
+ aria-expanded="false"
419
+ @click="toggleButtonMenu(getButtonTriggerId('edit', props.dataItem.id))"
420
+ />
421
+
422
+ <Button
423
+ :ref="
424
+ (element) =>
425
+ buttonRegistry.register(
426
+ getButtonTriggerId('more', props.dataItem.id),
427
+ element,
428
+ )
429
+ "
430
+ aria-label="More actions"
431
+ aria-haspopup="menu"
432
+ aria-expanded="false"
433
+ @click="toggleButtonMenu(getButtonTriggerId('more', props.dataItem.id))"
434
+ />
435
+ ```
436
+
437
+ The active composite id tells the consumer both which row and which button owns
438
+ the popup. Parse it to derive the shared menu's label and items:
439
+
440
+ ```ts
441
+ const activeButton = computed(() => {
442
+ const id = buttonRegistry.activeId.value;
443
+ if (!id) return null;
444
+
445
+ const [key, rowId] = id.split(":") as [ButtonKey, string];
446
+ const record = records.value.find((item) => item.id === Number(rowId));
447
+ return record ? { key, record } : null;
448
+ });
449
+
450
+ const buttonMenuItems = computed(() =>
451
+ activeButton.value?.key === "edit"
452
+ ? [{ text: "Rename", id: "rename" }]
453
+ : [{ text: "Archive", id: "archive" }],
454
+ );
455
+ ```
456
+
457
+ This still uses one button registry, one `usePopupMenu`, and one Popup/Menu tree
458
+ for the whole grid. Use separate registries and popup controllers only when the
459
+ buttons represent genuinely different menu behaviors, such as different focus,
460
+ positioning, lifecycle, or exclusivity rules. For a reusable collection of
461
+ ActionCell triggers, `@featherk/ui` also provides `useActionCellMenu`, which owns
462
+ this composite-id orchestration.
463
+
464
+ ## Rules to Keep
465
+
466
+ - Keep separate registries and popup controllers for row and button behavior.
467
+ - One button registry may contain many buttons per row when they share one menu
468
+ behavior; use composite ids so every trigger remains unique.
469
+ - Register both registries with `useExclusiveGroup` and call
470
+ `deactivateOthers` before activating either one.
471
+ - Render `aria-haspopup="menu"` and `aria-expanded="false"` on every trigger.
472
+ - Register elements through template refs so rerenders and virtualization update
473
+ the registry.
474
+ - Capture row activation before activating the row id.
475
+ - Bind `usePopupMenu.offset` instead of dynamically changing Kendo's `anchor`.
476
+ - Derive labels, items, and business context from the active registry id.
477
+ - Do not add row roles or focus behavior in popup code; `useGridA11y` and the
478
+ grid's interaction model own those semantics.
479
+
480
+ 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).
@@ -0,0 +1,106 @@
1
+ # usePopupMenu Shared Instances
2
+
3
+ [Back to usePopupMenu](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenu.md)
4
+
5
+ Use one `usePopupMenu` instance for many equivalent triggers when only one item may own the menu at a time.
6
+
7
+ ## Quick Start
8
+
9
+ 1. Register every trigger with `useActiveIdRegistry`.
10
+ 2. Pass `registry.isActive` and `registry.activeElement` to `usePopupMenu`.
11
+ 3. Activate or deactivate ids in a consumer-owned toggle function.
12
+ 4. Render one Popup and bind its `show`, `offset`, and close handler.
13
+
14
+ ```vue
15
+ <template>
16
+ <ul>
17
+ <li v-for="record in records" :key="record.id">
18
+ <button
19
+ :ref="(element) => registerTrigger(record.id, element)"
20
+ type="button"
21
+ aria-haspopup="menu"
22
+ aria-expanded="false"
23
+ @click="toggleMenu(record.id)"
24
+ >
25
+ Actions for {{ record.name }}
26
+ </button>
27
+ </li>
28
+ </ul>
29
+
30
+ <Popup
31
+ :show="registry.isActive.value"
32
+ :offset="menu.offset.value"
33
+ @close="menu.handlePopupClose"
34
+ >
35
+ <Menu
36
+ ref="menuRef"
37
+ :items="items"
38
+ :vertical="true"
39
+ @keydown.escape="menu.handleMenuEscape"
40
+ @select="menu.handleMenuSelect"
41
+ />
42
+ </Popup>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { ref, type ComponentPublicInstance } from "vue";
47
+ import { Popup } from "@progress/kendo-vue-popup";
48
+ import { Menu } from "@progress/kendo-vue-layout";
49
+ import { usePopupMenu } from "@featherk/composables/menu";
50
+ import { useActiveIdRegistry } from "@featherk/composables/registry";
51
+
52
+ const records = [
53
+ { id: 1, name: "Alpha" },
54
+ { id: 2, name: "Beta" },
55
+ ];
56
+ const items = [{ text: "Open" }, { text: "Archive" }];
57
+ const registry = useActiveIdRegistry<number>();
58
+ const menuRef = ref<ComponentPublicInstance | null>(null);
59
+
60
+ // Step 2: one reactive pair follows whichever trigger id is active.
61
+ const menu = usePopupMenu({
62
+ isOpen: registry.isActive,
63
+ triggerRef: registry.activeElement,
64
+ menuRef,
65
+ triggerMode: "button",
66
+ anchor: true,
67
+ requestShow: () => {},
68
+ requestHide: () => registry.deactivate(),
69
+ });
70
+
71
+ // Step 1: registration remains stable across keyed rerenders.
72
+ const registerTrigger = (id: number, element: unknown) => {
73
+ registry.register(
74
+ id,
75
+ element as HTMLElement | ComponentPublicInstance | null,
76
+ );
77
+ };
78
+
79
+ // Step 3: shared state must distinguish closing the active id from switching ids.
80
+ const toggleMenu = (id: number) => {
81
+ if (registry.isIdActive(id)) {
82
+ menu.handleTriggerClick();
83
+ return;
84
+ }
85
+
86
+ if (registry.isActive.value) {
87
+ menu.handleTriggerClick();
88
+ }
89
+ registry.activate(id);
90
+ };
91
+ </script>
92
+ ```
93
+
94
+ ## Why Offset Positioning
95
+
96
+ Bind the returned `offset` instead of Kendo Popup's `anchor` prop for a shared instance. Kendo resolves an anchor ref name when the Popup mounts; it does not dynamically retarget a different trigger element. `usePopupMenu` remeasures the active registered element on scroll, resize, and trigger replacement.
97
+
98
+ ## Trigger Switching
99
+
100
+ When an active id changes, `usePopupMenu` demotes the previous element to `aria-expanded="false"` and promotes the new trigger. Each trigger must provide the static baseline:
101
+
102
+ ```html
103
+ aria-haspopup="menu" aria-expanded="false"
104
+ ```
105
+
106
+ The composable does not add `aria-haspopup`, roles, or keyboard focusability.
@@ -0,0 +1,115 @@
1
+ # Upgrading usePopupMenu to 0.13.0
2
+
3
+ [Back to usePopupMenu](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenu.md)
4
+
5
+ This guide covers migration from `@featherk/composables` 0.12.x to 0.13.0.
6
+
7
+ ## Required Changes
8
+
9
+ Version 0.13.0 renames three returned handlers so their names apply equally to button and row triggers.
10
+
11
+ | 0.12.x | 0.13.0 |
12
+ | --------------------------- | ---------------------- |
13
+ | `handleActionButtonClick` | `handleTriggerClick` |
14
+ | `handleActionButtonKeydown` | `handleTriggerKeydown` |
15
+ | `handleActionMenuEscape` | `handleMenuEscape` |
16
+
17
+ Before:
18
+
19
+ ```vue
20
+ <button
21
+ @click="menu.handleActionButtonClick"
22
+ @keydown="menu.handleActionButtonKeydown"
23
+ />
24
+ <Menu @keydown.escape="menu.handleActionMenuEscape" />
25
+ ```
26
+
27
+ After:
28
+
29
+ ```vue
30
+ <button @click="menu.handleTriggerClick" @keydown="menu.handleTriggerKeydown" />
31
+ <Menu @keydown.escape="menu.handleMenuEscape" />
32
+ ```
33
+
34
+ Search all consumer source for:
35
+
36
+ ```text
37
+ handleActionButtonClick
38
+ handleActionButtonKeydown
39
+ handleActionMenuEscape
40
+ ```
41
+
42
+ The old properties are removed rather than retained as aliases.
43
+
44
+ ## Optional: Replace Manual Activation Geometry
45
+
46
+ Custom `getRect(trigger)` remains supported. Consumers that manually store pointer coordinates relative to a trigger can remove that state and use activation-point anchoring.
47
+
48
+ Before:
49
+
50
+ ```ts
51
+ const point = ref<{ x: number; y: number } | null>(null);
52
+
53
+ const menu = usePopupMenu({
54
+ // Other required options omitted.
55
+ anchor: {
56
+ intersectionThreshold: 0,
57
+ getRect: (trigger) => {
58
+ const rect = trigger.getBoundingClientRect();
59
+ return point.value
60
+ ? new DOMRect(rect.left + point.value.x, rect.top + point.value.y, 0, 0)
61
+ : null;
62
+ },
63
+ },
64
+ });
65
+ ```
66
+
67
+ After:
68
+
69
+ ```ts
70
+ const menu = usePopupMenu({
71
+ // Other required options omitted.
72
+ anchor: {
73
+ activationPoint: { pointerOffset: { x: -12, y: -12 } },
74
+ },
75
+ });
76
+
77
+ const trigger = registry.resolve(id);
78
+ if (trigger && menu.setAnchorActivation(trigger, context)) {
79
+ registry.activate(id);
80
+ }
81
+ ```
82
+
83
+ Capture before activating a shared registry id. Pointer coordinates become trigger-relative automatically; keyboard activation defaults to 8px from the left and vertically centered. Internal activation state clears when the menu closes.
84
+
85
+ ## Optional: Simplify Focus Restoration
86
+
87
+ Before:
88
+
89
+ ```ts
90
+ resolveFocusTarget: () =>
91
+ registry.activeElement.value?.closest(".k-table-row") ?? null,
92
+ ```
93
+
94
+ After:
95
+
96
+ ```ts
97
+ focusTargetContainerSelector: ".k-table-row",
98
+ ```
99
+
100
+ Keep `resolveFocusTarget` when the target cannot be expressed as the trigger's closest matching ancestor. Its precedence remains higher than `focusTargetRef` and `focusTargetContainerSelector`.
101
+
102
+ ## Unchanged Contracts
103
+
104
+ - The consumer owns `isOpen`, `requestShow`, and `requestHide`.
105
+ - The consumer provides static `aria-haspopup="menu"` and baseline `aria-expanded="false"`.
106
+ - `usePopupMenu` updates only `aria-expanded` unless ARIA management is disabled.
107
+ - `anchor: true` continues to follow the trigger's bottom-left corner.
108
+ - Custom `getRect(trigger)` remains supported and is source-compatible with zero-argument callbacks.
109
+ - Intersection dismissal and the `"anchor-hidden"` close reason are unchanged.
110
+ - `useActiveIdRegistry`, `useExclusiveGroup`, and `useGridRowAction` remain independent composables.
111
+ - Package root and `@featherk/composables/menu` imports remain supported.
112
+
113
+ ## Custom Geometry Fallback
114
+
115
+ Activation-point anchoring is optional. Retain `getRect(trigger)` when the popup must follow geometry that is not derived from a pointer or keyboard activation, such as a selection rectangle or a domain-specific virtual anchor.