@featherk/composables 0.13.1 → 0.13.2

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;
@@ -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
 
@@ -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?”
@@ -493,21 +493,24 @@ class-based lookup can't reliably tell them apart - pin each trap to its own
493
493
  Popup by the `id` it already has:
494
494
 
495
495
  ```ts
496
+ import {
497
+ POPUP_MENU_TRAP_OPTIONS,
498
+ usePopupTrap,
499
+ } from "@featherk/composables/trap";
500
+
496
501
  usePopupTrap({
497
502
  isOpen: rowRegistry.isActive,
498
503
  triggerEl: rowRegistry.activeElement,
499
504
  resolvePopupEl: () => document.getElementById("shared-row-menu-popup"),
500
505
  initialFocus: () => document.activeElement as HTMLElement | null,
501
- returnFocusToTrigger: false,
502
- focusTrapOptions: { returnFocusOnDeactivate: false },
506
+ ...POPUP_MENU_TRAP_OPTIONS,
503
507
  });
504
508
  usePopupTrap({
505
509
  isOpen: buttonRegistry.isActive,
506
510
  triggerEl: buttonRegistry.activeElement,
507
511
  resolvePopupEl: () => document.getElementById("shared-button-menu-popup"),
508
512
  initialFocus: () => document.activeElement as HTMLElement | null,
509
- returnFocusToTrigger: false,
510
- focusTrapOptions: { returnFocusOnDeactivate: false },
513
+ ...POPUP_MENU_TRAP_OPTIONS,
511
514
  });
512
515
  ```
513
516
 
@@ -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.2",
4
4
  "main": "dist/featherk-composables.umd.js",
5
5
  "module": "dist/featherk-composables.es.js",
6
6
  "types": "dist/index.d.ts",