# usePopupMenu

[Back to Composables README](https://github.com/NantHealth/featherk/blob/integration/packages/composables/README.md)

New to coordinating row and button menus in a Kendo Grid? Start with [Grid Popup Menu Orchestration](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuGridOrchestration.md).

Composable for accessible Kendo UI for Vue `Popup` + `Menu` action menus. It manages trigger toggling, first-item focus, outside clicks, input-modality tracking, and focus restoration. It does not trap focus or own popup state; keep `usePopupTrap` separate when focus trapping or generic popup lifecycle behavior is needed.

## Quick Start

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.
2. Create parent-owned refs for `isOpen`, `triggerRef`, `menuRef`, and an optional keyboard focus target.
3. Call `usePopupMenu(...)` with `requestShow`, `requestHide`, and an explicit `triggerMode: "button"` (or `"row"`).
4. Route Kendo Menu `@select` through `handleMenuSelect(event, onAction)` so the composable closes the popup before running your business action synchronously.
5. Bind trigger button `@click` and `@keydown` to the composable handlers.
6. Bind Popup `@close` and Menu `@keydown.escape` so keyboard and mouse close paths restore focus correctly.

```vue
<template>
  <section ref="panelRef">
    <!-- Step 1: static aria-haspopup/aria-expanded baseline; Step 5: bind trigger handlers -->
    <button
      ref="triggerRef"
      type="button"
      aria-haspopup="menu"
      aria-expanded="false"
      @click="menu.handleTriggerClick"
      @keydown="menu.handleTriggerKeydown"
    >
      Actions
    </button>

    <!-- Step 6: bind popup/menu close hooks for modality-aware focus restoration -->
    <Popup :show="isOpen" @close="menu.handlePopupClose">
      <strong v-if="menuTitle" class="fk-menu-title">
        {{ menuTitle }}
      </strong>
      <Menu
        ref="menuRef"
        @keydown.escape="menu.handleMenuEscape"
        @select="onSelect"
      />
    </Popup>
  </section>
</template>

<script setup lang="ts">
import { ref } from "vue";
import {
  usePopupMenu,
  type KendoMenuSelectEvent,
} from "@featherk/composables/menu";

// Step 2: parent-owned menu state and element refs
const isOpen = ref(false);
const triggerRef = ref<HTMLElement | null>(null);
const menuRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const menuTitle = ref("Record actions");

// Step 3: wire composable show/hide ownership to parent state
const menu = usePopupMenu({
  isOpen,
  triggerRef,
  menuRef,
  triggerMode: "button",
  menuLabel: menuTitle,
  focusTargetRef: panelRef,
  requestShow: () => (isOpen.value = true),
  requestHide: () => (isOpen.value = false),
});

// Step 4: usePopupMenu closes before running the business action synchronously
const onSelect = (event: KendoMenuSelectEvent) => {
  menu.handleMenuSelect(event, (selected) => {
    // Keep application-specific actions in the consuming component.
    if (selected.item?.text === "Archive") archiveRecord();
  });
};
</script>
```

## Grid Action Cell

For a virtualized Kendo Grid, use `focusTargetContainerSelector` to restore keyboard focus to the trigger's current row without reaching through a registry.

1. Add static `aria-haspopup="menu"` and `aria-expanded="false"` to the row's trigger button template.
2. Read `showMenu` from the row item and keep open/close state parent-owned.
3. Create trigger and menu refs as component refs (`ComponentPublicInstance`) for Kendo wrappers.
4. Call `usePopupMenu(...)` with an explicit `triggerMode: "button"` and map `requestShow`/`requestHide` to row-specific emits.
5. Provide `focusTargetContainerSelector` so keyboard close restores focus to the current virtualized row.
6. Route menu selection through `handleMenuSelect(event, onAction)` so the popup closes before row action logic runs synchronously.

```ts
<script setup lang="ts">
import { computed, ref, type ComponentPublicInstance } from "vue";
import {
  usePopupMenu,
  type KendoMenuSelectEvent,
} from "@featherk/composables/menu";

const props = defineProps<{ dataItem: { id: string; showMenu: boolean } }>();
const emit = defineEmits<{
  "update:showMenu": [id: string];
  "update:hideMenu": [id: string];
}>();

// Step 2: read row-owned open state from the data item
const isOpen = computed(() => props.dataItem.showMenu);

// Step 3: Kendo refs are component instances; usePopupMenu resolves $el internally
const triggerRef = ref<ComponentPublicInstance | null>(null);
const menuRef = ref<ComponentPublicInstance | null>(null);

// Step 4: keep popup ownership in parent row state via emits
const menu = usePopupMenu({
  isOpen,
  triggerRef,
  menuRef,
  triggerMode: "button",
  requestShow: () => emit("update:showMenu", props.dataItem.id),
  requestHide: () => emit("update:hideMenu", props.dataItem.id),
  // Step 5: keyboard close restores focus to the trigger's current row
  focusTargetContainerSelector: ".k-table-row[data-grid-row-index]",
});

// Step 6: usePopupMenu closes before running the row-specific business action
const onSelect = (event: KendoMenuSelectEvent) => {
  menu.handleMenuSelect(event, (selected) => {
    // Row-specific action logic remains here.
    runGridAction(props.dataItem.id, selected.item?.text);
  });
};
</script>
```

## Row + Button Dual Trigger

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.

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.
2. Create one `usePopupMenu` instance per trigger role, each with its own `triggerRef`, `isOpen`, `requestShow`, and `requestHide`.
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.
4. Set `triggerMode` explicitly for each instance (`"row"` for the row trigger, `"button"` for the nested button).
5. Verify in devtools that each instance's `triggerRef.value` resolves to a distinct DOM node before wiring `requestShow`/`requestHide`.

```ts
// Step 2-4: independent instances, independent triggerRef, explicit triggerMode
const rowTriggerRef = computed(
  () => cellRef.value?.closest(".k-table-row") as HTMLElement | null,
);

const buttonMenu = usePopupMenu({
  isOpen: computed(() => props.dataItem.showButtonMenu),
  triggerRef: buttonRef,
  menuRef: buttonMenuRef,
  triggerMode: "button",
  requestShow: () => emit("update:toggleButtonMenu", props.dataItem.id),
  requestHide: () => emit("update:hideButtonMenu", props.dataItem.id),
});

const rowMenu = usePopupMenu({
  isOpen: computed(() => props.dataItem.showRowMenu),
  triggerRef: rowTriggerRef,
  menuRef: rowMenuRef,
  triggerMode: "row",
  requestShow: () => emit("update:toggleRowMenu", props.dataItem.id),
  requestHide: () => emit("update:hideRowMenu", props.dataItem.id),
});
```

For a complete shared-instance implementation, see [Shared Instance Menus](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuSharedInstance.md).

## Row vs. Button Trigger Differences

`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:

- **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.
- **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.
- **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.

## Sharing One Instance Across Many Triggers (Grid-Wide)

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.

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.
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.
3. Feed `isOpen: registry.isActive` and `triggerRef: registry.activeElement` straight into `usePopupMenu`, rather than deriving them from a per-row prop.
4. Enable `anchor: true` and bind the shared `Popup` to `:offset="menu.offset"`, not `:anchor`. Kendo's `Popup.anchor` only resolves a static template-ref name once at mount; it cannot dynamically retarget a different element after the popup is already mounted. Use `anchor.activationPoint` for pointer/keyboard context menus and `getRect(trigger)` only for custom geometry.
5. Drive show/hide through `registry.activate(id)` / `registry.deactivate()` rather than binding `handleTriggerClick` directly to every shared trigger. A single instance's `isOpen` is one shared boolean, so the generic toggle handler cannot distinguish closing the current id from opening another id.

```ts
// Step 1: one registry tracks the active id and resolves it to its DOM element
const rowRegistry = useActiveIdRegistry<number>();

// Step 3: isOpen/triggerRef derive from the registry, not a per-row prop
const rowMenu = usePopupMenu({
  isOpen: rowRegistry.isActive,
  triggerRef: rowRegistry.activeElement,
  menuRef: rowMenuRef,
  triggerMode: "row",
  anchor: true,
  requestShow: () => {}, // state is set directly by the row click handler, not by the composable
  requestHide: () => rowRegistry.deactivate(),
});

// Step 2: register each row's element as it renders (e.g. inside a Grid row-render function)
const registerRowTrigger = (id: number, el: HTMLElement | null) => {
  rowRegistry.register(id, el);
};

// Step 5: toggle logic owns the open/close decision; the composable only reacts
const toggleRowMenu = (id: number) => {
  if (rowRegistry.isIdActive(id)) {
    rowRegistry.deactivate(); // closes: the reactive watcher demotes aria-expanded
    return;
  }
  rowRegistry.activate(id); // switches directly: previous trigger is demoted, new one promoted
};
```

### Cursor-Positioned Row Menus That Follow Scrolling

Enable `anchor.activationPoint`, call `setAnchorActivation(row, context)` before activating the shared row id, and bind `offset` to the Popup. The composable owns trigger-relative coordinate conversion, keyboard placement, scroll reconstruction, clipping, and cleanup.

```ts
const rowMenu = usePopupMenu({
  isOpen: rowRegistry.isActive,
  triggerRef: rowRegistry.activeElement,
  menuRef: rowMenuRef,
  triggerMode: "row",
  anchor: {
    activationPoint: { pointerOffset: { x: -20, y: -20 } },
  },
  requestShow: () => {},
  requestHide: () => rowRegistry.deactivate(),
});

const openRowMenu = (id: number, context: RowActionContext) => {
  const row = rowRegistry.resolve(id);
  if (!row || !rowMenu.setAnchorActivation(row, context)) return;
  rowRegistry.activate(id);
};
```

See [Activation-Point Anchors](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuActivationAnchor.md) for the complete standalone setup and custom keyboard placement.

For a second shared instance driven by a real `@click` handler on each trigger, close the currently active trigger explicitly before switching the active id, so its `triggerRef` still resolves to the correct element at close time:

```ts
const toggleButtonMenu = (id: number) => {
  if (buttonRegistry.isIdActive(id)) {
    buttonMenu.handleTriggerClick(); // closes: triggerRef still matches this id
    return;
  }
  if (buttonRegistry.isActive.value) {
    buttonMenu.handleTriggerClick(); // closes the OTHER trigger correctly, before switching
  }
  buttonRegistry.activate(id); // the reactive watcher promotes the new trigger's aria-expanded
  nextTick(focusFirstMenuItem); // reproduce first-item focus manually; the "open" branch never ran
};
```

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.

## Pairing with `usePopupTrap`

`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:

1. Pass the same `isOpen` and the resolved trigger element (`triggerEl`) that you gave `usePopupMenu`.
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.
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.
4. Set `returnFocusToTrigger: false`. This only disables `usePopupTrap`'s own hand-rolled Escape-focus logic.
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.
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.

`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.

```ts
import {
  POPUP_MENU_TRAP_OPTIONS,
  usePopupTrap,
} from "@featherk/composables/trap";

usePopupTrap({
  isOpen: registry.isActive,
  triggerEl: registry.activeElement,
  // Step 6: only required when this popup coexists with another in the template.
  resolvePopupEl: () => document.getElementById("my-shared-popup"),
  initialFocus: () => document.activeElement as HTMLElement | null,
  ...POPUP_MENU_TRAP_OPTIONS,
});
```

`POPUP_MENU_TRAP_OPTIONS` is an optional convenience preset for this pairing.
It disables the trap's competing focus restoration paths so `usePopupMenu`
remains the sole owner of focus restoration. `initialFocus` stays explicit
because it should preserve the focus behavior chosen by each menu. Spread the
preset last in the options object so these required integration settings win
over conflicting trap options:

```ts
usePopupTrap({
  isOpen,
  initialFocus: () => document.activeElement as HTMLElement | null,
  ...POPUP_MENU_TRAP_OPTIONS,
});
```

## API

### `usePopupMenu(options)`

#### Options

- `isOpen: Readonly<Ref<boolean>>`: Parent-owned popup visibility state observed, but never mutated, by the composable.
- `menuRef: PopupMenuElementRef`: Ref to the Kendo `Menu` element or component.
- `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.
- `triggerRef: PopupMenuElementRef`: Ref to the action trigger element or component.
- `requestShow(): void`: Opens the parent-owned popup state.
- `requestHide(): void`: Closes the parent-owned popup state.
- `focusTargetRef?: PopupMenuElementRef`: Optional element/component to focus after a keyboard-driven close.
- `resolveFocusTarget?: () => HTMLElement | null`: Dynamic focus-target resolver. It takes precedence over `focusTargetRef` and is useful for virtualized grid rows.
- `focusTargetContainerSelector?: string`: Closest trigger ancestor to focus after any keyboard-driven close. Used after `resolveFocusTarget` and `focusTargetRef`.
- `menuItemSelector?: string`: Selector for the first enabled item. Defaults to `.k-menu-item:not(.k-disabled)`.
- `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.
- `menuLabel?: MaybeRefOrGetter<string | null | undefined>`: Optional accessible name applied as `aria-label` to the rendered `ul[role="menubar"]`. Empty labels remove the managed attribute.
- `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.
- `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.

`PopupMenuElementRef` accepts a normal `HTMLElement` ref or a Vue/Kendo component ref that exposes `$el`.

#### Returns

- `setAnchorActivation(trigger, activation)`: Captures a pointer or keyboard activation for `anchor.activationPoint`; returns `false` when activation anchoring or a trigger is unavailable.
- `handleTriggerClick()`: Toggles the menu and focuses the first enabled item after opening.
- `handleTriggerKeydown(event)`: `Enter` and `Space` toggle; `Escape` closes, or focuses the configured target when already closed.
- `handleMenuEscape(event)`: Closes an open menu with keyboard modality.
- `handleMenuSelect(event, onAction?)`: Records the selection modality, requests close, then invokes optional business logic.
- `handlePopupClose()`: Restores focus after Kendo Popup has closed.
- `offset`: A computed `{ left, top }` value for Kendo Popup's `:offset`; it is `{ left: 0, top: 0 }` while closed.

#### Types

```ts
export type PopupMenuCloseReason =
  | "keyboard"
  | "mouse-selection"
  | "outside-click"
  | "trigger-click"
  | "anchor-hidden"
  | null;
export type PopupMenuCloseTrigger = Exclude<PopupMenuCloseReason, null>;
export type PopupMenuAlign = {
  horizontal: "left" | "center" | "right";
  vertical: "top" | "center" | "bottom";
};
export type PopupMenuCollision = {
  horizontal: "fit" | "flip";
  vertical: "fit" | "flip";
};
export type KendoMenuSelectEvent = {
  item?: { text?: string };
  event?: { type?: string } | null;
};
```

## Aligning the Popup to the Anchor (`anchorAlign` / `popupAlign`)

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.

- `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.
- `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.
- `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.

```ts
const buttonMenu = usePopupMenu({
  isOpen: registry.isActive,
  triggerRef: registry.activeElement,
  menuRef: menuRef,
  triggerMode: "button",
  anchor: {
    anchorAlign: { horizontal: "right", vertical: "bottom" },
    popupAlign: { horizontal: "right", vertical: "top" },
  },
  requestShow: () => {},
  requestHide: () => registry.deactivate(),
});
```

```vue
<Popup :show="isOpen" :offset="buttonMenu.offset.value">
  <strong v-if="activeTitle">{{ activeTitle }}</strong>
  <Menu ref="menuRef" :items="activeMenuItems" />
</Popup>
```

## Handling Viewport Collision

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.

- `"flip"` moves the popup to the opposite side of the anchor when that reduces overflow.
- `"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.
- Mixed strategies are supported, for example `{ horizontal: "fit", vertical: "flip" }`.
- 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.
- Collision detection requires measuring the popup's real size, so `popupRect` is measured via `ResizeObserver` whenever `anchor` is configured.
- `"center"` alignment on either axis never flips - flipping center to center is a no-op by definition.
- 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`).
- The underlying math is a pure, stateless function - `computeCollisionOffset` in `@featherk/composables/geometry` - reusable outside `usePopupMenu` for any anchor/popup positioning problem.

```ts
anchor: {
  collision: { horizontal: "fit", vertical: "flip" },
}
```

## Behavior

- Pointer click, `Enter`, and `Space` toggle the menu.
- `aria-haspopup="menu"` is a static, consumer-owned template attribute; the composable never sets or removes it.
- `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.
- 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.
- 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.
- Opening focuses the first enabled Kendo Menu item after Vue renders it.
- Outside clicks close the menu, while the trigger is ignored to prevent a close/reopen race.
- 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.
- `Escape` on the trigger or inside the menu uses the keyboard close path.
- Keyboard-driven closes focus `resolveFocusTarget()` or `focusTargetRef`, then fall back to the trigger.
- Mouse selection closes restore focus to the trigger.
- Outside-click and trigger-click closes do not force focus restoration.
- An anchor-hidden close does not restore focus, preventing an off-screen grid row from being scrolled back into view.
- Business-specific actions are never implemented by the composable.
