@featherk/composables 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docs-manifest.js +22 -22
- package/dist/featherk-composables.es.js +1421 -1307
- 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/geometry/computeFlippedOffset.d.ts +40 -0
- package/dist/geometry/computeFlippedOffset.test.d.ts +1 -0
- package/dist/geometry/index.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/menu/usePopupMenu.d.ts +31 -0
- package/docs/menu/usePopupMenu.md +86 -1
- package/docs/menu/usePopupMenuGridOrchestration.md +40 -0
- package/docs/trap/usePopupTrap.md +1 -1
- package/package.json +6 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** A corner/center alignment pair, independent of any specific composable. */
|
|
2
|
+
export type GeometryAlign = {
|
|
3
|
+
horizontal: "left" | "center" | "right";
|
|
4
|
+
vertical: "top" | "center" | "bottom";
|
|
5
|
+
};
|
|
6
|
+
/** A rectangular clipping region (viewport or a scrollable container's rect). */
|
|
7
|
+
export type GeometryBounds = {
|
|
8
|
+
left: number;
|
|
9
|
+
top: number;
|
|
10
|
+
right: number;
|
|
11
|
+
bottom: number;
|
|
12
|
+
};
|
|
13
|
+
export type ComputeFlippedOffsetInput = {
|
|
14
|
+
/** The anchor rect (or a zero-size point rect for a click/keyboard activation). */
|
|
15
|
+
anchor: DOMRect;
|
|
16
|
+
/** The popup's own measured size, or `null` before it has been measured. */
|
|
17
|
+
popup: DOMRect | null;
|
|
18
|
+
/** Point on the anchor that the popup is positioned from. */
|
|
19
|
+
anchorAlign: GeometryAlign;
|
|
20
|
+
/** Point on the popup that is placed at the computed anchor point. */
|
|
21
|
+
popupAlign: GeometryAlign;
|
|
22
|
+
/** Clipping bounds checked for overflow; same coordinate space as `anchor`. */
|
|
23
|
+
bounds: GeometryBounds;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Computes a popup's top-left corner from an anchor rect and alignment pair,
|
|
27
|
+
* flipping either axis to the anchor's opposite side when the preferred
|
|
28
|
+
* position would overflow `bounds` and flipping actually reduces the
|
|
29
|
+
* overflow. Mirrors Kendo Popup's own `anchorAlign`/`popupAlign`/`collision:
|
|
30
|
+
* "flip"` semantics, which only run when Kendo positions via a real `anchor`
|
|
31
|
+
* DOM ref - this reproduces them for offset-based positioning.
|
|
32
|
+
*
|
|
33
|
+
* A `center` alignment on an axis never flips (there is no opposite side).
|
|
34
|
+
* Returns the un-flipped position when `popup` is `null` (not yet measured)
|
|
35
|
+
* or `bounds` cannot be checked - there is nothing to compare against yet.
|
|
36
|
+
*/
|
|
37
|
+
export declare const computeFlippedOffset: ({ anchor, popup, anchorAlign, popupAlign, bounds, }: ComputeFlippedOffsetInput) => {
|
|
38
|
+
left: number;
|
|
39
|
+
top: number;
|
|
40
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./computeFlippedOffset";
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ComponentPublicInstance, type ComputedRef, type MaybeRefOrGetter, type Ref } from "vue";
|
|
2
|
+
import { type GeometryAlign } from "../geometry";
|
|
2
3
|
/** A template ref that may point to an element or a Vue/Kendo component. */
|
|
3
4
|
export type PopupMenuElementRef = Ref<HTMLElement | ComponentPublicInstance | null>;
|
|
4
5
|
export type PopupMenuTriggerMode = "button" | "row";
|
|
@@ -6,6 +7,8 @@ export type PopupMenuOffset = {
|
|
|
6
7
|
left: number;
|
|
7
8
|
top: number;
|
|
8
9
|
};
|
|
10
|
+
/** Mirrors Kendo Popup's `anchorAlign`/`popupAlign` point shape. */
|
|
11
|
+
export type PopupMenuAlign = GeometryAlign;
|
|
9
12
|
export type PopupMenuAnchorActivation = {
|
|
10
13
|
triggerType: "click" | "keyboard";
|
|
11
14
|
coordinates?: {
|
|
@@ -32,6 +35,25 @@ type PopupMenuAnchorBaseOptions = {
|
|
|
32
35
|
hideWhenAnchorClipped?: boolean;
|
|
33
36
|
/** Intersection ratio required to keep the anchor considered visible. */
|
|
34
37
|
intersectionThreshold?: number | number[];
|
|
38
|
+
/**
|
|
39
|
+
* Point on the anchor rect that `offset` is computed from. Mirrors Kendo
|
|
40
|
+
* Popup's `anchorAlign` semantics. Defaults to `{ vertical: "bottom",
|
|
41
|
+
* horizontal: "left" }`, matching Kendo's own default. Passing `anchorAlign`/
|
|
42
|
+
* `popupAlign` directly to `<Popup>` has no effect when positioning via
|
|
43
|
+
* `offset` (no `anchor` DOM ref) - Kendo's own alignment engine only runs
|
|
44
|
+
* against a real anchor element - so these options exist to reproduce that
|
|
45
|
+
* alignment ourselves in the computed `offset`.
|
|
46
|
+
*/
|
|
47
|
+
anchorAlign?: PopupMenuAlign;
|
|
48
|
+
/**
|
|
49
|
+
* Point on the popup itself that is placed at the computed anchor point.
|
|
50
|
+
* Mirrors Kendo Popup's `popupAlign` semantics. Defaults to `{ vertical:
|
|
51
|
+
* "top", horizontal: "left" }`. Requires measuring the rendered popup
|
|
52
|
+
* content (`popupContentRef`, falling back to `menuRef`); until it has a
|
|
53
|
+
* size (e.g. the first open frame), non-"left"/"top" alignments briefly
|
|
54
|
+
* fall back to "left"/"top".
|
|
55
|
+
*/
|
|
56
|
+
popupAlign?: PopupMenuAlign;
|
|
35
57
|
};
|
|
36
58
|
/** Offset-positioning options for a Popup with a dynamically changing trigger. */
|
|
37
59
|
export type PopupMenuAnchorOptions = PopupMenuAnchorBaseOptions & ({
|
|
@@ -78,6 +100,15 @@ export type UsePopupMenuOptions = {
|
|
|
78
100
|
isOpen: Readonly<Ref<boolean>>;
|
|
79
101
|
/** Ref to the popup menu element or component. Used for outside-click detection. */
|
|
80
102
|
menuRef: PopupMenuElementRef;
|
|
103
|
+
/**
|
|
104
|
+
* Ref to the full rendered popup content (e.g. a wrapper containing both a
|
|
105
|
+
* title and the Menu) used to measure size for a non-"left"/"top"
|
|
106
|
+
* `anchor.popupAlign`. Defaults to `menuRef` when omitted - which
|
|
107
|
+
* under-measures the popup whenever it renders anything besides the Menu
|
|
108
|
+
* (e.g. a title), throwing off `popupAlign`'s corner calculation. Not read
|
|
109
|
+
* for outside-click detection; that always uses `menuRef`.
|
|
110
|
+
*/
|
|
111
|
+
popupContentRef?: PopupMenuElementRef;
|
|
81
112
|
/** Ref to the trigger button element or component. Ignored by outside-click detection. */
|
|
82
113
|
triggerRef: PopupMenuElementRef;
|
|
83
114
|
/** Called to open the popup menu. Kendo passes its event args as a rest tuple. */
|
|
@@ -171,6 +171,14 @@ const rowMenu = usePopupMenu({
|
|
|
171
171
|
|
|
172
172
|
For a complete shared-instance implementation, see [Shared Instance Menus](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuSharedInstance.md).
|
|
173
173
|
|
|
174
|
+
## Row vs. Button Trigger Differences
|
|
175
|
+
|
|
176
|
+
`triggerMode: "row"` and `triggerMode: "button"` share the same `handleTriggerClick`/`handleTriggerKeydown`/`handleMenuSelect` code paths - opening, closing, and selection logic is identical either way. What actually differs:
|
|
177
|
+
|
|
178
|
+
- **Anchor positioning convention (not enforced, just the natural fit).** A row spans the full grid width with no single natural corner to anchor to, and the same row can be clicked at a different point every time - so row triggers conventionally use `anchor.activationPoint` (dynamic, positioned at the click/keyboard point). A button is a small, fixed-size element with an obvious anchor corner, so button triggers conventionally use plain rect-based anchoring (`anchor: true`, or `anchor` omitted) - the menu simply drops below the button every time, in the same spot. Nothing stops flipping this (e.g. `activationPoint` on a right-click-anywhere button, or a fixed rect anchor for a row menu that should always appear in one corner) - but for a shared/registry-driven trigger, `activationPoint` always requires the consumer to call `setAnchorActivation(trigger, activation)` with real coordinates before activating (see "Cursor-Positioned Row Menus" above), regardless of which mode it's paired with. `usePopupMenu` cannot capture this automatically for a shared instance, because it only ever sees the *currently active* trigger, never the one about to become active.
|
|
179
|
+
- **ARIA element matching is enforced, not just conventional.** `triggerMode: "row"` only manages an element matching `tr.k-table-row`; `triggerMode: "button"` only manages an element that does **not** match `tr.k-table-row`. If the resolved `triggerRef` doesn't match the declared mode (e.g. `triggerMode: "button"` resolves to a `tr`), the composable logs a `console.warn` and skips ARIA management entirely for that resolution, rather than silently managing the wrong element shape.
|
|
180
|
+
- **Escape fallback target differs.** `triggerMode: "button"` defaults `escapeFocusContainerSelector` to `.k-table-row` - pressing Escape with no closer configured focus target walks up from the button to its containing row and focuses that, keeping keyboard users in the grid's row navigation instead of stranding them on a small nested cell. `triggerMode: "row"` has no such default: the trigger *is* the row already, so Escape with no configured target simply refocuses it directly - there's nothing to "walk up" to.
|
|
181
|
+
|
|
174
182
|
## Sharing One Instance Across Many Triggers (Grid-Wide)
|
|
175
183
|
|
|
176
184
|
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.
|
|
@@ -255,6 +263,31 @@ const toggleButtonMenu = (id: number) => {
|
|
|
255
263
|
|
|
256
264
|
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.
|
|
257
265
|
|
|
266
|
+
## Pairing with `usePopupTrap`
|
|
267
|
+
|
|
268
|
+
`usePopupMenu` deliberately does not trap focus (see the intro above) - a real menu should let `Tab` move on to the next element in the page, not cycle inside it. If a specific popup genuinely needs `Tab` trapped (behaving more like a modal than a menu), compose `usePopupTrap` alongside it rather than fighting its own close/focus-restoration logic:
|
|
269
|
+
|
|
270
|
+
1. Pass the same `isOpen` and the resolved trigger element (`triggerEl`) that you gave `usePopupMenu`.
|
|
271
|
+
2. Omit `onRequestClose` entirely. `usePopupTrap` has its own outside-click/Escape detection, but without `onRequestClose` it never acts on it - `usePopupMenu` remains the sole decision-maker for when the popup closes. Passing `onRequestClose` here would let two composables independently decide to close on the same interaction.
|
|
272
|
+
3. Set `initialFocus: () => document.activeElement`. `usePopupMenu` already focuses the first enabled menu item on open; this preserves that instead of refocusing a possibly different element.
|
|
273
|
+
4. Set `returnFocusToTrigger: false`. This only disables `usePopupTrap`'s own hand-rolled Escape-focus logic.
|
|
274
|
+
5. **Also set `focusTrapOptions: { returnFocusOnDeactivate: false }`.** This is a separate, easy-to-miss setting: the underlying `focus-trap` library has its own independent `returnFocusOnDeactivate` option that defaults to `true` and fires on every `deactivate()`, restoring focus to whatever it captured as "previously focused" - completely bypassing step 4 and racing `usePopupMenu`'s own (correct) restoration target. Skipping this leaves focus landing on the wrong element after close, even though everything else is wired correctly.
|
|
275
|
+
6. If more than one `<Popup>` is rendered in the same template (e.g. a shared row menu and a shared button menu, both permanently mounted with only `:show` toggling visibility), pass `resolvePopupEl` pointing at that specific `<Popup>` by a stable `id`. Without it, `usePopupTrap`'s default class-based lookup can't reliably tell multiple `.k-popup` elements apart and may bind the wrong one.
|
|
276
|
+
|
|
277
|
+
`usePopupTrap` already defaults `allowOutsideClick` to `true` internally - no consumer action needed for that one, but see [usePopupTrap](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/trap/usePopupTrap.md) for why it matters: without it, a genuinely active trap swallows every outside click (e.g. a second trigger in the same grid row), not just ones that would close it.
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
usePopupTrap({
|
|
281
|
+
isOpen: registry.isActive,
|
|
282
|
+
triggerEl: registry.activeElement,
|
|
283
|
+
// Step 6: only required when this popup coexists with another in the template.
|
|
284
|
+
resolvePopupEl: () => document.getElementById("my-shared-popup"),
|
|
285
|
+
initialFocus: () => document.activeElement as HTMLElement | null,
|
|
286
|
+
returnFocusToTrigger: false,
|
|
287
|
+
focusTrapOptions: { returnFocusOnDeactivate: false },
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
258
291
|
## API
|
|
259
292
|
|
|
260
293
|
### `usePopupMenu(options)`
|
|
@@ -263,6 +296,7 @@ See [Focus and Exclusivity](https://github.com/NantHealth/featherk/blob/integrat
|
|
|
263
296
|
|
|
264
297
|
- `isOpen: Readonly<Ref<boolean>>`: Parent-owned popup visibility state observed, but never mutated, by the composable.
|
|
265
298
|
- `menuRef: PopupMenuElementRef`: Ref to the Kendo `Menu` element or component.
|
|
299
|
+
- `popupContentRef?: PopupMenuElementRef`: What a non-default `anchor.popupAlign` measures for the popup's own size. Defaults to `menuRef`; see "Aligning the Popup to the Anchor" below.
|
|
266
300
|
- `triggerRef: PopupMenuElementRef`: Ref to the action trigger element or component.
|
|
267
301
|
- `requestShow(): void`: Opens the parent-owned popup state.
|
|
268
302
|
- `requestHide(): void`: Closes the parent-owned popup state.
|
|
@@ -273,7 +307,7 @@ See [Focus and Exclusivity](https://github.com/NantHealth/featherk/blob/integrat
|
|
|
273
307
|
- `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.
|
|
274
308
|
- `menuLabel?: MaybeRefOrGetter<string | null | undefined>`: Optional accessible name applied as `aria-label` to the rendered `ul[role="menubar"]`. Empty labels remove the managed attribute.
|
|
275
309
|
- `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.
|
|
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.
|
|
310
|
+
- `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. `anchorAlign`/`popupAlign` (see below) configure the computed corner.
|
|
277
311
|
|
|
278
312
|
`PopupMenuElementRef` accepts a normal `HTMLElement` ref or a Vue/Kendo component ref that exposes `$el`.
|
|
279
313
|
|
|
@@ -298,12 +332,63 @@ export type PopupMenuCloseReason =
|
|
|
298
332
|
| "anchor-hidden"
|
|
299
333
|
| null;
|
|
300
334
|
export type PopupMenuCloseTrigger = Exclude<PopupMenuCloseReason, null>;
|
|
335
|
+
export type PopupMenuAlign = {
|
|
336
|
+
horizontal: "left" | "center" | "right";
|
|
337
|
+
vertical: "top" | "center" | "bottom";
|
|
338
|
+
};
|
|
301
339
|
export type KendoMenuSelectEvent = {
|
|
302
340
|
item?: { text?: string };
|
|
303
341
|
event?: { type?: string } | null;
|
|
304
342
|
};
|
|
305
343
|
```
|
|
306
344
|
|
|
345
|
+
## Aligning the Popup to the Anchor (`anchorAlign` / `popupAlign`)
|
|
346
|
+
|
|
347
|
+
Kendo's `<Popup>` only runs its own `anchorAlign`/`popupAlign` alignment engine when it is positioned via a real `anchor` DOM ref. `usePopupMenu` always positions through `:offset` instead - because Kendo's `anchor` prop resolves a static template-ref name once at mount and cannot retarget a different element per open (see the shared-instance section above) - so passing `anchor-align`/`popup-align` directly to `<Popup>` is a silent no-op when using `usePopupMenu`. Configure alignment through `anchor.anchorAlign`/`anchor.popupAlign` instead; `usePopupMenu` computes the aligned `offset` itself.
|
|
348
|
+
|
|
349
|
+
- `anchorAlign?: PopupMenuAlign`: The point on the anchor rect that `offset` is computed from. Defaults to `{ horizontal: "left", vertical: "bottom" }`, matching Kendo's own default. Meaningless (a no-op) against an `activationPoint` anchor, since that anchor is already a single point with no width/height.
|
|
350
|
+
- `popupAlign?: PopupMenuAlign`: The point on the popup itself that lands at the computed anchor point. Defaults to `{ horizontal: "left", vertical: "top" }`. A `"center"`/`"right"`/`"bottom"` value requires measuring the rendered popup content (via `popupContentRef`, falling back to `menuRef`), so on the very first open there can be a one-frame snap from the top-left default to the final aligned position while that measurement completes.
|
|
351
|
+
- `popupContentRef?: PopupMenuElementRef`: What `popupAlign` measures for the popup's own size. Defaults to `menuRef`, which **under-measures** the popup whenever it renders anything besides the Menu - e.g. a title `<strong>` above it, as in the Grid Action Cell example - throwing off the computed corner (especially when that extra content is wider or taller than the Menu itself). Point this at a wrapper element containing everything rendered inside the `<Popup>` whenever `popupAlign` is not `{ horizontal: "left", vertical: "top" }` and the popup renders more than just the Menu.
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
const buttonMenu = usePopupMenu({
|
|
355
|
+
isOpen: registry.isActive,
|
|
356
|
+
triggerRef: registry.activeElement,
|
|
357
|
+
menuRef: menuRef,
|
|
358
|
+
// Only needed because the template below renders a title above the Menu -
|
|
359
|
+
// without it, popupAlign would measure just the Menu and mis-align the popup.
|
|
360
|
+
popupContentRef: popupContentRef,
|
|
361
|
+
triggerMode: "button",
|
|
362
|
+
anchor: {
|
|
363
|
+
anchorAlign: { horizontal: "right", vertical: "bottom" },
|
|
364
|
+
popupAlign: { horizontal: "right", vertical: "top" },
|
|
365
|
+
},
|
|
366
|
+
requestShow: () => {},
|
|
367
|
+
requestHide: () => registry.deactivate(),
|
|
368
|
+
});
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
```vue
|
|
372
|
+
<Popup :show="isOpen" :offset="buttonMenu.offset.value">
|
|
373
|
+
<!-- popupContentRef wraps everything the Popup renders, not just the Menu. -->
|
|
374
|
+
<div ref="popupContentRef">
|
|
375
|
+
<strong v-if="activeTitle">{{ activeTitle }}</strong>
|
|
376
|
+
<Menu ref="menuRef" :items="activeMenuItems" />
|
|
377
|
+
</div>
|
|
378
|
+
</Popup>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Flipping the Popup on Viewport Collision
|
|
382
|
+
|
|
383
|
+
Whenever `anchor` is configured, `usePopupMenu` automatically flips the popup to the opposite side of the anchor, per axis, if the preferred `anchorAlign`/`popupAlign` corner would render outside the viewport (or the configured `clipRoot`, when set). This is always on and requires no configuration - it exists because Kendo's own collision-avoidance engine, like its alignment engine, only runs against a real `anchor` DOM ref and is a no-op against `usePopupMenu`'s `:offset`-based positioning.
|
|
384
|
+
|
|
385
|
+
- Each axis is flipped independently: a popup can flip horizontally, vertically, both, or neither, depending on which edges it would overflow.
|
|
386
|
+
- A flip is only committed if it actually reduces the overflow on that axis; a popup wider or taller than the available space is left at its preferred position rather than flipped back and forth.
|
|
387
|
+
- Flip detection requires measuring the popup's real size, so `popupRect` is now measured (via `ResizeObserver`) whenever `anchor` is configured, not only when `popupAlign` is non-default.
|
|
388
|
+
- `"center"` alignment on either axis never flips - flipping center to center is a no-op by definition.
|
|
389
|
+
- Bounds default to the viewport (`window.innerWidth`/`innerHeight`); pass `anchor.clipRoot` to constrain flipping to a scrollable ancestor instead (the same container used for `hideWhenAnchorClipped`).
|
|
390
|
+
- The underlying math is a pure, stateless function - `computeFlippedOffset` in `@featherk/composables/geometry` - reusable outside `usePopupMenu` for any anchor/popup positioning problem.
|
|
391
|
+
|
|
307
392
|
## Behavior
|
|
308
393
|
|
|
309
394
|
- Pointer click, `Enter`, and `Space` toggle the menu.
|
|
@@ -125,6 +125,7 @@ The template contains many triggers but only two popup trees:
|
|
|
125
125
|
|
|
126
126
|
<!-- Step 5: one physical row menu for every row trigger. -->
|
|
127
127
|
<Popup
|
|
128
|
+
id="shared-row-menu-popup"
|
|
128
129
|
:show="rowRegistry.isActive.value"
|
|
129
130
|
:offset="rowMenu.offset.value"
|
|
130
131
|
@close="rowMenu.handlePopupClose"
|
|
@@ -141,6 +142,7 @@ The template contains many triggers but only two popup trees:
|
|
|
141
142
|
|
|
142
143
|
<!-- Step 5: one physical button menu for every action button. -->
|
|
143
144
|
<Popup
|
|
145
|
+
id="shared-button-menu-popup"
|
|
144
146
|
:show="buttonRegistry.isActive.value"
|
|
145
147
|
:offset="buttonMenu.offset.value"
|
|
146
148
|
@close="buttonMenu.handlePopupClose"
|
|
@@ -477,4 +479,42 @@ this composite-id orchestration.
|
|
|
477
479
|
- Do not add row roles or focus behavior in popup code; `useGridA11y` and the
|
|
478
480
|
grid's interaction model own those semantics.
|
|
479
481
|
|
|
482
|
+
## Trapping Focus in a Shared Popup
|
|
483
|
+
|
|
484
|
+
Neither `rowMenu` nor `buttonMenu` above traps `Tab` inside its Popup - that's
|
|
485
|
+
intentional (see `usePopupMenu.md`'s intro). If a specific shared popup needs
|
|
486
|
+
it anyway, pair it with `usePopupTrap` using the same registry, and disable
|
|
487
|
+
both its own Escape-focus logic and the underlying `focus-trap` library's
|
|
488
|
+
independent `returnFocusOnDeactivate` default, so `usePopupMenu` remains the
|
|
489
|
+
sole owner of close/focus-restoration decisions. Both shared Popups above stay
|
|
490
|
+
permanently mounted (only `:show` toggles visibility) with pure `:offset`
|
|
491
|
+
positioning (no real Kendo `anchor` ref), so `usePopupTrap`'s default
|
|
492
|
+
class-based lookup can't reliably tell them apart - pin each trap to its own
|
|
493
|
+
Popup by the `id` it already has:
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
usePopupTrap({
|
|
497
|
+
isOpen: rowRegistry.isActive,
|
|
498
|
+
triggerEl: rowRegistry.activeElement,
|
|
499
|
+
resolvePopupEl: () => document.getElementById("shared-row-menu-popup"),
|
|
500
|
+
initialFocus: () => document.activeElement as HTMLElement | null,
|
|
501
|
+
returnFocusToTrigger: false,
|
|
502
|
+
focusTrapOptions: { returnFocusOnDeactivate: false },
|
|
503
|
+
});
|
|
504
|
+
usePopupTrap({
|
|
505
|
+
isOpen: buttonRegistry.isActive,
|
|
506
|
+
triggerEl: buttonRegistry.activeElement,
|
|
507
|
+
resolvePopupEl: () => document.getElementById("shared-button-menu-popup"),
|
|
508
|
+
initialFocus: () => document.activeElement as HTMLElement | null,
|
|
509
|
+
returnFocusToTrigger: false,
|
|
510
|
+
focusTrapOptions: { returnFocusOnDeactivate: false },
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
`usePopupTrap` already defaults `allowOutsideClick` to `true` - without it, a
|
|
515
|
+
genuinely active trap on one menu would swallow clicks on the *other* trigger
|
|
516
|
+
in the same row (e.g. the row menu trapping Tab while the user tries to click
|
|
517
|
+
the action button). See `usePopupMenu.md`'s "Pairing with `usePopupTrap`"
|
|
518
|
+
section for why each of these options is required.
|
|
519
|
+
|
|
480
520
|
For deeper details, see [Shared Instances](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuSharedInstance.md), [Activation-Point Anchors](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuActivationAnchor.md), [Focus and Exclusivity](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuFocusAndExclusivity.md), and [useExclusiveGroup](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/registry/useExclusiveGroup.md).
|
|
@@ -101,7 +101,7 @@ export type InitialFocus = string | ((root: HTMLElement) => HTMLElement | null |
|
|
|
101
101
|
- `.k-popup`
|
|
102
102
|
- `.k-timepicker-popup`
|
|
103
103
|
- `.k-menu-popup`
|
|
104
|
-
- **Focus trap**: Uses `useFocusTrap(popupRef)` with a fallback/initial focus inside the popup. `escapeDeactivates` and `clickOutsideDeactivates` are disabled; closing is managed explicitly.
|
|
104
|
+
- **Focus trap**: Uses `useFocusTrap(popupRef)` with a fallback/initial focus inside the popup. `escapeDeactivates` and `clickOutsideDeactivates` are disabled; closing is managed explicitly. `allowOutsideClick` defaults to `true` - without it, `focus-trap`'s own default (`false`) `preventDefault()`s and swallows *every* outside pointerdown/click while the trap is active, not just ones that would close it, blocking interaction with anything else on the page (e.g. a second trigger in the same grid row) until the trap deactivates. Override via `focusTrapOptions` only if a specific popup genuinely needs that stricter modal-style behavior.
|
|
105
105
|
- **Escape to close**: Adds a `keydown` listener on the popup when opened; pressing Escape calls `onRequestClose('escape', event)` and stops propagation.
|
|
106
106
|
- **Outside click to close**: Uses `onClickOutside(popupRef, ...)` to call `onRequestClose('outside', event)` when open.
|
|
107
107
|
- **Open/close lifecycle**: When `isOpen` becomes true, discovers the popup, sets `popupRef`, and activates the focus trap. When it becomes false, deactivates, removes listeners, clears `popupRef`, and optionally returns focus to `triggerEl`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@featherk/composables",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"main": "dist/featherk-composables.umd.js",
|
|
5
5
|
"module": "dist/featherk-composables.es.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
"import": "./dist/featherk-composables.es.js",
|
|
46
46
|
"require": "./dist/featherk-composables.umd.js"
|
|
47
47
|
},
|
|
48
|
+
"./geometry": {
|
|
49
|
+
"types": "./dist/geometry/index.d.ts",
|
|
50
|
+
"import": "./dist/featherk-composables.es.js",
|
|
51
|
+
"require": "./dist/featherk-composables.umd.js"
|
|
52
|
+
},
|
|
48
53
|
"./registry": {
|
|
49
54
|
"types": "./dist/registry/index.d.ts",
|
|
50
55
|
"import": "./dist/featherk-composables.es.js",
|