@featherk/composables 0.12.4 → 0.13.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.
- package/README.md +5 -0
- package/dist/docs-manifest.d.ts +5 -0
- package/dist/docs-manifest.js +27 -17
- package/dist/featherk-composables.es.js +1242 -1197
- package/dist/featherk-composables.es.js.map +1 -1
- package/dist/featherk-composables.umd.js +1 -1
- package/dist/featherk-composables.umd.js.map +1 -1
- package/dist/menu/usePopupMenu.d.ts +46 -11
- package/docs/menu/usePopupMenu.md +52 -25
- package/docs/menu/usePopupMenuActivationAnchor.md +132 -0
- package/docs/menu/usePopupMenuFocusAndExclusivity.md +105 -0
- package/docs/menu/usePopupMenuGridOrchestration.md +480 -0
- package/docs/menu/usePopupMenuSharedInstance.md +106 -0
- package/docs/menu/usePopupMenuUpgrade0130.md +115 -0
- package/package.json +1 -1
|
@@ -6,17 +6,43 @@ export type PopupMenuOffset = {
|
|
|
6
6
|
left: number;
|
|
7
7
|
top: number;
|
|
8
8
|
};
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export type PopupMenuAnchorActivation = {
|
|
10
|
+
triggerType: "click" | "keyboard";
|
|
11
|
+
coordinates?: {
|
|
12
|
+
clientX: number;
|
|
13
|
+
clientY: number;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type PopupMenuActivationPointOptions = {
|
|
17
|
+
/** Added to pointer coordinates after they are made trigger-relative. */
|
|
18
|
+
pointerOffset?: {
|
|
19
|
+
x?: number;
|
|
20
|
+
y?: number;
|
|
21
|
+
};
|
|
22
|
+
/** Trigger-relative keyboard position. Defaults to 8px from the left and vertically centered. */
|
|
23
|
+
keyboardPosition?: {
|
|
24
|
+
x?: number;
|
|
25
|
+
y?: number | "top" | "center" | "bottom";
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
type PopupMenuAnchorBaseOptions = {
|
|
11
29
|
/** Scrollable/clipping container used for out-of-view dismissal. */
|
|
12
30
|
clipRoot?: MaybeRefOrGetter<Element | null>;
|
|
13
31
|
/** Closes the menu after its trigger leaves the clipping container. */
|
|
14
32
|
hideWhenAnchorClipped?: boolean;
|
|
15
|
-
/** Intersection ratio required to keep the anchor considered visible.
|
|
33
|
+
/** Intersection ratio required to keep the anchor considered visible. */
|
|
16
34
|
intersectionThreshold?: number | number[];
|
|
17
|
-
/** Overrides the trigger rect for pointer-positioned menus. */
|
|
18
|
-
getRect?: () => DOMRect | null;
|
|
19
35
|
};
|
|
36
|
+
/** Offset-positioning options for a Popup with a dynamically changing trigger. */
|
|
37
|
+
export type PopupMenuAnchorOptions = PopupMenuAnchorBaseOptions & ({
|
|
38
|
+
/** Tracks a pointer or keyboard activation point relative to the trigger. */
|
|
39
|
+
activationPoint: true | PopupMenuActivationPointOptions;
|
|
40
|
+
getRect?: never;
|
|
41
|
+
} | {
|
|
42
|
+
activationPoint?: never;
|
|
43
|
+
/** Resolves a custom anchor rect from the current trigger element. */
|
|
44
|
+
getRect?: (trigger: HTMLElement) => DOMRect | null;
|
|
45
|
+
});
|
|
20
46
|
/** Why the popup was closed, including the focus policy for mouse paths. */
|
|
21
47
|
export type PopupMenuCloseReason =
|
|
22
48
|
/** Keyboard-driven menu selection (Enter/Space on a menu item); restores the configured focus target. */
|
|
@@ -48,8 +74,8 @@ export type KendoMenuSelectEvent<TItem = {
|
|
|
48
74
|
};
|
|
49
75
|
/** Options for configuring {@link usePopupMenu}. */
|
|
50
76
|
export type UsePopupMenuOptions = {
|
|
51
|
-
/**
|
|
52
|
-
isOpen: Ref<boolean
|
|
77
|
+
/** Read-only reactive flag reflecting whether the popup menu is currently open. */
|
|
78
|
+
isOpen: Readonly<Ref<boolean>>;
|
|
53
79
|
/** Ref to the popup menu element or component. Used for outside-click detection. */
|
|
54
80
|
menuRef: PopupMenuElementRef;
|
|
55
81
|
/** Ref to the trigger button element or component. Ignored by outside-click detection. */
|
|
@@ -68,6 +94,12 @@ export type UsePopupMenuOptions = {
|
|
|
68
94
|
* Takes precedence over `focusTargetRef`.
|
|
69
95
|
*/
|
|
70
96
|
resolveFocusTarget?: () => HTMLElement | null;
|
|
97
|
+
/**
|
|
98
|
+
* Resolves the closest matching ancestor of the trigger as the configured
|
|
99
|
+
* keyboard-close focus target. Used after `resolveFocusTarget` and
|
|
100
|
+
* `focusTargetRef`.
|
|
101
|
+
*/
|
|
102
|
+
focusTargetContainerSelector?: string;
|
|
71
103
|
/**
|
|
72
104
|
* Resolves a fallback focus target for Escape closes when no `focusTargetRef`/
|
|
73
105
|
* `resolveFocusTarget` is configured, e.g. the trigger's containing grid row so
|
|
@@ -110,15 +142,17 @@ export type UsePopupMenuOptions = {
|
|
|
110
142
|
};
|
|
111
143
|
/** Functions returned by {@link usePopupMenu} for the action menu. */
|
|
112
144
|
export type UsePopupMenuReturn = {
|
|
145
|
+
/** Captures an activation relative to a trigger for activation-point anchoring. */
|
|
146
|
+
setAnchorActivation: (trigger: HTMLElement | ComponentPublicInstance | null, activation: PopupMenuAnchorActivation) => boolean;
|
|
113
147
|
/** Opens the menu, or closes it when it is already open. */
|
|
114
|
-
|
|
148
|
+
handleTriggerClick: (...args: unknown[]) => void;
|
|
115
149
|
/**
|
|
116
|
-
* Handles keyboard input on the
|
|
150
|
+
* Handles keyboard input on the trigger. Enter and Space open or close
|
|
117
151
|
* the menu. Escape closes an open menu or moves focus to the configured target.
|
|
118
152
|
*/
|
|
119
|
-
|
|
153
|
+
handleTriggerKeydown: (...args: unknown[]) => void;
|
|
120
154
|
/** Closes the menu when Escape is pressed while using the menu. */
|
|
121
|
-
|
|
155
|
+
handleMenuEscape: (event: KeyboardEvent) => void;
|
|
122
156
|
/**
|
|
123
157
|
* Closes the menu after an item is chosen, then runs the optional action
|
|
124
158
|
* provided by the consuming component.
|
|
@@ -143,3 +177,4 @@ export type UsePopupMenuReturn = {
|
|
|
143
177
|
* @returns Template event handlers for the action trigger, Kendo Menu, and Popup.
|
|
144
178
|
*/
|
|
145
179
|
export declare const usePopupMenu: (options: UsePopupMenuOptions) => UsePopupMenuReturn;
|
|
180
|
+
export {};
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[Back to Composables README](https://github.com/NantHealth/featherk/blob/integration/packages/composables/README.md)
|
|
4
4
|
|
|
5
|
+
New to coordinating row and button menus in a Kendo Grid? Start with [Grid Popup Menu Orchestration](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuGridOrchestration.md).
|
|
6
|
+
|
|
5
7
|
Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It manages trigger toggling, first-item focus, outside clicks, input-modality tracking, and focus restoration. It does not trap focus or own popup state; keep `usePopupTrap` separate when focus trapping or generic popup lifecycle behavior is needed.
|
|
6
8
|
|
|
7
9
|
## Quick Start
|
|
@@ -22,8 +24,8 @@ Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It man
|
|
|
22
24
|
type="button"
|
|
23
25
|
aria-haspopup="menu"
|
|
24
26
|
aria-expanded="false"
|
|
25
|
-
@click="menu.
|
|
26
|
-
@keydown="menu.
|
|
27
|
+
@click="menu.handleTriggerClick"
|
|
28
|
+
@keydown="menu.handleTriggerKeydown"
|
|
27
29
|
>
|
|
28
30
|
Actions
|
|
29
31
|
</button>
|
|
@@ -35,7 +37,7 @@ Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It man
|
|
|
35
37
|
</strong>
|
|
36
38
|
<Menu
|
|
37
39
|
ref="menuRef"
|
|
38
|
-
@keydown.escape="menu.
|
|
40
|
+
@keydown.escape="menu.handleMenuEscape"
|
|
39
41
|
@select="onSelect"
|
|
40
42
|
/>
|
|
41
43
|
</Popup>
|
|
@@ -80,13 +82,13 @@ const onSelect = (event: KendoMenuSelectEvent) => {
|
|
|
80
82
|
|
|
81
83
|
## Grid Action Cell
|
|
82
84
|
|
|
83
|
-
For a virtualized Kendo Grid, use `
|
|
85
|
+
For a virtualized Kendo Grid, use `focusTargetContainerSelector` to restore keyboard focus to the trigger's current row without reaching through a registry.
|
|
84
86
|
|
|
85
87
|
1. Add static `aria-haspopup="menu"` and `aria-expanded="false"` to the row's trigger button template.
|
|
86
88
|
2. Read `showMenu` from the row item and keep open/close state parent-owned.
|
|
87
89
|
3. Create trigger and menu refs as component refs (`ComponentPublicInstance`) for Kendo wrappers.
|
|
88
90
|
4. Call `usePopupMenu(...)` with an explicit `triggerMode: "button"` and map `requestShow`/`requestHide` to row-specific emits.
|
|
89
|
-
5. Provide `
|
|
91
|
+
5. Provide `focusTargetContainerSelector` so keyboard close restores focus to the current virtualized row.
|
|
90
92
|
6. Route menu selection through `handleMenuSelect(event, onAction)` so row action logic can use its active state before the popup closes.
|
|
91
93
|
|
|
92
94
|
```ts
|
|
@@ -118,13 +120,8 @@ const menu = usePopupMenu({
|
|
|
118
120
|
triggerMode: "button",
|
|
119
121
|
requestShow: () => emit("update:showMenu", props.dataItem.id),
|
|
120
122
|
requestHide: () => emit("update:hideMenu", props.dataItem.id),
|
|
121
|
-
// Step 5: keyboard close restores focus to
|
|
122
|
-
|
|
123
|
-
const trigger = triggerRef.value?.$el as HTMLElement | undefined;
|
|
124
|
-
return (
|
|
125
|
-
trigger?.closest(".k-table-row[data-grid-row-index]") ?? null
|
|
126
|
-
) as HTMLElement | null;
|
|
127
|
-
},
|
|
123
|
+
// Step 5: keyboard close restores focus to the trigger's current row
|
|
124
|
+
focusTargetContainerSelector: ".k-table-row[data-grid-row-index]",
|
|
128
125
|
});
|
|
129
126
|
|
|
130
127
|
// Step 6: run row-specific business action before usePopupMenu closes
|
|
@@ -149,7 +146,9 @@ A grid row can have both a row-level context menu (`triggerMode: "row"`) and a n
|
|
|
149
146
|
|
|
150
147
|
```ts
|
|
151
148
|
// Step 2-4: independent instances, independent triggerRef, explicit triggerMode
|
|
152
|
-
const rowTriggerRef = computed(
|
|
149
|
+
const rowTriggerRef = computed(
|
|
150
|
+
() => cellRef.value?.closest(".k-table-row") as HTMLElement | null,
|
|
151
|
+
);
|
|
153
152
|
|
|
154
153
|
const buttonMenu = usePopupMenu({
|
|
155
154
|
isOpen: computed(() => props.dataItem.showButtonMenu),
|
|
@@ -170,7 +169,7 @@ const rowMenu = usePopupMenu({
|
|
|
170
169
|
});
|
|
171
170
|
```
|
|
172
171
|
|
|
173
|
-
|
|
172
|
+
For a complete shared-instance implementation, see [Shared Instance Menus](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuSharedInstance.md).
|
|
174
173
|
|
|
175
174
|
## Sharing One Instance Across Many Triggers (Grid-Wide)
|
|
176
175
|
|
|
@@ -179,8 +178,8 @@ A single `usePopupMenu` instance can manage an entire list of same-mode triggers
|
|
|
179
178
|
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.
|
|
180
179
|
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.
|
|
181
180
|
3. Feed `isOpen: registry.isActive` and `triggerRef: registry.activeElement` straight into `usePopupMenu`, rather than deriving them from a per-row prop.
|
|
182
|
-
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
|
|
183
|
-
5. Drive show/hide through `registry.activate(id)` / `registry.deactivate()` rather than binding `
|
|
181
|
+
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 `anchor.activationPoint` for pointer/keyboard context menus and `getRect(trigger)` only for custom geometry.
|
|
182
|
+
5. Drive show/hide through `registry.activate(id)` / `registry.deactivate()` rather than binding `handleTriggerClick` directly to every shared trigger. A single instance's `isOpen` is one shared boolean, so the generic toggle handler cannot distinguish closing the current id from opening another id.
|
|
184
183
|
|
|
185
184
|
```ts
|
|
186
185
|
// Step 1: one registry tracks the active id and resolves it to its DOM element
|
|
@@ -212,23 +211,49 @@ const toggleRowMenu = (id: number) => {
|
|
|
212
211
|
};
|
|
213
212
|
```
|
|
214
213
|
|
|
215
|
-
|
|
214
|
+
### Cursor-Positioned Row Menus That Follow Scrolling
|
|
215
|
+
|
|
216
|
+
Enable `anchor.activationPoint`, call `setAnchorActivation(row, context)` before activating the shared row id, and bind `offset` to the Popup. The composable owns trigger-relative coordinate conversion, keyboard placement, scroll reconstruction, clipping, and cleanup.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const rowMenu = usePopupMenu({
|
|
220
|
+
isOpen: rowRegistry.isActive,
|
|
221
|
+
triggerRef: rowRegistry.activeElement,
|
|
222
|
+
menuRef: rowMenuRef,
|
|
223
|
+
triggerMode: "row",
|
|
224
|
+
anchor: {
|
|
225
|
+
activationPoint: { pointerOffset: { x: -20, y: -20 } },
|
|
226
|
+
},
|
|
227
|
+
requestShow: () => {},
|
|
228
|
+
requestHide: () => rowRegistry.deactivate(),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const openRowMenu = (id: number, context: RowActionContext) => {
|
|
232
|
+
const row = rowRegistry.resolve(id);
|
|
233
|
+
if (!row || !rowMenu.setAnchorActivation(row, context)) return;
|
|
234
|
+
rowRegistry.activate(id);
|
|
235
|
+
};
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
See [Activation-Point Anchors](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuActivationAnchor.md) for the complete standalone setup and custom keyboard placement.
|
|
239
|
+
|
|
240
|
+
For a second shared instance driven by a real `@click` handler on each trigger, close the currently active trigger explicitly before switching the active id, so its `triggerRef` still resolves to the correct element at close time:
|
|
216
241
|
|
|
217
242
|
```ts
|
|
218
243
|
const toggleButtonMenu = (id: number) => {
|
|
219
244
|
if (buttonRegistry.isIdActive(id)) {
|
|
220
|
-
buttonMenu.
|
|
245
|
+
buttonMenu.handleTriggerClick(); // closes: triggerRef still matches this id
|
|
221
246
|
return;
|
|
222
247
|
}
|
|
223
248
|
if (buttonRegistry.isActive.value) {
|
|
224
|
-
buttonMenu.
|
|
249
|
+
buttonMenu.handleTriggerClick(); // closes the OTHER trigger correctly, before switching
|
|
225
250
|
}
|
|
226
251
|
buttonRegistry.activate(id); // the reactive watcher promotes the new trigger's aria-expanded
|
|
227
252
|
nextTick(focusFirstMenuItem); // reproduce first-item focus manually; the "open" branch never ran
|
|
228
253
|
};
|
|
229
254
|
```
|
|
230
255
|
|
|
231
|
-
See [
|
|
256
|
+
See [Focus and Exclusivity](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuFocusAndExclusivity.md) for complete focus precedence and multi-menu coordination examples.
|
|
232
257
|
|
|
233
258
|
## API
|
|
234
259
|
|
|
@@ -236,26 +261,28 @@ See [UsePopupMenu.vue](https://github.com/NantHealth/featherk/blob/integration/d
|
|
|
236
261
|
|
|
237
262
|
#### Options
|
|
238
263
|
|
|
239
|
-
- `isOpen: Ref<boolean
|
|
264
|
+
- `isOpen: Readonly<Ref<boolean>>`: Parent-owned popup visibility state observed, but never mutated, by the composable.
|
|
240
265
|
- `menuRef: PopupMenuElementRef`: Ref to the Kendo `Menu` element or component.
|
|
241
266
|
- `triggerRef: PopupMenuElementRef`: Ref to the action trigger element or component.
|
|
242
267
|
- `requestShow(): void`: Opens the parent-owned popup state.
|
|
243
268
|
- `requestHide(): void`: Closes the parent-owned popup state.
|
|
244
269
|
- `focusTargetRef?: PopupMenuElementRef`: Optional element/component to focus after a keyboard-driven close.
|
|
245
270
|
- `resolveFocusTarget?: () => HTMLElement | null`: Dynamic focus-target resolver. It takes precedence over `focusTargetRef` and is useful for virtualized grid rows.
|
|
271
|
+
- `focusTargetContainerSelector?: string`: Closest trigger ancestor to focus after any keyboard-driven close. Used after `resolveFocusTarget` and `focusTargetRef`.
|
|
246
272
|
- `menuItemSelector?: string`: Selector for the first enabled item. Defaults to `.k-menu-item:not(.k-disabled)`.
|
|
247
273
|
- `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.
|
|
248
274
|
- `menuLabel?: MaybeRefOrGetter<string | null | undefined>`: Optional accessible name applied as `aria-label` to the rendered `ul[role="menubar"]`. Empty labels remove the managed attribute.
|
|
249
275
|
- `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.
|
|
250
|
-
- `anchor?: true | PopupMenuAnchorOptions`: Enables document-relative
|
|
276
|
+
- `anchor?: true | PopupMenuAnchorOptions`: Enables document-relative offset tracking. `true` follows the trigger's bottom-left corner. `activationPoint` captures pointer/keyboard positions relative to the trigger and defaults its clipping threshold to `0`; `getRect(trigger)` remains the mutually exclusive custom-geometry escape hatch. Without `clipRoot`, the composable prefers the nearest overflow-enabled ancestor that can actually scroll, skipping styled wrappers with no overflow. Other anchors default to threshold `0.5`. A fully clipped trigger closes with `"anchor-hidden"` without restoring focus.
|
|
251
277
|
|
|
252
278
|
`PopupMenuElementRef` accepts a normal `HTMLElement` ref or a Vue/Kendo component ref that exposes `$el`.
|
|
253
279
|
|
|
254
280
|
#### Returns
|
|
255
281
|
|
|
256
|
-
- `
|
|
257
|
-
- `
|
|
258
|
-
- `
|
|
282
|
+
- `setAnchorActivation(trigger, activation)`: Captures a pointer or keyboard activation for `anchor.activationPoint`; returns `false` when activation anchoring or a trigger is unavailable.
|
|
283
|
+
- `handleTriggerClick()`: Toggles the menu and focuses the first enabled item after opening.
|
|
284
|
+
- `handleTriggerKeydown(event)`: `Enter` and `Space` toggle; `Escape` closes, or focuses the configured target when already closed.
|
|
285
|
+
- `handleMenuEscape(event)`: Closes an open menu with keyboard modality.
|
|
259
286
|
- `handleMenuSelect(event, onAction?)`: Records the selection modality, requests close, then invokes optional business logic.
|
|
260
287
|
- `handlePopupClose()`: Restores focus after Kendo Popup has closed.
|
|
261
288
|
- `offset`: A computed `{ left, top }` value for Kendo Popup's `:offset`; it is `{ left: 0, top: 0 }` while closed.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# usePopupMenu Activation-Point Anchors
|
|
2
|
+
|
|
3
|
+
[Back to usePopupMenu](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenu.md)
|
|
4
|
+
|
|
5
|
+
Use an activation-point anchor when a context menu must open near a pointer or at a predictable keyboard position and continue following its trigger during scrolling.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
1. Register each trigger element by id.
|
|
10
|
+
2. Enable `anchor.activationPoint` and bind `menu.offset` to the Popup.
|
|
11
|
+
3. Capture the activation against the trigger before activating its id.
|
|
12
|
+
4. Keep visibility parent-owned through the registry.
|
|
13
|
+
|
|
14
|
+
```vue
|
|
15
|
+
<template>
|
|
16
|
+
<table>
|
|
17
|
+
<tbody>
|
|
18
|
+
<tr
|
|
19
|
+
v-for="row in rows"
|
|
20
|
+
:key="row.id"
|
|
21
|
+
:ref="(element) => registerRow(row.id, element)"
|
|
22
|
+
class="k-table-row"
|
|
23
|
+
tabindex="0"
|
|
24
|
+
aria-haspopup="menu"
|
|
25
|
+
aria-expanded="false"
|
|
26
|
+
@click="openFromPointer(row.id, $event)"
|
|
27
|
+
@keydown.enter.prevent="openFromKeyboard(row.id)"
|
|
28
|
+
@keydown.space.prevent="openFromKeyboard(row.id)"
|
|
29
|
+
>
|
|
30
|
+
<td>{{ row.name }}</td>
|
|
31
|
+
</tr>
|
|
32
|
+
</tbody>
|
|
33
|
+
</table>
|
|
34
|
+
|
|
35
|
+
<Popup
|
|
36
|
+
:show="rowRegistry.isActive.value"
|
|
37
|
+
:offset="menu.offset.value"
|
|
38
|
+
@close="menu.handlePopupClose"
|
|
39
|
+
>
|
|
40
|
+
<Menu
|
|
41
|
+
ref="menuRef"
|
|
42
|
+
:items="items"
|
|
43
|
+
:vertical="true"
|
|
44
|
+
@keydown.escape="menu.handleMenuEscape"
|
|
45
|
+
@select="menu.handleMenuSelect"
|
|
46
|
+
/>
|
|
47
|
+
</Popup>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<script setup lang="ts">
|
|
51
|
+
import { ref, type ComponentPublicInstance } from "vue";
|
|
52
|
+
import { Popup } from "@progress/kendo-vue-popup";
|
|
53
|
+
import { Menu } from "@progress/kendo-vue-layout";
|
|
54
|
+
import { usePopupMenu } from "@featherk/composables/menu";
|
|
55
|
+
import { useActiveIdRegistry } from "@featherk/composables/registry";
|
|
56
|
+
|
|
57
|
+
const rows = [
|
|
58
|
+
{ id: 1, name: "Alpha" },
|
|
59
|
+
{ id: 2, name: "Beta" },
|
|
60
|
+
];
|
|
61
|
+
const items = [{ text: "Open" }, { text: "Archive" }];
|
|
62
|
+
const rowRegistry = useActiveIdRegistry<number>();
|
|
63
|
+
const menuRef = ref<ComponentPublicInstance | null>(null);
|
|
64
|
+
|
|
65
|
+
// Step 2: pointer coordinates receive 12px of visual clearance. Keyboard
|
|
66
|
+
// activation defaults to x=8 and the trigger's vertical center.
|
|
67
|
+
const menu = usePopupMenu({
|
|
68
|
+
isOpen: rowRegistry.isActive,
|
|
69
|
+
triggerRef: rowRegistry.activeElement,
|
|
70
|
+
menuRef,
|
|
71
|
+
triggerMode: "row",
|
|
72
|
+
anchor: {
|
|
73
|
+
activationPoint: { pointerOffset: { x: -12, y: -12 } },
|
|
74
|
+
},
|
|
75
|
+
requestShow: () => {},
|
|
76
|
+
requestHide: () => rowRegistry.deactivate(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Step 1: the registry resolves an id to the current DOM element, including
|
|
80
|
+
// after keyed or virtualized rendering replaces it.
|
|
81
|
+
const registerRow = (id: number, element: unknown) => {
|
|
82
|
+
rowRegistry.register(id, element as HTMLElement | null);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const activate = (
|
|
86
|
+
id: number,
|
|
87
|
+
activation: Parameters<typeof menu.setAnchorActivation>[1],
|
|
88
|
+
) => {
|
|
89
|
+
const row = rowRegistry.resolve(id);
|
|
90
|
+
// Step 3: capture before activation because triggerRef is still null or may
|
|
91
|
+
// still resolve to the previously active row.
|
|
92
|
+
if (!row || !menu.setAnchorActivation(row, activation)) return;
|
|
93
|
+
|
|
94
|
+
// Step 4: the registry remains the owner of open state.
|
|
95
|
+
rowRegistry.activate(id);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const openFromPointer = (id: number, event: MouseEvent) => {
|
|
99
|
+
activate(id, {
|
|
100
|
+
triggerType: "click",
|
|
101
|
+
coordinates: { clientX: event.clientX, clientY: event.clientY },
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const openFromKeyboard = (id: number) => {
|
|
106
|
+
activate(id, { triggerType: "keyboard" });
|
|
107
|
+
};
|
|
108
|
+
</script>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Keyboard Position
|
|
112
|
+
|
|
113
|
+
The default keyboard point is 8px inside the trigger's left edge and vertically centered. Override it when needed:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
anchor: {
|
|
117
|
+
activationPoint: {
|
|
118
|
+
keyboardPosition: { x: 12, y: "bottom" },
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`y` accepts a number or `"top"`, `"center"`, or `"bottom"`.
|
|
124
|
+
|
|
125
|
+
## Behavior
|
|
126
|
+
|
|
127
|
+
- Pointer coordinates are converted to a trigger-relative point once.
|
|
128
|
+
- Scroll and resize measurements reconstruct that point from the trigger's current rectangle.
|
|
129
|
+
- The clipping threshold defaults to `0`; the menu stays open while any part of the trigger remains visible.
|
|
130
|
+
- A fully clipped trigger closes with `"anchor-hidden"` without restoring focus.
|
|
131
|
+
- Activation metadata is cleared whenever `isOpen` becomes false.
|
|
132
|
+
- Use custom `getRect(trigger)` instead when positioning does not originate from a pointer or keyboard activation.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# usePopupMenu Focus and Exclusivity
|
|
2
|
+
|
|
3
|
+
[Back to usePopupMenu](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenu.md)
|
|
4
|
+
|
|
5
|
+
This guide covers keyboard focus restoration and coordination between independently configured popup menus.
|
|
6
|
+
|
|
7
|
+
## Focus Target Precedence
|
|
8
|
+
|
|
9
|
+
For keyboard menu selection and Escape, configured focus targets resolve in this order:
|
|
10
|
+
|
|
11
|
+
1. `resolveFocusTarget()`
|
|
12
|
+
2. `focusTargetRef`
|
|
13
|
+
3. `focusTargetContainerSelector`, resolved with `trigger.closest(selector)`
|
|
14
|
+
4. For Escape only: `escapeFocusTarget()`
|
|
15
|
+
5. For Escape only: `escapeFocusContainerSelector`
|
|
16
|
+
6. The trigger
|
|
17
|
+
|
|
18
|
+
Use the selector option for the common case where a button menu should return keyboard focus to its containing row or toolbar group:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const menu = usePopupMenu({
|
|
22
|
+
isOpen,
|
|
23
|
+
triggerRef,
|
|
24
|
+
menuRef,
|
|
25
|
+
triggerMode: "button",
|
|
26
|
+
focusTargetContainerSelector: ".k-table-row",
|
|
27
|
+
requestShow: () => (isOpen.value = true),
|
|
28
|
+
requestHide: () => (isOpen.value = false),
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The target is captured before `requestHide()` runs, so it remains available when shared state immediately clears `triggerRef`.
|
|
33
|
+
|
|
34
|
+
## Close Behavior
|
|
35
|
+
|
|
36
|
+
| Close path | Focus behavior |
|
|
37
|
+
| ----------------------- | ------------------------------------------------ |
|
|
38
|
+
| Keyboard menu selection | Configured focus target, then trigger |
|
|
39
|
+
| Escape | Configured target, Escape fallback, then trigger |
|
|
40
|
+
| Pointer menu selection | Trigger |
|
|
41
|
+
| Outside click | Browser retains control |
|
|
42
|
+
| Trigger click | Browser retains control |
|
|
43
|
+
| Anchor fully clipped | No focus restoration |
|
|
44
|
+
|
|
45
|
+
## Multiple Exclusive Menus
|
|
46
|
+
|
|
47
|
+
Keep exclusivity outside `usePopupMenu`. `useExclusiveGroup` coordinates registries without coupling popup lifecycle to application state.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { ref } from "vue";
|
|
51
|
+
import { usePopupMenu } from "@featherk/composables/menu";
|
|
52
|
+
import {
|
|
53
|
+
useActiveIdRegistry,
|
|
54
|
+
useExclusiveGroup,
|
|
55
|
+
} from "@featherk/composables/registry";
|
|
56
|
+
|
|
57
|
+
const group = useExclusiveGroup();
|
|
58
|
+
const rowRegistry = useActiveIdRegistry<number>();
|
|
59
|
+
const buttonRegistry = useActiveIdRegistry<number>();
|
|
60
|
+
const rowMenuRef = ref<HTMLElement | null>(null);
|
|
61
|
+
const buttonMenuRef = ref<HTMLElement | null>(null);
|
|
62
|
+
|
|
63
|
+
group.register(rowRegistry);
|
|
64
|
+
group.register(buttonRegistry);
|
|
65
|
+
|
|
66
|
+
const rowMenu = usePopupMenu({
|
|
67
|
+
isOpen: rowRegistry.isActive,
|
|
68
|
+
triggerRef: rowRegistry.activeElement,
|
|
69
|
+
menuRef: rowMenuRef,
|
|
70
|
+
triggerMode: "row",
|
|
71
|
+
anchor: { activationPoint: true },
|
|
72
|
+
requestShow: () => {},
|
|
73
|
+
requestHide: () => rowRegistry.deactivate(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const buttonMenu = usePopupMenu({
|
|
77
|
+
isOpen: buttonRegistry.isActive,
|
|
78
|
+
triggerRef: buttonRegistry.activeElement,
|
|
79
|
+
menuRef: buttonMenuRef,
|
|
80
|
+
triggerMode: "button",
|
|
81
|
+
anchor: true,
|
|
82
|
+
focusTargetContainerSelector: ".k-table-row",
|
|
83
|
+
requestShow: () => {},
|
|
84
|
+
requestHide: () => buttonRegistry.deactivate(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const openRowMenu = (id: number, row: HTMLElement, event: MouseEvent) => {
|
|
88
|
+
group.deactivateOthers(rowRegistry);
|
|
89
|
+
if (
|
|
90
|
+
rowMenu.setAnchorActivation(row, {
|
|
91
|
+
triggerType: "click",
|
|
92
|
+
coordinates: { clientX: event.clientX, clientY: event.clientY },
|
|
93
|
+
})
|
|
94
|
+
) {
|
|
95
|
+
rowRegistry.activate(id);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const openButtonMenu = (id: number) => {
|
|
100
|
+
group.deactivateOthers(buttonRegistry);
|
|
101
|
+
buttonRegistry.activate(id);
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`useExclusiveGroup` changes only active registry state. Each `usePopupMenu` instance continues to own its own ARIA, positioning, close reason, and focus behavior.
|