@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.
- package/README.md +5 -0
- package/dist/docs-manifest.d.ts +5 -0
- package/dist/docs-manifest.js +27 -17
- package/dist/featherk-composables.es.js +1242 -1197
- package/dist/featherk-composables.es.js.map +1 -1
- package/dist/featherk-composables.umd.js +1 -1
- package/dist/featherk-composables.umd.js.map +1 -1
- package/dist/menu/usePopupMenu.d.ts +46 -11
- package/docs/menu/usePopupMenu.md +52 -25
- package/docs/menu/usePopupMenuActivationAnchor.md +132 -0
- package/docs/menu/usePopupMenuFocusAndExclusivity.md +105 -0
- package/docs/menu/usePopupMenuGridOrchestration.md +480 -0
- package/docs/menu/usePopupMenuSharedInstance.md +106 -0
- package/docs/menu/usePopupMenuUpgrade0130.md +115 -0
- package/package.json +1 -1
|
@@ -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.
|