@featherk/composables 0.13.2 → 0.13.4
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 +2 -2
- package/dist/docs-manifest.js +23 -23
- package/dist/featherk-composables.es.js +1240 -1222
- 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 → computeCollisionOffset.d.ts} +14 -10
- package/dist/geometry/index.d.ts +1 -1
- package/dist/menu/usePopupMenu.d.ts +11 -10
- package/docs/menu/usePopupMenu.md +29 -23
- package/docs/menu/usePopupMenuGridOrchestration.md +6 -4
- package/package.json +1 -1
- /package/dist/geometry/{computeFlippedOffset.test.d.ts → computeCollisionOffset.test.d.ts} +0 -0
|
@@ -10,7 +10,12 @@ export type GeometryBounds = {
|
|
|
10
10
|
right: number;
|
|
11
11
|
bottom: number;
|
|
12
12
|
};
|
|
13
|
-
export type
|
|
13
|
+
export type GeometryCollisionType = "fit" | "flip";
|
|
14
|
+
export type GeometryCollision = {
|
|
15
|
+
horizontal: GeometryCollisionType;
|
|
16
|
+
vertical: GeometryCollisionType;
|
|
17
|
+
};
|
|
18
|
+
export type ComputeCollisionOffsetInput = {
|
|
14
19
|
/** The anchor rect (or a zero-size point rect for a click/keyboard activation). */
|
|
15
20
|
anchor: DOMRect;
|
|
16
21
|
/** The popup's own measured size, or `null` before it has been measured. */
|
|
@@ -21,20 +26,19 @@ export type ComputeFlippedOffsetInput = {
|
|
|
21
26
|
popupAlign: GeometryAlign;
|
|
22
27
|
/** Clipping bounds checked for overflow; same coordinate space as `anchor`. */
|
|
23
28
|
bounds: GeometryBounds;
|
|
29
|
+
/** Per-axis collision behavior applied when the preferred corner overflows. */
|
|
30
|
+
collision: GeometryCollision;
|
|
24
31
|
};
|
|
25
32
|
/**
|
|
26
33
|
* Computes a popup's top-left corner from an anchor rect and alignment pair,
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* "flip"` semantics, which only run when Kendo positions via a real `anchor`
|
|
31
|
-
* DOM ref - this reproduces them for offset-based positioning.
|
|
34
|
+
* then applies Kendo-style `fit` or `flip` collision behavior independently
|
|
35
|
+
* on each axis. Fit shifts the popup within bounds without changing sides;
|
|
36
|
+
* flip moves it to the anchor's opposite side only when that reduces overflow.
|
|
32
37
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* or `bounds` cannot be checked - there is nothing to compare against yet.
|
|
38
|
+
* Returns the preferred position when `popup` is `null`, since collision
|
|
39
|
+
* behavior cannot be calculated before the popup has been measured.
|
|
36
40
|
*/
|
|
37
|
-
export declare const
|
|
41
|
+
export declare const computeCollisionOffset: ({ anchor, popup, anchorAlign, popupAlign, bounds, collision, }: ComputeCollisionOffsetInput) => {
|
|
38
42
|
left: number;
|
|
39
43
|
top: number;
|
|
40
44
|
};
|
package/dist/geometry/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "./
|
|
1
|
+
export * from "./computeCollisionOffset";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ComponentPublicInstance, type ComputedRef, type MaybeRefOrGetter, type Ref } from "vue";
|
|
2
|
-
import { type GeometryAlign } from "../geometry";
|
|
2
|
+
import { type GeometryAlign, type GeometryCollision } from "../geometry";
|
|
3
3
|
/** A template ref that may point to an element or a Vue/Kendo component. */
|
|
4
4
|
export type PopupMenuElementRef = Ref<HTMLElement | ComponentPublicInstance | null>;
|
|
5
5
|
export type PopupMenuTriggerMode = "button" | "row";
|
|
@@ -9,6 +9,8 @@ export type PopupMenuOffset = {
|
|
|
9
9
|
};
|
|
10
10
|
/** Mirrors Kendo Popup's `anchorAlign`/`popupAlign` point shape. */
|
|
11
11
|
export type PopupMenuAlign = GeometryAlign;
|
|
12
|
+
/** Per-axis viewport collision behavior for offset-positioned popups. */
|
|
13
|
+
export type PopupMenuCollision = GeometryCollision;
|
|
12
14
|
export type PopupMenuAnchorActivation = {
|
|
13
15
|
triggerType: "click" | "keyboard";
|
|
14
16
|
coordinates?: {
|
|
@@ -35,6 +37,8 @@ type PopupMenuAnchorBaseOptions = {
|
|
|
35
37
|
hideWhenAnchorClipped?: boolean;
|
|
36
38
|
/** Intersection ratio required to keep the anchor considered visible. */
|
|
37
39
|
intersectionThreshold?: number | number[];
|
|
40
|
+
/** Per-axis collision behavior. Defaults to `flip` on both axes. */
|
|
41
|
+
collision?: PopupMenuCollision;
|
|
38
42
|
/**
|
|
39
43
|
* Point on the anchor rect that `offset` is computed from. Mirrors Kendo
|
|
40
44
|
* Popup's `anchorAlign` semantics. Defaults to `{ vertical: "bottom",
|
|
@@ -49,9 +53,8 @@ type PopupMenuAnchorBaseOptions = {
|
|
|
49
53
|
* Point on the popup itself that is placed at the computed anchor point.
|
|
50
54
|
* Mirrors Kendo Popup's `popupAlign` semantics. Defaults to `{ vertical:
|
|
51
55
|
* "top", horizontal: "left" }`. Requires measuring the rendered popup
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* fall back to "left"/"top".
|
|
56
|
+
* border box; until it has a size (e.g. the first open frame), non-"left"/
|
|
57
|
+
* "top" alignments briefly fall back to "left"/"top".
|
|
55
58
|
*/
|
|
56
59
|
popupAlign?: PopupMenuAlign;
|
|
57
60
|
};
|
|
@@ -101,12 +104,10 @@ export type UsePopupMenuOptions = {
|
|
|
101
104
|
/** Ref to the popup menu element or component. Used for outside-click detection. */
|
|
102
105
|
menuRef: PopupMenuElementRef;
|
|
103
106
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* (e.g. a title), throwing off `popupAlign`'s corner calculation. Not read
|
|
109
|
-
* for outside-click detection; that always uses `menuRef`.
|
|
107
|
+
* Optional measurement source for custom popup markup. The source is
|
|
108
|
+
* promoted to its nearest Kendo `.k-popup` so alignment and collision use
|
|
109
|
+
* the complete border box. Defaults to `menuRef`; outside-click detection
|
|
110
|
+
* always uses `menuRef` directly.
|
|
110
111
|
*/
|
|
111
112
|
popupContentRef?: PopupMenuElementRef;
|
|
112
113
|
/** Ref to the trigger button element or component. Ignored by outside-click detection. */
|
|
@@ -11,7 +11,7 @@ Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It man
|
|
|
11
11
|
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.
|
|
12
12
|
2. Create parent-owned refs for `isOpen`, `triggerRef`, `menuRef`, and an optional keyboard focus target.
|
|
13
13
|
3. Call `usePopupMenu(...)` with `requestShow`, `requestHide`, and an explicit `triggerMode: "button"` (or `"row"`).
|
|
14
|
-
4. Route Kendo Menu `@select` through `handleMenuSelect(event, onAction)` so
|
|
14
|
+
4. Route Kendo Menu `@select` through `handleMenuSelect(event, onAction)` so the composable closes the popup before running your business action synchronously.
|
|
15
15
|
5. Bind trigger button `@click` and `@keydown` to the composable handlers.
|
|
16
16
|
6. Bind Popup `@close` and Menu `@keydown.escape` so keyboard and mouse close paths restore focus correctly.
|
|
17
17
|
|
|
@@ -70,7 +70,7 @@ const menu = usePopupMenu({
|
|
|
70
70
|
requestHide: () => (isOpen.value = false),
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
// Step 4:
|
|
73
|
+
// Step 4: usePopupMenu closes before running the business action synchronously
|
|
74
74
|
const onSelect = (event: KendoMenuSelectEvent) => {
|
|
75
75
|
menu.handleMenuSelect(event, (selected) => {
|
|
76
76
|
// Keep application-specific actions in the consuming component.
|
|
@@ -89,7 +89,7 @@ For a virtualized Kendo Grid, use `focusTargetContainerSelector` to restore keyb
|
|
|
89
89
|
3. Create trigger and menu refs as component refs (`ComponentPublicInstance`) for Kendo wrappers.
|
|
90
90
|
4. Call `usePopupMenu(...)` with an explicit `triggerMode: "button"` and map `requestShow`/`requestHide` to row-specific emits.
|
|
91
91
|
5. Provide `focusTargetContainerSelector` so keyboard close restores focus to the current virtualized row.
|
|
92
|
-
6. Route menu selection through `handleMenuSelect(event, onAction)` so
|
|
92
|
+
6. Route menu selection through `handleMenuSelect(event, onAction)` so the popup closes before row action logic runs synchronously.
|
|
93
93
|
|
|
94
94
|
```ts
|
|
95
95
|
<script setup lang="ts">
|
|
@@ -124,7 +124,7 @@ const menu = usePopupMenu({
|
|
|
124
124
|
focusTargetContainerSelector: ".k-table-row[data-grid-row-index]",
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
// Step 6:
|
|
127
|
+
// Step 6: usePopupMenu closes before running the row-specific business action
|
|
128
128
|
const onSelect = (event: KendoMenuSelectEvent) => {
|
|
129
129
|
menu.handleMenuSelect(event, (selected) => {
|
|
130
130
|
// Row-specific action logic remains here.
|
|
@@ -315,7 +315,7 @@ usePopupTrap({
|
|
|
315
315
|
|
|
316
316
|
- `isOpen: Readonly<Ref<boolean>>`: Parent-owned popup visibility state observed, but never mutated, by the composable.
|
|
317
317
|
- `menuRef: PopupMenuElementRef`: Ref to the Kendo `Menu` element or component.
|
|
318
|
-
- `popupContentRef?: PopupMenuElementRef`:
|
|
318
|
+
- `popupContentRef?: PopupMenuElementRef`: Optional measurement source for custom popup markup. The composable measures its nearest Kendo `.k-popup` border box, falling back to the source element itself. Defaults to `menuRef`; see "Aligning the Popup to the Anchor" below.
|
|
319
319
|
- `triggerRef: PopupMenuElementRef`: Ref to the action trigger element or component.
|
|
320
320
|
- `requestShow(): void`: Opens the parent-owned popup state.
|
|
321
321
|
- `requestHide(): void`: Closes the parent-owned popup state.
|
|
@@ -326,7 +326,7 @@ usePopupTrap({
|
|
|
326
326
|
- `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.
|
|
327
327
|
- `menuLabel?: MaybeRefOrGetter<string | null | undefined>`: Optional accessible name applied as `aria-label` to the rendered `ul[role="menubar"]`. Empty labels remove the managed attribute.
|
|
328
328
|
- `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.
|
|
329
|
-
- `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`
|
|
329
|
+
- `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` configure the computed corner, while `collision` configures per-axis `fit` or `flip` behavior.
|
|
330
330
|
|
|
331
331
|
`PopupMenuElementRef` accepts a normal `HTMLElement` ref or a Vue/Kendo component ref that exposes `$el`.
|
|
332
332
|
|
|
@@ -355,6 +355,10 @@ export type PopupMenuAlign = {
|
|
|
355
355
|
horizontal: "left" | "center" | "right";
|
|
356
356
|
vertical: "top" | "center" | "bottom";
|
|
357
357
|
};
|
|
358
|
+
export type PopupMenuCollision = {
|
|
359
|
+
horizontal: "fit" | "flip";
|
|
360
|
+
vertical: "fit" | "flip";
|
|
361
|
+
};
|
|
358
362
|
export type KendoMenuSelectEvent = {
|
|
359
363
|
item?: { text?: string };
|
|
360
364
|
event?: { type?: string } | null;
|
|
@@ -366,17 +370,14 @@ export type KendoMenuSelectEvent = {
|
|
|
366
370
|
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.
|
|
367
371
|
|
|
368
372
|
- `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.
|
|
369
|
-
- `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
|
|
370
|
-
- `popupContentRef?: PopupMenuElementRef`:
|
|
373
|
+
- `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, 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.
|
|
374
|
+
- `popupContentRef?: PopupMenuElementRef`: Optional measurement source for custom popup markup. The composable promotes it (or `menuRef` by default) to the nearest Kendo `.k-popup`, ensuring titles, sibling content, and popup borders are included in alignment and collision calculations.
|
|
371
375
|
|
|
372
376
|
```ts
|
|
373
377
|
const buttonMenu = usePopupMenu({
|
|
374
378
|
isOpen: registry.isActive,
|
|
375
379
|
triggerRef: registry.activeElement,
|
|
376
380
|
menuRef: menuRef,
|
|
377
|
-
// Only needed because the template below renders a title above the Menu -
|
|
378
|
-
// without it, popupAlign would measure just the Menu and mis-align the popup.
|
|
379
|
-
popupContentRef: popupContentRef,
|
|
380
381
|
triggerMode: "button",
|
|
381
382
|
anchor: {
|
|
382
383
|
anchorAlign: { horizontal: "right", vertical: "bottom" },
|
|
@@ -389,24 +390,29 @@ const buttonMenu = usePopupMenu({
|
|
|
389
390
|
|
|
390
391
|
```vue
|
|
391
392
|
<Popup :show="isOpen" :offset="buttonMenu.offset.value">
|
|
392
|
-
|
|
393
|
-
<
|
|
394
|
-
<strong v-if="activeTitle">{{ activeTitle }}</strong>
|
|
395
|
-
<Menu ref="menuRef" :items="activeMenuItems" />
|
|
396
|
-
</div>
|
|
393
|
+
<strong v-if="activeTitle">{{ activeTitle }}</strong>
|
|
394
|
+
<Menu ref="menuRef" :items="activeMenuItems" />
|
|
397
395
|
</Popup>
|
|
398
396
|
```
|
|
399
397
|
|
|
400
|
-
##
|
|
398
|
+
## Handling Viewport Collision
|
|
401
399
|
|
|
402
|
-
Whenever `anchor` is configured, `usePopupMenu`
|
|
400
|
+
Whenever `anchor` is configured, `usePopupMenu` applies collision behavior if the preferred `anchorAlign`/`popupAlign` corner would render outside the viewport or configured `clipRoot`. Configure each axis independently with `anchor.collision`; both axes default to `"flip"`, preserving the original behavior.
|
|
403
401
|
|
|
404
|
-
-
|
|
402
|
+
- `"flip"` moves the popup to the opposite side of the anchor when that reduces overflow.
|
|
403
|
+
- `"fit"` shifts the popup just far enough to remain within bounds without changing anchor sides. If the popup is larger than the available space, that axis is pinned to the bounds' starting edge.
|
|
404
|
+
- Mixed strategies are supported, for example `{ horizontal: "fit", vertical: "flip" }`.
|
|
405
405
|
- 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.
|
|
406
|
-
-
|
|
406
|
+
- Collision detection requires measuring the popup's real size, so `popupRect` is measured via `ResizeObserver` whenever `anchor` is configured.
|
|
407
407
|
- `"center"` alignment on either axis never flips - flipping center to center is a no-op by definition.
|
|
408
|
-
- Bounds default to the viewport (`window.innerWidth`/`innerHeight`); pass `anchor.clipRoot` to constrain
|
|
409
|
-
- The underlying math is a pure, stateless function - `
|
|
408
|
+
- Bounds default to the viewport (`window.innerWidth`/`innerHeight`); pass `anchor.clipRoot` to constrain collision handling to a scrollable ancestor instead (the same container used for `hideWhenAnchorClipped`).
|
|
409
|
+
- The underlying math is a pure, stateless function - `computeCollisionOffset` in `@featherk/composables/geometry` - reusable outside `usePopupMenu` for any anchor/popup positioning problem.
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
anchor: {
|
|
413
|
+
collision: { horizontal: "fit", vertical: "flip" },
|
|
414
|
+
}
|
|
415
|
+
```
|
|
410
416
|
|
|
411
417
|
## Behavior
|
|
412
418
|
|
|
@@ -417,7 +423,7 @@ Whenever `anchor` is configured, `usePopupMenu` automatically flips the popup to
|
|
|
417
423
|
- 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.
|
|
418
424
|
- Opening focuses the first enabled Kendo Menu item after Vue renders it.
|
|
419
425
|
- Outside clicks close the menu, while the trigger is ignored to prevent a close/reopen race.
|
|
420
|
-
- Menu selection
|
|
426
|
+
- Menu selection records modality and requests close before running the optional business callback synchronously. Registry-backed consumers must snapshot business context before calling `handleMenuSelect`; `useActionCellMenu` does this internally.
|
|
421
427
|
- `Escape` on the trigger or inside the menu uses the keyboard close path.
|
|
422
428
|
- Keyboard-driven closes focus `resolveFocusTarget()` or `focusTargetRef`, then fall back to the trigger.
|
|
423
429
|
- Mouse selection closes restore focus to the trigger.
|
|
@@ -340,17 +340,19 @@ const handleGridKeydown = (event: KeyboardEvent) => {
|
|
|
340
340
|
};
|
|
341
341
|
|
|
342
342
|
const handleRowMenuSelect = (event: KendoMenuSelectEvent) => {
|
|
343
|
+
const record = activeRowRecord.value;
|
|
343
344
|
rowMenu.handleMenuSelect(event, (selection) => {
|
|
344
|
-
if (selection.item &&
|
|
345
|
-
console.log(selection.item.id,
|
|
345
|
+
if (selection.item && record) {
|
|
346
|
+
console.log(selection.item.id, record);
|
|
346
347
|
}
|
|
347
348
|
});
|
|
348
349
|
};
|
|
349
350
|
|
|
350
351
|
const handleButtonMenuSelect = (event: KendoMenuSelectEvent) => {
|
|
352
|
+
const record = activeButtonRecord.value;
|
|
351
353
|
buttonMenu.handleMenuSelect(event, (selection) => {
|
|
352
|
-
if (selection.item &&
|
|
353
|
-
console.log(selection.item.id,
|
|
354
|
+
if (selection.item && record) {
|
|
355
|
+
console.log(selection.item.id, record);
|
|
354
356
|
}
|
|
355
357
|
});
|
|
356
358
|
};
|
package/package.json
CHANGED
|
File without changes
|