@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.
- package/README.md +6 -0
- package/dist/featherk-composables.es.js +1226 -1104
- 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/id/index.d.ts +2 -0
- package/dist/id/useCompositeId.d.ts +17 -0
- package/dist/id/useCompositeId.test.d.ts +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/menu/usePopupMenu.d.ts +68 -11
- package/dist/observer/index.d.ts +1 -0
- package/dist/observer/useIntersectionObserver.d.ts +31 -0
- package/dist/observer/useIntersectionObserver.test.d.ts +1 -0
- package/dist/registry/index.d.ts +2 -0
- package/dist/registry/useActiveIdRegistry.d.ts +35 -0
- package/dist/registry/useActiveIdRegistry.test.d.ts +1 -0
- package/docs/date/useMaskedDateInput.md +9 -3
- package/docs/id/useCompositeId.md +29 -0
- package/docs/menu/usePopupMenu.md +129 -26
- package/docs/observer/useIntersectionObserver.md +95 -0
- package/docs/registry/useActiveIdRegistry.md +136 -0
- package/package.json +16 -1
|
@@ -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 =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
47
|
-
|
|
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: (
|
|
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,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
|
|
25
|
+
import { useMaskedDateInput } from "@featherk/composables/date";
|
|
26
|
+
import { usePopupTrap } from "@featherk/composables/trap";
|
|
26
27
|
|
|
27
|
-
const selectedDate = ref<Date |
|
|
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
|
|
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.
|
|
10
|
-
2.
|
|
11
|
-
3.
|
|
12
|
-
4.
|
|
13
|
-
5. Bind
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
78
|
-
2.
|
|
79
|
-
3.
|
|
80
|
-
4.
|
|
81
|
-
5.
|
|
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
|
|
101
|
+
// Step 2: read row-owned open state from the data item
|
|
98
102
|
const isOpen = computed(() => props.dataItem.showMenu);
|
|
99
103
|
|
|
100
|
-
// Step
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
145
|
-
- `triggerMode
|
|
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"`
|
|
177
|
-
-
|
|
178
|
-
-
|
|
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
|
|
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.
|