@featherk/composables 0.13.3 → 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.
@@ -10,7 +10,12 @@ export type GeometryBounds = {
10
10
  right: number;
11
11
  bottom: number;
12
12
  };
13
- export type ComputeFlippedOffsetInput = {
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
- * 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.
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
- * 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.
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 computeFlippedOffset: ({ anchor, popup, anchorAlign, popupAlign, bounds, }: ComputeFlippedOffsetInput) => {
41
+ export declare const computeCollisionOffset: ({ anchor, popup, anchorAlign, popupAlign, bounds, collision, }: ComputeCollisionOffsetInput) => {
38
42
  left: number;
39
43
  top: number;
40
44
  };
@@ -1 +1 @@
1
- export * from "./computeFlippedOffset";
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
- * 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".
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
- * 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`.
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. */
@@ -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`: What a non-default `anchor.popupAlign` measures for the popup's own size. Defaults to `menuRef`; see "Aligning the Popup to the Anchor" below.
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` (see below) configure the computed corner.
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 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.
370
- - `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.
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
- <!-- popupContentRef wraps everything the Popup renders, not just the Menu. -->
393
- <div ref="popupContentRef">
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
- ## Flipping the Popup on Viewport Collision
398
+ ## Handling Viewport Collision
401
399
 
402
- 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.
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
- - Each axis is flipped independently: a popup can flip horizontally, vertically, both, or neither, depending on which edges it would overflow.
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
- - 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.
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 flipping to a scrollable ancestor instead (the same container used for `hideWhenAnchorClipped`).
409
- - The underlying math is a pure, stateless function - `computeFlippedOffset` in `@featherk/composables/geometry` - reusable outside `usePopupMenu` for any anchor/popup positioning problem.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@featherk/composables",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
4
4
  "main": "dist/featherk-composables.umd.js",
5
5
  "module": "dist/featherk-composables.es.js",
6
6
  "types": "dist/index.d.ts",