@featherk/composables 0.11.1 → 0.12.0

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.
@@ -0,0 +1,2 @@
1
+ export { useCompositeId } from "./useCompositeId";
2
+ export type { CompositeIdPart, UseCompositeIdReturn } from "./useCompositeId";
@@ -0,0 +1,17 @@
1
+ /** A stable segment used to compose a component-scoped DOM identifier. */
2
+ export type CompositeIdPart = string | number | null | undefined;
3
+ /** Values returned by {@link useCompositeId}. */
4
+ export type UseCompositeIdReturn = {
5
+ /** Unique Vue component-instance identifier, prefixed for FeatherK markup. */
6
+ instanceId: string;
7
+ /** Combines the component instance ID with stable logical identifier segments. */
8
+ compose: (...parts: CompositeIdPart[]) => string;
9
+ };
10
+ /**
11
+ * Creates component-scoped DOM identifiers from Vue's SSR-safe `useId()`.
12
+ *
13
+ * Use the resulting `instanceId` for a stable local base and `compose` for
14
+ * related trigger, menu, popup, and ARIA relationship IDs. This does not
15
+ * replace consumer-owned domain IDs used for shared state or registries.
16
+ */
17
+ export declare const useCompositeId: (prefix?: string) => UseCompositeIdReturn;
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export * from "./grid";
2
2
  export * from "./address";
3
3
  export * from "./form";
4
+ export * from "./id";
4
5
  export * from "./menu";
6
+ export * from "./observer";
7
+ export * from "./registry";
5
8
  export { useMaskedDateInput } from "./date";
6
9
  export type { ChangePayload as DateChangePayload } from "./date";
7
10
  export { useMaskedDateRangeInput } from "./range";
@@ -1,9 +1,36 @@
1
- import { type ComponentPublicInstance, type Ref } from "vue";
1
+ import { type ComponentPublicInstance, type ComputedRef, type MaybeRefOrGetter, type Ref } from "vue";
2
2
  /** A template ref that may point to an element or a Vue/Kendo component. */
3
3
  export type PopupMenuElementRef = Ref<HTMLElement | ComponentPublicInstance | null>;
4
4
  export type PopupMenuTriggerMode = "button" | "row";
5
+ export type PopupMenuOffset = {
6
+ left: number;
7
+ top: number;
8
+ };
9
+ /** Offset-positioning options for a Popup with a dynamically changing trigger. */
10
+ export type PopupMenuAnchorOptions = {
11
+ /** Scrollable/clipping container used for out-of-view dismissal. */
12
+ clipRoot?: MaybeRefOrGetter<Element | null>;
13
+ /** Closes the menu after its trigger leaves the clipping container. */
14
+ hideWhenAnchorClipped?: boolean;
15
+ /** Overrides the trigger rect for pointer-positioned menus. */
16
+ getRect?: () => DOMRect | null;
17
+ };
5
18
  /** Why the popup was closed, including the focus policy for mouse paths. */
6
- export type PopupMenuCloseReason = "keyboard" | "mouse-selection" | "outside-click" | "trigger-click" | null;
19
+ export type PopupMenuCloseReason =
20
+ /** Keyboard-driven menu selection (Enter/Space on a menu item); restores the configured focus target. */
21
+ "keyboard"
22
+ /** Escape key; restores the configured focus target, then `escapeFocusTarget`, then the trigger. */
23
+ | "escape"
24
+ /** Pointer menu selection; restores focus to the trigger. */
25
+ | "mouse-selection"
26
+ /** Pointer interaction outside the menu; leaves browser focus unchanged. */
27
+ | "outside-click"
28
+ /** Interaction with the open trigger; leaves browser focus unchanged. */
29
+ | "trigger-click"
30
+ /** Trigger left its clipping area; closes without scrolling it back into view. */
31
+ | "anchor-hidden"
32
+ /** No close has been requested yet. */
33
+ | null;
7
34
  export type PopupMenuCloseTrigger = Exclude<PopupMenuCloseReason, null>;
8
35
  /** Minimal event shape emitted by Kendo Menu `@select`. */
9
36
  export type KendoMenuSelectEvent = {
@@ -22,10 +49,10 @@ export type UsePopupMenuOptions = {
22
49
  menuRef: PopupMenuElementRef;
23
50
  /** Ref to the trigger button element or component. Ignored by outside-click detection. */
24
51
  triggerRef: PopupMenuElementRef;
25
- /** Called to open the popup menu. */
26
- requestShow: () => void;
27
- /** Called to close the popup menu. */
28
- requestHide: () => void;
52
+ /** Called to open the popup menu. Kendo passes its event args as a rest tuple. */
53
+ requestShow: (...args: unknown[]) => void;
54
+ /** Called to close the popup menu. Kendo passes its event args as a rest tuple. */
55
+ requestHide: (...args: unknown[]) => void;
29
56
  /**
30
57
  * Optional target to focus after a keyboard-driven close.
31
58
  * Supports ordinary elements and Vue/Kendo components exposing `$el`.
@@ -36,25 +63,53 @@ export type UsePopupMenuOptions = {
36
63
  * Takes precedence over `focusTargetRef`.
37
64
  */
38
65
  resolveFocusTarget?: () => HTMLElement | null;
66
+ /**
67
+ * Resolves a fallback focus target for Escape closes when no `focusTargetRef`/
68
+ * `resolveFocusTarget` is configured, e.g. the trigger's containing grid row so
69
+ * keyboard users land back in row navigation instead of on the trigger button.
70
+ * Only consulted for `triggerMode: "button"`; row triggers already restore to
71
+ * themselves. Takes effect only for Escape, not keyboard menu selection.
72
+ * Takes precedence over `escapeFocusContainerSelector`.
73
+ */
74
+ escapeFocusTarget?: () => HTMLElement | null;
75
+ /**
76
+ * CSS selector for the closest ancestor of the trigger (or, if the menu was
77
+ * never opened, of `document.activeElement`) to focus on Escape. Sugar over
78
+ * `escapeFocusTarget` for the common "collapse back into the row" case.
79
+ * Defaults to `".k-table-row"` for `triggerMode: "button"`; harmless to leave
80
+ * at its default for non-grid button triggers since a missing ancestor simply
81
+ * falls through to focusing the trigger. Ignored when `escapeFocusTarget` is set.
82
+ */
83
+ escapeFocusContainerSelector?: string;
39
84
  /**
40
85
  * CSS selector used to find the first focusable menu item after open.
41
86
  * Defaults to `".k-menu-item:not(.k-disabled)"` for Kendo Menu.
42
87
  */
43
88
  menuItemSelector?: string;
44
- /** Keeps menu-trigger ARIA attributes in sync with `isOpen`. Defaults to `true`. */
89
+ /**
90
+ * Keeps `aria-expanded` in sync with `isOpen` on the resolved `triggerRef`.
91
+ * Defaults to `true`. The consumer is always responsible for the static
92
+ * `aria-haspopup="menu"` and baseline `aria-expanded="false"` attributes.
93
+ */
45
94
  manageMenuTriggerAria?: boolean;
46
- /** Allows a table row to own the menu trigger semantics without adding button behavior. */
47
- triggerMode?: PopupMenuTriggerMode;
95
+ /**
96
+ * Declares whether `triggerRef` is a button or a `tr.k-table-row`. Required so
97
+ * misrouted refs (e.g. a button-mode ref that resolves to a row) are never
98
+ * silently managed.
99
+ */
100
+ triggerMode: PopupMenuTriggerMode;
101
+ /** Enables offset positioning and lifecycle tracking for a dynamic trigger. */
102
+ anchor?: PopupMenuAnchorOptions | true;
48
103
  };
49
104
  /** Functions returned by {@link usePopupMenu} for the action menu. */
50
105
  export type UsePopupMenuReturn = {
51
106
  /** Opens the menu, or closes it when it is already open. */
52
- handleActionButtonClick: () => void;
107
+ handleActionButtonClick: (...args: unknown[]) => void;
53
108
  /**
54
109
  * Handles keyboard input on the action button. Enter and Space open or close
55
110
  * the menu. Escape closes an open menu or moves focus to the configured target.
56
111
  */
57
- handleActionButtonKeydown: (event: KeyboardEvent) => void;
112
+ handleActionButtonKeydown: (...args: unknown[]) => void;
58
113
  /** Closes the menu when Escape is pressed while using the menu. */
59
114
  handleActionMenuEscape: (event: KeyboardEvent) => void;
60
115
  /**
@@ -67,6 +122,8 @@ export type UsePopupMenuReturn = {
67
122
  * configured focus target; mouse menu selections return focus to the trigger.
68
123
  */
69
124
  handlePopupClose: () => void;
125
+ /** Bind to Kendo Popup's `offset` prop when `anchor` is enabled. */
126
+ offset: ComputedRef<PopupMenuOffset>;
70
127
  };
71
128
  /**
72
129
  * Composable for accessible Kendo Popup + Menu action-menu interactions.
@@ -0,0 +1 @@
1
+ export * from "./useIntersectionObserver";
@@ -0,0 +1,31 @@
1
+ import { type Ref } from "vue";
2
+ export type UseIntersectionObserverOptions = {
3
+ /** Element to observe; the observer reinitializes whenever this ref changes. */
4
+ target: Readonly<Ref<Element | null>>;
5
+ /**
6
+ * Resolves the scroll container that clips the target. Grid consumers must
7
+ * provide the actual scrolling container, such as `.k-grid-content`. The
8
+ * observer reinitializes whenever the resolved root element changes.
9
+ */
10
+ root: () => Element | null;
11
+ /**
12
+ * One or more intersection ratios from `0` through `1`. `0` observes entry
13
+ * and complete departure; `1` observes full visibility. Defaults to `1`.
14
+ */
15
+ threshold?: number | number[];
16
+ /** Called whenever the target's intersection state changes. */
17
+ onChange: (entry: IntersectionObserverEntry) => void;
18
+ };
19
+ export type UseIntersectionObserverReturn = {
20
+ /** Stops observing the current target. Observation resumes if the target ref changes. */
21
+ disconnect: () => void;
22
+ };
23
+ /**
24
+ * Observes a reactive element against a lazily resolved clipping root.
25
+ *
26
+ * By default, the observer uses a threshold of `1`, so consumers are notified
27
+ * when the target is no longer fully visible within its root. The observer
28
+ * reinitializes whenever the target or resolved root changes. If
29
+ * `IntersectionObserver` is unavailable (e.g. SSR), this is a no-op.
30
+ */
31
+ export declare const useIntersectionObserver: (options: UseIntersectionObserverOptions) => UseIntersectionObserverReturn;
@@ -0,0 +1 @@
1
+ export {};
@@ -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 {};
@@ -22,9 +22,10 @@ import { ref } from "vue";
22
22
  import { DatePicker } from "@progress/kendo-vue-dateinputs";
23
23
  import { MaskedTextBox } from "@progress/kendo-vue-inputs";
24
24
  import { Error } from "@progress/kendo-vue-labels";
25
- import { useMaskedDateInput, usePopupTrap } from "@featherk/composables";
25
+ import { useMaskedDateInput } from "@featherk/composables/date";
26
+ import { usePopupTrap } from "@featherk/composables/trap";
26
27
 
27
- const selectedDate = ref<Date | null | undefined>();
28
+ const selectedDate = ref<Date | undefined>();
28
29
  const showPicker = ref(false);
29
30
  const pickerRoot = ref<HTMLElement | null>(null);
30
31
 
@@ -35,7 +36,7 @@ const masked = useMaskedDateInput({
35
36
  id: "startDate",
36
37
  onChange: ({ value }) => { selectedDate.value = value ?? undefined; },
37
38
  onShowCalendar: () => (showPicker.value = true),
38
- externalValue: selectedDate as any,
39
+ externalValue: selectedDate,
39
40
  min: MIN_DATE,
40
41
  max: MAX_DATE,
41
42
  required: true,
@@ -105,6 +106,11 @@ Note: Destructure only the values used as component props that would otherwise r
105
106
 
106
107
  `useMaskedDateInput` can also power a custom masked input inside Kendo `DatePicker` via the `dateInput="masked"` slot. See the full reference in [src/components/custom-date-picker/CustomDatePicker.vue](../src/components/custom-date-picker/CustomDatePicker.vue).
107
108
 
109
+ When binding to Kendo `DatePicker`, use `Ref<Date | undefined>` for the calendar
110
+ `v-model`. The composable's `ChangePayload` uses `Date | null` to represent an
111
+ incomplete or invalid mask, so normalize that payload at the callback boundary with
112
+ `value ?? undefined` before assigning it to the DatePicker model.
113
+
108
114
  ## API
109
115
 
110
116
  ### `useMaskedDateInput(options)`
@@ -0,0 +1,29 @@
1
+ # useCompositeId
2
+
3
+ [Back to Composables README](https://github.com/NantHealth/featherk/blob/integration/packages/composables/README.md)
4
+
5
+ Creates SSR-safe, component-scoped DOM IDs using Vue's `useId()`. Use it for related trigger, popup, menu, and ARIA relationship IDs within one rendered component instance.
6
+
7
+ ## Quick Start
8
+
9
+ 1. Call `useCompositeId()` from component setup.
10
+ 2. Use `instanceId` when one component-level DOM ID is needed.
11
+ 3. Use `compose(...)` for related IDs with stable logical segments.
12
+
13
+ ```ts
14
+ import { useCompositeId } from "@featherk/composables";
15
+
16
+ // Step 1: Vue creates a unique ID for this rendered component instance.
17
+ const ids = useCompositeId("action-cell");
18
+
19
+ // Step 2: use the component-level ID where a single DOM ID is needed.
20
+ const cellId = ids.instanceId;
21
+
22
+ // Step 3: compose related DOM and ARIA relationship IDs.
23
+ const triggerId = ids.compose("patient", "trigger");
24
+ const menuId = ids.compose("patient", "menu");
25
+ ```
26
+
27
+ The default prefix is `fkid`, producing IDs like `fkid-v-0-patient-menu`. Pass a custom prefix when a component-specific DOM prefix improves inspection or debugging.
28
+
29
+ `useCompositeId` is for component-scoped DOM identifiers. Keep deterministic row/trigger IDs used with `useActiveIdRegistry` consumer-owned, for example `patient:42`.
@@ -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 your business action runs while the active menu context is still available, before the composable closes the popup.
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: act while the active context is available; usePopupMenu closes afterward
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 row action logic can use its active state before the popup closes.
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: run row-specific business action before usePopupMenu closes
121
126
  const onSelect = (event: KendoMenuSelectEvent) => {
122
127
  menu.handleMenuSelect(event, (selected) => {
123
128
  // Row-specific action logic remains here.
@@ -127,6 +132,99 @@ 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. Enable `anchor: true` and bind the shared `Popup` to `:offset="menu.offset"`, 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. Use Kendo's `anchorAlign` and `popupAlign` props when the popup should align to a specific edge of its button. Use `anchor: { getRect: () => ... }` for cursor-positioned menus.
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
+ anchor: true,
191
+ requestShow: () => {}, // state is set directly by the row click handler, not by the composable
192
+ requestHide: () => rowRegistry.deactivate(),
193
+ });
194
+
195
+ // Step 2: register each row's element as it renders (e.g. inside a Grid row-render function)
196
+ const registerRowTrigger = (id: number, el: HTMLElement | null) => {
197
+ rowRegistry.register(id, el);
198
+ };
199
+
200
+ // Step 5: toggle logic owns the open/close decision; the composable only reacts
201
+ const toggleRowMenu = (id: number) => {
202
+ if (rowRegistry.isIdActive(id)) {
203
+ rowRegistry.deactivate(); // closes: the reactive watcher demotes aria-expanded
204
+ return;
205
+ }
206
+ rowRegistry.activate(id); // switches directly: previous trigger is demoted, new one promoted
207
+ };
208
+ ```
209
+
210
+ 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:
211
+
212
+ ```ts
213
+ const toggleButtonMenu = (id: number) => {
214
+ if (buttonRegistry.isIdActive(id)) {
215
+ buttonMenu.handleActionButtonClick(); // closes: triggerRef still matches this id
216
+ return;
217
+ }
218
+ if (buttonRegistry.isActive.value) {
219
+ buttonMenu.handleActionButtonClick(); // closes the OTHER trigger correctly, before switching
220
+ }
221
+ buttonRegistry.activate(id); // the reactive watcher promotes the new trigger's aria-expanded
222
+ nextTick(focusFirstMenuItem); // reproduce first-item focus manually; the "open" branch never ran
223
+ };
224
+ ```
225
+
226
+ 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 composable-owned offset tracking. `useActiveIdRegistry` is now part of `@featherk/composables`, so shared-instance consumers can import it directly instead of keeping a demo-local copy.
227
+
130
228
  ## API
131
229
 
132
230
  ### `usePopupMenu(options)`
@@ -141,8 +239,9 @@ const onSelect = (event: KendoMenuSelectEvent) => {
141
239
  - `focusTargetRef?: PopupMenuElementRef`: Optional element/component to focus after a keyboard-driven close.
142
240
  - `resolveFocusTarget?: () => HTMLElement | null`: Dynamic focus-target resolver. It takes precedence over `focusTargetRef` and is useful for virtualized grid rows.
143
241
  - `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.
242
+ - `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.
243
+ - `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.
244
+ - `anchor?: true | PopupMenuAnchorOptions`: Enables document-relative `offset` tracking for dynamically retargeted Popups. `true` uses the resolved trigger's rectangle and nearest scrollable ancestor. `PopupMenuAnchorOptions` accepts `clipRoot`, `hideWhenAnchorClipped`, and `getRect` for pointer-positioned menus. Internally, the anchor-hidden policy uses `useIntersectionObserver` with `threshold: 1`; a clipped trigger closes with `"anchor-hidden"` without restoring focus.
146
245
 
147
246
  `PopupMenuElementRef` accepts a normal `HTMLElement` ref or a Vue/Kendo component ref that exposes `$el`.
148
247
 
@@ -153,6 +252,7 @@ const onSelect = (event: KendoMenuSelectEvent) => {
153
252
  - `handleActionMenuEscape(event)`: Closes an open menu with keyboard modality.
154
253
  - `handleMenuSelect(event, onAction?)`: Records the selection modality, requests close, then invokes optional business logic.
155
254
  - `handlePopupClose()`: Restores focus after Kendo Popup has closed.
255
+ - `offset`: A computed `{ left, top }` value for Kendo Popup's `:offset`; it is `{ left: 0, top: 0 }` while closed.
156
256
 
157
257
  #### Types
158
258
 
@@ -162,6 +262,7 @@ export type PopupMenuCloseReason =
162
262
  | "mouse-selection"
163
263
  | "outside-click"
164
264
  | "trigger-click"
265
+ | "anchor-hidden"
165
266
  | null;
166
267
  export type PopupMenuCloseTrigger = Exclude<PopupMenuCloseReason, null>;
167
268
  export type KendoMenuSelectEvent = {
@@ -173,14 +274,16 @@ export type KendoMenuSelectEvent = {
173
274
  ## Behavior
174
275
 
175
276
  - 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.
277
+ - `aria-haspopup="menu"` is a static, consumer-owned template attribute; the composable never sets or removes it.
278
+ - `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.
279
+ - 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.
280
+ - 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
281
  - Opening focuses the first enabled Kendo Menu item after Vue renders it.
180
282
  - Outside clicks close the menu, while the trigger is ignored to prevent a close/reopen race.
181
- - Menu selection records keyboard or mouse modality from the selection event type.
283
+ - Menu selection runs the optional business callback while the active state is still available, then records modality and closes the popup.
182
284
  - `Escape` on the trigger or inside the menu uses the keyboard close path.
183
285
  - Keyboard-driven closes focus `resolveFocusTarget()` or `focusTargetRef`, then fall back to the trigger.
184
286
  - Mouse selection closes restore focus to the trigger.
185
287
  - Outside-click and trigger-click closes do not force focus restoration.
288
+ - An anchor-hidden close does not restore focus, preventing an off-screen grid row from being scrolled back into view.
186
289
  - Business-specific actions are never implemented by the composable.