@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/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 your business action runs while the active menu context is still available, before the composable closes the popup.
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: act while the active context is available; usePopupMenu closes afterward
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 row action logic can use its active state before the popup closes.
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: run row-specific business action before usePopupMenu closes
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
- returnFocusToTrigger: false,
287
- focusTrapOptions: { returnFocusOnDeactivate: false },
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 runs the optional business callback while the active state is still available, then records modality and closes the popup.
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 && activeRowRecord.value) {
345
- console.log(selection.item.id, activeRowRecord.value);
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 && activeButtonRecord.value) {
353
- console.log(selection.item.id, activeButtonRecord.value);
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
- returnFocusToTrigger: false,
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
- returnFocusToTrigger: false,
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@featherk/composables",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "main": "dist/featherk-composables.umd.js",
5
5
  "module": "dist/featherk-composables.es.js",
6
6
  "types": "dist/index.d.ts",