@featherk/composables 0.13.1 → 0.13.3
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 +8 -0
- package/dist/docs-manifest.d.ts +1 -0
- package/dist/docs-manifest.js +24 -22
- package/dist/featherk-composables.es.js +172 -165
- 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/index.d.ts +1 -1
- package/dist/trap/usePopupTrap.d.ts +14 -0
- package/docs/menu/usePopupMenu.md +26 -7
- package/docs/menu/usePopupMenuAndActiveIdRegistry.md +185 -0
- package/docs/menu/usePopupMenuGridOrchestration.md +13 -8
- package/docs/trap/usePopupTrap.md +24 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -12,4 +12,4 @@ export { useMaskedDateRangeInput } from "./range";
|
|
|
12
12
|
export type { RangeChangePayload } from "./range";
|
|
13
13
|
export { useMaskedTimeInput } from "./time";
|
|
14
14
|
export type { ChangePayload as TimeChangePayload } from "./time";
|
|
15
|
-
export { usePopupTrap } from "./trap";
|
|
15
|
+
export { POPUP_MENU_TRAP_OPTIONS, usePopupTrap } from "./trap";
|
|
@@ -2,6 +2,20 @@ import { type Ref } from "vue";
|
|
|
2
2
|
import { useFocusTrap } from "@vueuse/integrations/useFocusTrap";
|
|
3
3
|
export type CloseReason = "escape" | "outside";
|
|
4
4
|
export type InitialFocus = string | ((root: HTMLElement) => HTMLElement | null | undefined);
|
|
5
|
+
/**
|
|
6
|
+
* Focus-restoration options for pairing `usePopupTrap` with `usePopupMenu`.
|
|
7
|
+
*
|
|
8
|
+
* `usePopupMenu` owns menu-close focus restoration. These settings disable
|
|
9
|
+
* both competing restoration paths in `usePopupTrap` and the underlying
|
|
10
|
+
* `focus-trap` implementation. Spread this preset last so those integration
|
|
11
|
+
* settings take precedence.
|
|
12
|
+
*/
|
|
13
|
+
export declare const POPUP_MENU_TRAP_OPTIONS: {
|
|
14
|
+
readonly returnFocusToTrigger: false;
|
|
15
|
+
readonly focusTrapOptions: {
|
|
16
|
+
readonly returnFocusOnDeactivate: false;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
5
19
|
export declare function usePopupTrap(opts: {
|
|
6
20
|
isOpen: Ref<boolean>;
|
|
7
21
|
onRequestClose?: (reason: CloseReason, ev?: Event) => void;
|
|
@@ -11,7 +11,7 @@ Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It man
|
|
|
11
11
|
1. Add static `aria-haspopup="menu"` and baseline `aria-expanded="false"` to the trigger element in your template. The composable never sets `aria-haspopup` and never removes `aria-expanded`; the consumer owns both as the closed-state baseline.
|
|
12
12
|
2. Create parent-owned refs for `isOpen`, `triggerRef`, `menuRef`, and an optional keyboard focus target.
|
|
13
13
|
3. Call `usePopupMenu(...)` with `requestShow`, `requestHide`, and an explicit `triggerMode: "button"` (or `"row"`).
|
|
14
|
-
4. Route Kendo Menu `@select` through `handleMenuSelect(event, onAction)` so
|
|
14
|
+
4. Route Kendo Menu `@select` through `handleMenuSelect(event, onAction)` so the composable closes the popup before running your business action synchronously.
|
|
15
15
|
5. Bind trigger button `@click` and `@keydown` to the composable handlers.
|
|
16
16
|
6. Bind Popup `@close` and Menu `@keydown.escape` so keyboard and mouse close paths restore focus correctly.
|
|
17
17
|
|
|
@@ -70,7 +70,7 @@ const menu = usePopupMenu({
|
|
|
70
70
|
requestHide: () => (isOpen.value = false),
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
// Step 4:
|
|
73
|
+
// Step 4: usePopupMenu closes before running the business action synchronously
|
|
74
74
|
const onSelect = (event: KendoMenuSelectEvent) => {
|
|
75
75
|
menu.handleMenuSelect(event, (selected) => {
|
|
76
76
|
// Keep application-specific actions in the consuming component.
|
|
@@ -89,7 +89,7 @@ For a virtualized Kendo Grid, use `focusTargetContainerSelector` to restore keyb
|
|
|
89
89
|
3. Create trigger and menu refs as component refs (`ComponentPublicInstance`) for Kendo wrappers.
|
|
90
90
|
4. Call `usePopupMenu(...)` with an explicit `triggerMode: "button"` and map `requestShow`/`requestHide` to row-specific emits.
|
|
91
91
|
5. Provide `focusTargetContainerSelector` so keyboard close restores focus to the current virtualized row.
|
|
92
|
-
6. Route menu selection through `handleMenuSelect(event, onAction)` so
|
|
92
|
+
6. Route menu selection through `handleMenuSelect(event, onAction)` so the popup closes before row action logic runs synchronously.
|
|
93
93
|
|
|
94
94
|
```ts
|
|
95
95
|
<script setup lang="ts">
|
|
@@ -124,7 +124,7 @@ const menu = usePopupMenu({
|
|
|
124
124
|
focusTargetContainerSelector: ".k-table-row[data-grid-row-index]",
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
// Step 6:
|
|
127
|
+
// Step 6: usePopupMenu closes before running the row-specific business action
|
|
128
128
|
const onSelect = (event: KendoMenuSelectEvent) => {
|
|
129
129
|
menu.handleMenuSelect(event, (selected) => {
|
|
130
130
|
// Row-specific action logic remains here.
|
|
@@ -277,14 +277,33 @@ See [Focus and Exclusivity](https://github.com/NantHealth/featherk/blob/integrat
|
|
|
277
277
|
`usePopupTrap` already defaults `allowOutsideClick` to `true` internally - no consumer action needed for that one, but see [usePopupTrap](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/trap/usePopupTrap.md) for why it matters: without it, a genuinely active trap swallows every outside click (e.g. a second trigger in the same grid row), not just ones that would close it.
|
|
278
278
|
|
|
279
279
|
```ts
|
|
280
|
+
import {
|
|
281
|
+
POPUP_MENU_TRAP_OPTIONS,
|
|
282
|
+
usePopupTrap,
|
|
283
|
+
} from "@featherk/composables/trap";
|
|
284
|
+
|
|
280
285
|
usePopupTrap({
|
|
281
286
|
isOpen: registry.isActive,
|
|
282
287
|
triggerEl: registry.activeElement,
|
|
283
288
|
// Step 6: only required when this popup coexists with another in the template.
|
|
284
289
|
resolvePopupEl: () => document.getElementById("my-shared-popup"),
|
|
285
290
|
initialFocus: () => document.activeElement as HTMLElement | null,
|
|
286
|
-
|
|
287
|
-
|
|
291
|
+
...POPUP_MENU_TRAP_OPTIONS,
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`POPUP_MENU_TRAP_OPTIONS` is an optional convenience preset for this pairing.
|
|
296
|
+
It disables the trap's competing focus restoration paths so `usePopupMenu`
|
|
297
|
+
remains the sole owner of focus restoration. `initialFocus` stays explicit
|
|
298
|
+
because it should preserve the focus behavior chosen by each menu. Spread the
|
|
299
|
+
preset last in the options object so these required integration settings win
|
|
300
|
+
over conflicting trap options:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
usePopupTrap({
|
|
304
|
+
isOpen,
|
|
305
|
+
initialFocus: () => document.activeElement as HTMLElement | null,
|
|
306
|
+
...POPUP_MENU_TRAP_OPTIONS,
|
|
288
307
|
});
|
|
289
308
|
```
|
|
290
309
|
|
|
@@ -398,7 +417,7 @@ Whenever `anchor` is configured, `usePopupMenu` automatically flips the popup to
|
|
|
398
417
|
- Trigger replacement and virtualization are handled by re-resolving the ref. The previous trigger is demoted to `aria-expanded="false"`, not stripped of attributes, so a shared instance can move between many rows (e.g. a grid) without losing each row's `aria-haspopup` baseline. Set `manageMenuTriggerAria` to `false` when the consumer owns `aria-expanded` too.
|
|
399
418
|
- Opening focuses the first enabled Kendo Menu item after Vue renders it.
|
|
400
419
|
- Outside clicks close the menu, while the trigger is ignored to prevent a close/reopen race.
|
|
401
|
-
- Menu selection
|
|
420
|
+
- Menu selection records modality and requests close before running the optional business callback synchronously. Registry-backed consumers must snapshot business context before calling `handleMenuSelect`; `useActionCellMenu` does this internally.
|
|
402
421
|
- `Escape` on the trigger or inside the menu uses the keyboard close path.
|
|
403
422
|
- Keyboard-driven closes focus `resolveFocusTarget()` or `focusTargetRef`, then fall back to the trigger.
|
|
404
423
|
- Mouse selection closes restore focus to the trigger.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Shared Popup Menu Lifecycle with `useActiveIdRegistry`
|
|
2
|
+
|
|
3
|
+
[Back to usePopupMenu](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenu.md)
|
|
4
|
+
|
|
5
|
+
## The Story
|
|
6
|
+
|
|
7
|
+
Imagine a grid with 500 rows, but only one popup menu instance mounted for the entire grid.
|
|
8
|
+
|
|
9
|
+
When the user activates row `42`, the application needs to answer three questions:
|
|
10
|
+
|
|
11
|
+
1. Which ID is active?
|
|
12
|
+
2. Which DOM element belongs to that ID?
|
|
13
|
+
3. Which shared popup should display the corresponding content?
|
|
14
|
+
|
|
15
|
+
`useActiveIdRegistry` answers all three. It is the bridge between repeated grid items and a shared popup menu.
|
|
16
|
+
|
|
17
|
+
## Registering Each Trigger
|
|
18
|
+
|
|
19
|
+
As rows render, the consumer connects each stable row ID to its DOM element:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
rowRegistry.register(props.dataItem.id, rowElement);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
In a render callback, this commonly looks like:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
ref: (el: HTMLElement | null) =>
|
|
29
|
+
rowRegistry.register(props.dataItem.id, el),
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The registry does not discover row elements automatically. Registration is what makes `activeElement` useful.
|
|
33
|
+
|
|
34
|
+
When a row is unmounted or virtualized away, the ref callback receives `null`, and the registry removes that ID.
|
|
35
|
+
|
|
36
|
+
## Activating the Shared Popup
|
|
37
|
+
|
|
38
|
+
When the user activates a row, the consumer first checks whether that same row is already active:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
if (rowRegistry.isIdActive(id)) {
|
|
42
|
+
hideRowMenu();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For a new row, the consumer resolves the element, captures any positioning information, and activates the ID:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const row = rowRegistry.resolve(id);
|
|
51
|
+
if (!row) return;
|
|
52
|
+
|
|
53
|
+
if (!rowMenu.setAnchorActivation(row, context)) return;
|
|
54
|
+
|
|
55
|
+
rowRegistry.activate(id);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The important ordering is:
|
|
59
|
+
|
|
60
|
+
1. Resolve the element.
|
|
61
|
+
2. Validate or capture activation geometry.
|
|
62
|
+
3. Activate the ID.
|
|
63
|
+
|
|
64
|
+
That prevents the shared popup from becoming open for an ID whose DOM element is unavailable.
|
|
65
|
+
|
|
66
|
+
## Connecting the Registry to `usePopupMenu`
|
|
67
|
+
|
|
68
|
+
The shared `usePopupMenu` instance consumes derived registry state:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const rowMenu = usePopupMenu({
|
|
72
|
+
isOpen: rowRegistry.isActive,
|
|
73
|
+
triggerRef: rowRegistry.activeElement,
|
|
74
|
+
menuRef: rowMenuRef,
|
|
75
|
+
triggerMode: "row",
|
|
76
|
+
requestHide: () => hideRowMenu(),
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The registry supplies the shared menu's current context:
|
|
81
|
+
|
|
82
|
+
- `activeId` selects the current data item.
|
|
83
|
+
- `activeElement` supplies the current trigger.
|
|
84
|
+
- `isActive` controls popup visibility.
|
|
85
|
+
- Computed menu items and labels derive from `activeId`.
|
|
86
|
+
|
|
87
|
+
This is the central pattern used by the local shared-popup demos.
|
|
88
|
+
|
|
89
|
+
## Deactivation Is the Other Half
|
|
90
|
+
|
|
91
|
+
Every close path must eventually clear the registry:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const hideRowMenu = () => {
|
|
95
|
+
rowRegistry.deactivate();
|
|
96
|
+
nextTick(updateRowDomStates);
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
That includes:
|
|
101
|
+
|
|
102
|
+
- clicking the active trigger again
|
|
103
|
+
- selecting a menu item
|
|
104
|
+
- pressing Escape
|
|
105
|
+
- clicking outside
|
|
106
|
+
- the active row leaving the clipping area
|
|
107
|
+
- switching from a row menu to a button menu
|
|
108
|
+
- replacing the active ID with another ID
|
|
109
|
+
|
|
110
|
+
If deactivation is missed, the popup can remain logically open after the visual UI disappears. A stale `activeId` can also cause the wrong row to be treated as active, `activeElement` to point at an old element, focus restoration to target a stale trigger, or the next popup open to reuse stale menu data.
|
|
111
|
+
|
|
112
|
+
## One Registry per Popup Family
|
|
113
|
+
|
|
114
|
+
The shared popup demo uses separate registries for row and button menus:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const rowRegistry = useActiveIdRegistry<number>();
|
|
118
|
+
const buttonRegistry = useActiveIdRegistry<number>();
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
This is intentional. The two menu families have different trigger types, positioning rules, content, and focus semantics.
|
|
122
|
+
|
|
123
|
+
When opening a row menu, close the button menu first:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
hideButtonMenu();
|
|
127
|
+
rowRegistry.activate(id);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
When opening a button menu, close the row menu first:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
hideRowMenu();
|
|
134
|
+
buttonMenu.handleTriggerClick(id);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The rule is:
|
|
138
|
+
|
|
139
|
+
> One registry represents one mutually exclusive active-popup family.
|
|
140
|
+
|
|
141
|
+
Do not use one registry merely because two menus happen to appear on the same screen.
|
|
142
|
+
|
|
143
|
+
## When Menu Families Compete
|
|
144
|
+
|
|
145
|
+
Keep the registry lifecycle and popup lifecycle separate. Use
|
|
146
|
+
`useExclusiveGroup` when independent menu families must never be open at the
|
|
147
|
+
same time, and deactivate competing registries before activating the next
|
|
148
|
+
one.
|
|
149
|
+
|
|
150
|
+
For the complete exclusivity pattern and focus behavior, see [Focus and
|
|
151
|
+
Exclusivity](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuFocusAndExclusivity.md).
|
|
152
|
+
|
|
153
|
+
## Composite IDs for ActionCell
|
|
154
|
+
|
|
155
|
+
ActionCell has multiple triggers per row, so a row number alone is not enough. The consumer creates a deterministic composite ID:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const getActionTriggerId = (
|
|
159
|
+
trigger: ActionCellTrigger<Patient>,
|
|
160
|
+
patient: Patient,
|
|
161
|
+
) => `${trigger.key}:${patient.id}`;
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
An active ID such as `patient:42` identifies both the trigger type and the data item. The consumer can derive the active trigger, patient, title, and menu items from that one ID.
|
|
165
|
+
|
|
166
|
+
This lets one registry control all ActionCell trigger types while still identifying the exact active trigger.
|
|
167
|
+
|
|
168
|
+
## Consumer Checklist
|
|
169
|
+
|
|
170
|
+
For a shared popup backed by `useActiveIdRegistry`:
|
|
171
|
+
|
|
172
|
+
1. Create one registry per popup family.
|
|
173
|
+
2. Register each rendered trigger with a stable ID.
|
|
174
|
+
3. Unregister through the ref callback when the element disappears.
|
|
175
|
+
4. Pass `registry.isActive` as `usePopupMenu.isOpen`.
|
|
176
|
+
5. Pass `registry.activeElement` as `triggerRef`.
|
|
177
|
+
6. Derive labels and menu items from `registry.activeId`.
|
|
178
|
+
7. Activate only after resolving and validating the trigger element.
|
|
179
|
+
8. Deactivate on every close path.
|
|
180
|
+
9. Close other registries before activating a competing menu.
|
|
181
|
+
10. Use `useExclusiveGroup` when independent registry families must be mutually exclusive.
|
|
182
|
+
|
|
183
|
+
The shortest mental model is:
|
|
184
|
+
|
|
185
|
+
> Registration answers “where is this ID?” Activation answers “which ID owns the popup?” Deactivation answers “does the popup still have an owner?”
|
|
@@ -340,17 +340,19 @@ const handleGridKeydown = (event: KeyboardEvent) => {
|
|
|
340
340
|
};
|
|
341
341
|
|
|
342
342
|
const handleRowMenuSelect = (event: KendoMenuSelectEvent) => {
|
|
343
|
+
const record = activeRowRecord.value;
|
|
343
344
|
rowMenu.handleMenuSelect(event, (selection) => {
|
|
344
|
-
if (selection.item &&
|
|
345
|
-
console.log(selection.item.id,
|
|
345
|
+
if (selection.item && record) {
|
|
346
|
+
console.log(selection.item.id, record);
|
|
346
347
|
}
|
|
347
348
|
});
|
|
348
349
|
};
|
|
349
350
|
|
|
350
351
|
const handleButtonMenuSelect = (event: KendoMenuSelectEvent) => {
|
|
352
|
+
const record = activeButtonRecord.value;
|
|
351
353
|
buttonMenu.handleMenuSelect(event, (selection) => {
|
|
352
|
-
if (selection.item &&
|
|
353
|
-
console.log(selection.item.id,
|
|
354
|
+
if (selection.item && record) {
|
|
355
|
+
console.log(selection.item.id, record);
|
|
354
356
|
}
|
|
355
357
|
});
|
|
356
358
|
};
|
|
@@ -493,21 +495,24 @@ class-based lookup can't reliably tell them apart - pin each trap to its own
|
|
|
493
495
|
Popup by the `id` it already has:
|
|
494
496
|
|
|
495
497
|
```ts
|
|
498
|
+
import {
|
|
499
|
+
POPUP_MENU_TRAP_OPTIONS,
|
|
500
|
+
usePopupTrap,
|
|
501
|
+
} from "@featherk/composables/trap";
|
|
502
|
+
|
|
496
503
|
usePopupTrap({
|
|
497
504
|
isOpen: rowRegistry.isActive,
|
|
498
505
|
triggerEl: rowRegistry.activeElement,
|
|
499
506
|
resolvePopupEl: () => document.getElementById("shared-row-menu-popup"),
|
|
500
507
|
initialFocus: () => document.activeElement as HTMLElement | null,
|
|
501
|
-
|
|
502
|
-
focusTrapOptions: { returnFocusOnDeactivate: false },
|
|
508
|
+
...POPUP_MENU_TRAP_OPTIONS,
|
|
503
509
|
});
|
|
504
510
|
usePopupTrap({
|
|
505
511
|
isOpen: buttonRegistry.isActive,
|
|
506
512
|
triggerEl: buttonRegistry.activeElement,
|
|
507
513
|
resolvePopupEl: () => document.getElementById("shared-button-menu-popup"),
|
|
508
514
|
initialFocus: () => document.activeElement as HTMLElement | null,
|
|
509
|
-
|
|
510
|
-
focusTrapOptions: { returnFocusOnDeactivate: false },
|
|
515
|
+
...POPUP_MENU_TRAP_OPTIONS,
|
|
511
516
|
});
|
|
512
517
|
```
|
|
513
518
|
|
|
@@ -36,6 +36,30 @@ usePopupTrap({
|
|
|
36
36
|
</template>
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
When pairing this composable with `usePopupMenu`, the optional
|
|
40
|
+
`POPUP_MENU_TRAP_OPTIONS` export disables the trap's competing focus
|
|
41
|
+
restoration paths:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import {
|
|
45
|
+
POPUP_MENU_TRAP_OPTIONS,
|
|
46
|
+
usePopupTrap,
|
|
47
|
+
} from "@featherk/composables/trap";
|
|
48
|
+
|
|
49
|
+
usePopupTrap({
|
|
50
|
+
isOpen,
|
|
51
|
+
triggerEl,
|
|
52
|
+
initialFocus: () => document.activeElement as HTMLElement | null,
|
|
53
|
+
...POPUP_MENU_TRAP_OPTIONS,
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Keep `initialFocus` explicit because the correct focus target is specific to
|
|
58
|
+
the popup and its owning composable. `allowOutsideClick` is already `true` by
|
|
59
|
+
default and is not repeated in this preset. Spread this preset last when using
|
|
60
|
+
it with `usePopupMenu`; its focus-restoration settings are required for that
|
|
61
|
+
integration and should take precedence over conflicting options.
|
|
62
|
+
|
|
39
63
|
### TimePicker example (custom selectors)
|
|
40
64
|
|
|
41
65
|
```ts
|