@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.
- 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 +1539 -1380
- 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/geometry/computeFlippedOffset.d.ts +40 -0
- package/dist/geometry/computeFlippedOffset.test.d.ts +1 -0
- package/dist/geometry/index.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/menu/usePopupMenu.d.ts +77 -11
- package/docs/menu/usePopupMenu.md +137 -25
- package/docs/menu/usePopupMenuActivationAnchor.md +132 -0
- package/docs/menu/usePopupMenuFocusAndExclusivity.md +105 -0
- package/docs/menu/usePopupMenuGridOrchestration.md +520 -0
- package/docs/menu/usePopupMenuSharedInstance.md +106 -0
- package/docs/menu/usePopupMenuUpgrade0130.md +115 -0
- package/docs/trap/usePopupTrap.md +1 -1
- package/package.json +6 -1
|
@@ -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).
|