@featherk/composables 0.11.0 → 0.11.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
@@ -2,6 +2,7 @@ export * from "./grid";
2
2
  export * from "./address";
3
3
  export * from "./form";
4
4
  export * from "./menu";
5
+ export * from "./registry";
5
6
  export { useMaskedDateInput } from "./date";
6
7
  export type { ChangePayload as DateChangePayload } from "./date";
7
8
  export { useMaskedDateRangeInput } from "./range";
@@ -41,10 +41,18 @@ export type UsePopupMenuOptions = {
41
41
  * Defaults to `".k-menu-item:not(.k-disabled)"` for Kendo Menu.
42
42
  */
43
43
  menuItemSelector?: string;
44
- /** Keeps menu-trigger ARIA attributes in sync with `isOpen`. Defaults to `true`. */
44
+ /**
45
+ * Keeps `aria-expanded` in sync with `isOpen` on the resolved `triggerRef`.
46
+ * Defaults to `true`. The consumer is always responsible for the static
47
+ * `aria-haspopup="menu"` and baseline `aria-expanded="false"` attributes.
48
+ */
45
49
  manageMenuTriggerAria?: boolean;
46
- /** Allows a table row to own the menu trigger semantics without adding button behavior. */
47
- triggerMode?: PopupMenuTriggerMode;
50
+ /**
51
+ * Declares whether `triggerRef` is a button or a `tr.k-table-row`. Required so
52
+ * misrouted refs (e.g. a button-mode ref that resolves to a row) are never
53
+ * silently managed.
54
+ */
55
+ triggerMode: PopupMenuTriggerMode;
48
56
  };
49
57
  /** Functions returned by {@link usePopupMenu} for the action menu. */
50
58
  export type UsePopupMenuReturn = {
@@ -0,0 +1,2 @@
1
+ export { useActiveIdRegistry } from "./useActiveIdRegistry";
2
+ export type { RegisterableElement, UseActiveIdRegistryReturn, } from "./useActiveIdRegistry";
@@ -0,0 +1,35 @@
1
+ import { type ComputedRef, type DeepReadonly, type Ref } from "vue";
2
+ /** An element ref value that may be a plain element or a Vue/Kendo ref-like object exposing `$el`. */
3
+ export type RegisterableElement = HTMLElement | {
4
+ $el?: HTMLElement | null;
5
+ } | null;
6
+ /** Functions and state returned by {@link useActiveIdRegistry}. */
7
+ export type UseActiveIdRegistryReturn<Id> = {
8
+ /** The currently active id, or `null` when nothing is active. Read-only; use `activate`/`deactivate` to change it. */
9
+ activeId: DeepReadonly<Ref<Id | null>>;
10
+ /** `true` while any id is active. */
11
+ isActive: ComputedRef<boolean>;
12
+ /** The registered element for the active id, or `null`. */
13
+ activeElement: ComputedRef<HTMLElement | null>;
14
+ /** Registers (or unregisters, when `el` is `null`) the element for `id`. */
15
+ register: (id: Id, el: RegisterableElement) => void;
16
+ /** Returns the currently registered element for `id`, if any. */
17
+ resolve: (id: Id) => HTMLElement | null;
18
+ /** Marks `id` as active. */
19
+ activate: (id: Id) => void;
20
+ /** Clears the active id. */
21
+ deactivate: () => void;
22
+ /** Returns whether `id` is the currently active id. */
23
+ isIdActive: (id: Id) => boolean;
24
+ };
25
+ /**
26
+ * Tracks which single id, out of a rendered list of items (grid rows, tabs,
27
+ * action buttons, etc.), is currently "active," alongside a registry
28
+ * resolving each id to its mounted DOM element.
29
+ *
30
+ * This helper is useful for shared-instance patterns where one popup/menu instance
31
+ * is reused across a list while a single item remains active at a time.
32
+ *
33
+ * @returns Active-id state, an element registry, and activation helpers.
34
+ */
35
+ export declare const useActiveIdRegistry: <Id = string | number>() => UseActiveIdRegistryReturn<Id>;
@@ -0,0 +1 @@
1
+ export {};
@@ -6,27 +6,29 @@ Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It man
6
6
 
7
7
  ## Quick Start
8
8
 
9
- 1. Create parent-owned refs for `isOpen`, `triggerRef`, `menuRef`, and an optional keyboard focus target.
10
- 2. Call `usePopupMenu(...)` with `requestShow` and `requestHide` that update the parent-owned `isOpen` state.
11
- 3. Route Kendo Menu `@select` through `handleMenuSelect(event, onAction)` so the composable closes first and your business action runs second.
12
- 4. Bind trigger button `@click` and `@keydown` to the composable handlers.
13
- 5. Bind Popup `@close` and Menu `@keydown.escape` so keyboard and mouse close paths restore focus correctly.
9
+ 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.
10
+ 2. Create parent-owned refs for `isOpen`, `triggerRef`, `menuRef`, and an optional keyboard focus target.
11
+ 3. Call `usePopupMenu(...)` with `requestShow`, `requestHide`, and an explicit `triggerMode: "button"` (or `"row"`).
12
+ 4. Route Kendo Menu `@select` through `handleMenuSelect(event, onAction)` so the composable closes first and your business action runs second.
13
+ 5. Bind trigger button `@click` and `@keydown` to the composable handlers.
14
+ 6. Bind Popup `@close` and Menu `@keydown.escape` so keyboard and mouse close paths restore focus correctly.
14
15
 
15
16
  ```vue
16
17
  <template>
17
18
  <section ref="panelRef">
18
- <!-- Step 4: bind trigger handlers -->
19
+ <!-- Step 1: static aria-haspopup/aria-expanded baseline; Step 5: bind trigger handlers -->
19
20
  <button
20
21
  ref="triggerRef"
21
22
  type="button"
22
23
  aria-haspopup="menu"
24
+ aria-expanded="false"
23
25
  @click="menu.handleActionButtonClick"
24
26
  @keydown="menu.handleActionButtonKeydown"
25
27
  >
26
28
  Actions
27
29
  </button>
28
30
 
29
- <!-- Step 5: bind popup/menu close hooks for modality-aware focus restoration -->
31
+ <!-- Step 6: bind popup/menu close hooks for modality-aware focus restoration -->
30
32
  <Popup :show="isOpen" @close="menu.handlePopupClose">
31
33
  <Menu
32
34
  ref="menuRef"
@@ -44,23 +46,24 @@ import {
44
46
  type KendoMenuSelectEvent,
45
47
  } from "@featherk/composables/menu";
46
48
 
47
- // Step 1: parent-owned menu state and element refs
49
+ // Step 2: parent-owned menu state and element refs
48
50
  const isOpen = ref(false);
49
51
  const triggerRef = ref<HTMLElement | null>(null);
50
52
  const menuRef = ref<HTMLElement | null>(null);
51
53
  const panelRef = ref<HTMLElement | null>(null);
52
54
 
53
- // Step 2: wire composable show/hide ownership to parent state
55
+ // Step 3: wire composable show/hide ownership to parent state
54
56
  const menu = usePopupMenu({
55
57
  isOpen,
56
58
  triggerRef,
57
59
  menuRef,
60
+ triggerMode: "button",
58
61
  focusTargetRef: panelRef,
59
62
  requestShow: () => (isOpen.value = true),
60
63
  requestHide: () => (isOpen.value = false),
61
64
  });
62
65
 
63
- // Step 3: run close+modality handling first, then app-specific action logic
66
+ // Step 4: run close+modality handling first, then app-specific action logic
64
67
  const onSelect = (event: KendoMenuSelectEvent) => {
65
68
  menu.handleMenuSelect(event, (selected) => {
66
69
  // Keep application-specific actions in the consuming component.
@@ -74,11 +77,12 @@ const onSelect = (event: KendoMenuSelectEvent) => {
74
77
 
75
78
  For a virtualized Kendo Grid, use `resolveFocusTarget` to locate the current row when the popup closes by keyboard. The resolver is evaluated at focus time, after the grid and popup have settled.
76
79
 
77
- 1. Read `showMenu` from the row item and keep open/close state parent-owned.
78
- 2. Create trigger and menu refs as component refs (`ComponentPublicInstance`) for Kendo wrappers.
79
- 3. Call `usePopupMenu(...)` and map `requestShow`/`requestHide` to row-specific emits.
80
- 4. Provide `resolveFocusTarget` so keyboard close restores focus to the current virtualized row.
81
- 5. Route menu selection through `handleMenuSelect(event, onAction)` so close/focus behavior runs before row action logic.
80
+ 1. Add static `aria-haspopup="menu"` and `aria-expanded="false"` to the row's trigger button template.
81
+ 2. Read `showMenu` from the row item and keep open/close state parent-owned.
82
+ 3. Create trigger and menu refs as component refs (`ComponentPublicInstance`) for Kendo wrappers.
83
+ 4. Call `usePopupMenu(...)` with an explicit `triggerMode: "button"` and map `requestShow`/`requestHide` to row-specific emits.
84
+ 5. Provide `resolveFocusTarget` so keyboard close restores focus to the current virtualized row.
85
+ 6. Route menu selection through `handleMenuSelect(event, onAction)` so close/focus behavior runs before row action logic.
82
86
 
83
87
  ```ts
84
88
  <script setup lang="ts">
@@ -94,21 +98,22 @@ const emit = defineEmits<{
94
98
  "update:hideMenu": [id: string];
95
99
  }>();
96
100
 
97
- // Step 1: read row-owned open state from the data item
101
+ // Step 2: read row-owned open state from the data item
98
102
  const isOpen = computed(() => props.dataItem.showMenu);
99
103
 
100
- // Step 2: Kendo refs are component instances; usePopupMenu resolves $el internally
104
+ // Step 3: Kendo refs are component instances; usePopupMenu resolves $el internally
101
105
  const triggerRef = ref<ComponentPublicInstance | null>(null);
102
106
  const menuRef = ref<ComponentPublicInstance | null>(null);
103
107
 
104
- // Step 3: keep popup ownership in parent row state via emits
108
+ // Step 4: keep popup ownership in parent row state via emits
105
109
  const menu = usePopupMenu({
106
110
  isOpen,
107
111
  triggerRef,
108
112
  menuRef,
113
+ triggerMode: "button",
109
114
  requestShow: () => emit("update:showMenu", props.dataItem.id),
110
115
  requestHide: () => emit("update:hideMenu", props.dataItem.id),
111
- // Step 4: keyboard close restores focus to current virtualized grid row
116
+ // Step 5: keyboard close restores focus to current virtualized grid row
112
117
  resolveFocusTarget: () => {
113
118
  const trigger = triggerRef.value?.$el as HTMLElement | undefined;
114
119
  return (
@@ -117,7 +122,7 @@ const menu = usePopupMenu({
117
122
  },
118
123
  });
119
124
 
120
- // Step 5: let usePopupMenu close first, then run row-specific business action
125
+ // Step 6: let usePopupMenu close first, then run row-specific business action
121
126
  const onSelect = (event: KendoMenuSelectEvent) => {
122
127
  menu.handleMenuSelect(event, (selected) => {
123
128
  // Row-specific action logic remains here.
@@ -127,6 +132,98 @@ const onSelect = (event: KendoMenuSelectEvent) => {
127
132
  </script>
128
133
  ```
129
134
 
135
+ ## Row + Button Dual Trigger
136
+
137
+ A grid row can have both a row-level context menu (`triggerMode: "row"`) and a nested button-level quick-actions menu (`triggerMode: "button"`). Each trigger role needs its own composable instance and its own resolved DOM node; sharing a `triggerRef` between them lets one instance silently overwrite the other's `aria-expanded` state.
138
+
139
+ 1. Add static `aria-haspopup="menu"` and `aria-expanded="false"` to every menu-capable trigger (the row's rendered `tr` and the nested button) up front, in the template, not through the composable.
140
+ 2. Create one `usePopupMenu` instance per trigger role, each with its own `triggerRef`, `isOpen`, `requestShow`, and `requestHide`.
141
+ 3. Resolve the row instance's `triggerRef` from a stable cell inside the row (e.g. `cellRef.value?.closest(".k-table-row")`); resolve the button instance's `triggerRef` from the button component ref directly. Never derive one from the other.
142
+ 4. Set `triggerMode` explicitly for each instance (`"row"` for the row trigger, `"button"` for the nested button).
143
+ 5. Verify in devtools that each instance's `triggerRef.value` resolves to a distinct DOM node before wiring `requestShow`/`requestHide`.
144
+
145
+ ```ts
146
+ // Step 2-4: independent instances, independent triggerRef, explicit triggerMode
147
+ const rowTriggerRef = computed(() => cellRef.value?.closest(".k-table-row") as HTMLElement | null);
148
+
149
+ const buttonMenu = usePopupMenu({
150
+ isOpen: computed(() => props.dataItem.showButtonMenu),
151
+ triggerRef: buttonRef,
152
+ menuRef: buttonMenuRef,
153
+ triggerMode: "button",
154
+ requestShow: () => emit("update:toggleButtonMenu", props.dataItem.id),
155
+ requestHide: () => emit("update:hideButtonMenu", props.dataItem.id),
156
+ });
157
+
158
+ const rowMenu = usePopupMenu({
159
+ isOpen: computed(() => props.dataItem.showRowMenu),
160
+ triggerRef: rowTriggerRef,
161
+ menuRef: rowMenuRef,
162
+ triggerMode: "row",
163
+ requestShow: () => emit("update:toggleRowMenu", props.dataItem.id),
164
+ requestHide: () => emit("update:hideRowMenu", props.dataItem.id),
165
+ });
166
+ ```
167
+
168
+ See [UsePopupMenu.vue](https://github.com/NantHealth/featherk/blob/integration/demos/src/views/composables/UsePopupMenu.vue) for the full reference implementation: exactly two shared instances (one row-mode, one button-mode) for the whole grid, with per-row/button trigger registries and offset-positioned Popups instead of per-row `usePopupMenu` calls.
169
+
170
+ ## Sharing One Instance Across Many Triggers (Grid-Wide)
171
+
172
+ A single `usePopupMenu` instance can manage an entire list of same-mode triggers (every row, or every row's action button) instead of instantiating one composable per row. This avoids one popup lifecycle, one `onClickOutside` listener, and one set of watchers per row in large grids. It requires a small amount of consumer-owned bookkeeping the composable itself does not provide; the public helper `useActiveIdRegistry` from `@featherk/composables` handles that bookkeeping so each view does not need to reimplement it.
173
+
174
+ 1. Track exactly one "active id" for the whole list, instead of a boolean per row item, via `useActiveIdRegistry<Id>()`. Only one trigger in the list can be open at a time. It exposes a read-only `activeId`, a reactive `isActive`/`activeElement` pair, and `register`/`resolve`/`activate`/`deactivate`/`isIdActive` helpers.
175
+ 2. Register each row/button element into the registry as it mounts (e.g. a `row-render` ref callback, or a component `:ref` callback) via `registry.register(id, el)`. The registry's internal element map is reactive, so `activeElement` stays correct even if a keyed/virtualized re-render swaps out the active id's underlying DOM node.
176
+ 3. Feed `isOpen: registry.isActive` and `triggerRef: registry.activeElement` straight into `usePopupMenu`, rather than deriving them from a per-row prop.
177
+ 4. Position the shared `Popup` using `:offset` computed from the clicked element's `getBoundingClientRect()` (or click coordinates), not `:anchor`. Kendo's `Popup.anchor` only resolves a static template-ref name once at mount; it cannot dynamically retarget a different element after the popup is already mounted.
178
+ 5. Drive show/hide through `registry.activate(id)` / `registry.deactivate()` rather than binding `handleActionButtonClick` directly to every trigger's `@click`. A single instance's `isOpen` is one shared boolean, so `handleActionButtonClick()` cannot tell "close this trigger" apart from "open a different trigger" — calling it while another id is active will close the wrong thing.
179
+
180
+ ```ts
181
+ // Step 1: one registry tracks the active id and resolves it to its DOM element
182
+ const rowRegistry = useActiveIdRegistry<number>();
183
+
184
+ // Step 3: isOpen/triggerRef derive from the registry, not a per-row prop
185
+ const rowMenu = usePopupMenu({
186
+ isOpen: rowRegistry.isActive,
187
+ triggerRef: rowRegistry.activeElement,
188
+ menuRef: rowMenuRef,
189
+ triggerMode: "row",
190
+ requestShow: () => {}, // state is set directly by the row click handler, not by the composable
191
+ requestHide: () => rowRegistry.deactivate(),
192
+ });
193
+
194
+ // Step 2: register each row's element as it renders (e.g. inside a Grid row-render function)
195
+ const registerRowTrigger = (id: number, el: HTMLElement | null) => {
196
+ rowRegistry.register(id, el);
197
+ };
198
+
199
+ // Step 5: toggle logic owns the open/close decision; the composable only reacts
200
+ const toggleRowMenu = (id: number) => {
201
+ if (rowRegistry.isIdActive(id)) {
202
+ rowRegistry.deactivate(); // closes: the reactive watcher demotes aria-expanded
203
+ return;
204
+ }
205
+ rowRegistry.activate(id); // switches directly: previous trigger is demoted, new one promoted
206
+ };
207
+ ```
208
+
209
+ For a second shared instance driven by a real `@click` handler on each trigger (e.g. a per-row action button, where `handleActionButtonClick` toggle semantics would otherwise misfire on a different trigger), close the currently active trigger explicitly through the composable before switching the active id, so its `triggerRef` still resolves to the correct element at close time:
210
+
211
+ ```ts
212
+ const toggleButtonMenu = (id: number) => {
213
+ if (buttonRegistry.isIdActive(id)) {
214
+ buttonMenu.handleActionButtonClick(); // closes: triggerRef still matches this id
215
+ return;
216
+ }
217
+ if (buttonRegistry.isActive.value) {
218
+ buttonMenu.handleActionButtonClick(); // closes the OTHER trigger correctly, before switching
219
+ }
220
+ buttonRegistry.activate(id); // the reactive watcher promotes the new trigger's aria-expanded
221
+ nextTick(focusFirstMenuItem); // reproduce first-item focus manually; the "open" branch never ran
222
+ };
223
+ ```
224
+
225
+ See [UsePopupMenu.vue](https://github.com/NantHealth/featherk/blob/integration/demos/src/views/composables/UsePopupMenu.vue) for the complete working version of both patterns, including the registry-building `row-render` function and the offset-computation logic. `useActiveIdRegistry` is now part of `@featherk/composables`, so shared-instance consumers can import it directly instead of keeping a demo-local copy.
226
+
130
227
  ## API
131
228
 
132
229
  ### `usePopupMenu(options)`
@@ -141,8 +238,8 @@ const onSelect = (event: KendoMenuSelectEvent) => {
141
238
  - `focusTargetRef?: PopupMenuElementRef`: Optional element/component to focus after a keyboard-driven close.
142
239
  - `resolveFocusTarget?: () => HTMLElement | null`: Dynamic focus-target resolver. It takes precedence over `focusTargetRef` and is useful for virtualized grid rows.
143
240
  - `menuItemSelector?: string`: Selector for the first enabled item. Defaults to `.k-menu-item:not(.k-disabled)`.
144
- - `manageMenuTriggerAria?: boolean`: Keeps `aria-haspopup="menu"` and `aria-expanded` in sync with `isOpen` on the resolved `triggerRef` element. Defaults to `true`; while enabled, the composable owns both attributes and removes them when the trigger is replaced or the component unmounts. Set to `false` to own both attributes yourself.
145
- - `triggerMode?: "button" | "row"`: Selects the trigger semantics. Defaults to `"button"`; use `"row"` only when a `tr.k-table-row` owns the menu trigger. Row mode manages ARIA attributes but does not add `role`, `tabindex`, or keyboard behavior.
241
+ - `manageMenuTriggerAria?: boolean`: Keeps `aria-expanded` in sync with `isOpen` on the resolved `triggerRef` element. Defaults to `true`. The consumer always owns the static `aria-haspopup="menu"` attribute and the baseline `aria-expanded="false"`; the composable only ever writes `aria-expanded` and never removes it. Set to `false` to own `aria-expanded` yourself too.
242
+ - `triggerMode: "button" | "row"`: Required. Declares the trigger semantics so a misrouted `triggerRef` (e.g. one that unexpectedly resolves to a `tr.k-table-row` while `triggerMode: "button"` is set) is never silently managed; a mismatch logs a `console.warn` and skips ARIA management for that resolution. Use `"row"` only when a `tr.k-table-row` owns the menu trigger. Neither mode adds `role`, `tabindex`, or keyboard behavior.
146
243
 
147
244
  `PopupMenuElementRef` accepts a normal `HTMLElement` ref or a Vue/Kendo component ref that exposes `$el`.
148
245
 
@@ -173,9 +270,10 @@ export type KendoMenuSelectEvent = {
173
270
  ## Behavior
174
271
 
175
272
  - Pointer click, `Enter`, and `Space` toggle the menu.
176
- - `aria-haspopup="menu"` and `aria-expanded` are written to every resolved `triggerRef` element, including native buttons and Vue/Kendo component refs resolved through `$el`.
177
- - A `tr.k-table-row` is managed only with `triggerMode: "row"`; rows are never automatically made keyboard buttons.
178
- - Trigger replacement and virtualization are handled by re-resolving the ref. Managed attributes are removed from the previous trigger and on unmount. Set `manageMenuTriggerAria` to `false` when the consumer owns the attributes.
273
+ - `aria-haspopup="menu"` is a static, consumer-owned template attribute; the composable never sets or removes it.
274
+ - `aria-expanded` is written to every resolved `triggerRef` element, including native buttons and Vue/Kendo component refs resolved through `$el`. The consumer provides the baseline `aria-expanded="false"` in the template; the composable only ever updates the value afterward.
275
+ - A `tr.k-table-row` is managed only with `triggerMode: "row"`; rows are never automatically made keyboard buttons. A mismatched `triggerMode`/resolved-element combination logs a `console.warn` and skips management for that element.
276
+ - 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.
179
277
  - Opening focuses the first enabled Kendo Menu item after Vue renders it.
180
278
  - Outside clicks close the menu, while the trigger is ignored to prevent a close/reopen race.
181
279
  - Menu selection records keyboard or mouse modality from the selection event type.
@@ -0,0 +1,136 @@
1
+ # useActiveIdRegistry
2
+
3
+ [Back to Composables README](https://github.com/NantHealth/featherk/blob/integration/packages/composables/README.md)
4
+
5
+ Tracks a single active id across a list of rendered elements (grid rows, tabs, action buttons, etc.) while also resolving that id back to its mounted DOM element.
6
+
7
+ This helper is for shared-instance patterns where one popup/menu instance is reused across many triggers, but only a single item should be active/open at a time.
8
+
9
+ ## Why use it
10
+
11
+ When a popup or menu instance is shared across a grid or list, a simple boolean per item is not enough to coordinate the active target. `useActiveIdRegistry` keeps two things in sync:
12
+
13
+ - the active id for the list
14
+ - the mounted element for that active id
15
+
16
+ This lets consumers derive both `isOpen` and `triggerRef` from the same registry, rather than re-implementing one-off bookkeeping in each view.
17
+
18
+ ## Quick Start
19
+
20
+ 1. Create a registry instance at the shared list boundary so one active id drives the whole set of triggers.
21
+ 2. Register each mounted element by id as it appears or re-renders, including Kendo/Vue component refs via `$el`.
22
+ 3. Bind the registry's `isActive`/`activeElement` to the shared popup/menu instance and activate the item that should open.
23
+ 4. Deactivate when the item closes so the previously active trigger is demoted and the next item can become active.
24
+
25
+ ```ts
26
+ // Step 1: one registry manages the list-wide active id
27
+ const registry = useActiveIdRegistry<number>();
28
+
29
+ // Step 2: register each element as it mounts or re-renders
30
+ registry.register(1, rowElement); // Step 2
31
+ registry.register(2, buttonElement); // Step 2
32
+
33
+ // Step 3: connect the registry to a shared popup/menu instance
34
+ const isOpen = registry.isActive;
35
+ const triggerRef = registry.activeElement;
36
+
37
+ // Step 4: activate/deactivate when the current item opens or closes
38
+ registry.activate(2);
39
+
40
+ if (registry.isIdActive(2)) {
41
+ // active row/button is selected
42
+ }
43
+
44
+ registry.deactivate();
45
+ ```
46
+
47
+ ## Basic Usage
48
+
49
+ ```ts
50
+ const rowRegistry = useActiveIdRegistry<number>();
51
+
52
+ const rowMenu = usePopupMenu({
53
+ isOpen: rowRegistry.isActive,
54
+ triggerRef: rowRegistry.activeElement,
55
+ menuRef: rowMenuRef,
56
+ triggerMode: "row",
57
+ requestShow: () => {
58
+ // parent-owned open state is handled by registry activation
59
+ },
60
+ requestHide: () => rowRegistry.deactivate(),
61
+ });
62
+
63
+ const toggleRowMenu = (id: number) => {
64
+ if (rowRegistry.isIdActive(id)) {
65
+ rowRegistry.deactivate();
66
+ return;
67
+ }
68
+
69
+ rowRegistry.activate(id);
70
+ };
71
+ ```
72
+
73
+ This pattern is designed for one shared popup/menu instance across many rendered items. The active id stays in sync with the mounted DOM element, so the popup opens against the correct trigger without a per-item boolean for every row.
74
+
75
+ ## API
76
+
77
+ ```ts
78
+ const registry = useActiveIdRegistry<number>();
79
+
80
+ registry.register(1, rowElement);
81
+ registry.register(2, buttonElement);
82
+
83
+ registry.activate(2);
84
+
85
+ const isOpen = registry.isActive;
86
+ const triggerRef = registry.activeElement;
87
+
88
+ if (registry.isIdActive(2)) {
89
+ // active row/button is selected
90
+ }
91
+
92
+ registry.deactivate();
93
+ ```
94
+
95
+ ## Return shape
96
+
97
+ - `activeId`: readonly current id or `null`
98
+ - `isActive`: computed `true` when an id is active
99
+ - `activeElement`: computed DOM element for the active id
100
+ - `register(id, el)`: stores or removes the element for a given id
101
+ - `resolve(id)`: returns the registered DOM element for `id`
102
+ - `activate(id)`: sets the active id
103
+ - `deactivate()`: clears the active id
104
+ - `isIdActive(id)`: checks whether a given id is the current active id
105
+
106
+ ## Shared-instance popup usage
107
+
108
+ ```ts
109
+ const rowRegistry = useActiveIdRegistry<number>();
110
+
111
+ const rowMenu = usePopupMenu({
112
+ isOpen: rowRegistry.isActive,
113
+ triggerRef: rowRegistry.activeElement,
114
+ menuRef: rowMenuRef,
115
+ triggerMode: "row",
116
+ requestShow: () => {
117
+ // parent-owned open state is handled by activation logic
118
+ },
119
+ requestHide: () => rowRegistry.deactivate(),
120
+ });
121
+
122
+ const toggleRowMenu = (id: number) => {
123
+ if (rowRegistry.isIdActive(id)) {
124
+ rowRegistry.deactivate();
125
+ return;
126
+ }
127
+
128
+ rowRegistry.activate(id);
129
+ };
130
+ ```
131
+
132
+ ## Notes
133
+
134
+ - Accepts both raw DOM elements and Vue/Kendo component refs exposing `$el`.
135
+ - Works well with virtualized or keyed re-renders because the registry re-resolves the current active element when the underlying DOM node changes.
136
+ - This helper is intentionally generic; it does not own popup behavior or ARIA attributes itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@featherk/composables",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "main": "dist/featherk-composables.umd.js",
5
5
  "module": "dist/featherk-composables.es.js",
6
6
  "types": "dist/index.d.ts",
@@ -40,6 +40,11 @@
40
40
  "import": "./dist/featherk-composables.es.js",
41
41
  "require": "./dist/featherk-composables.umd.js"
42
42
  },
43
+ "./registry": {
44
+ "types": "./dist/registry/index.d.ts",
45
+ "import": "./dist/featherk-composables.es.js",
46
+ "require": "./dist/featherk-composables.umd.js"
47
+ },
43
48
  "./form": {
44
49
  "types": "./dist/form/index.d.ts",
45
50
  "import": "./dist/featherk-composables.es.js",