# usePopupMenu

[Back to Composables README](https://github.com/NantHealth/featherk/blob/integration/packages/composables/README.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 your business action runs while the active menu context is still available, before the composable closes the popup.
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.handleActionButtonClick"
      @keydown="menu.handleActionButtonKeydown"
    >
      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.handleActionMenuEscape"
        @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: act while the active context is available; usePopupMenu closes afterward
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 `resolveFocusTarget` to locate the current row when the popup closes by keyboard. The resolver is evaluated at focus time, after the grid and popup have settled.

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 `resolveFocusTarget` so keyboard close restores focus to the current virtualized row.
6. Route menu selection through `handleMenuSelect(event, onAction)` so row action logic can use its active state before the popup closes.

```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 current virtualized grid row
  resolveFocusTarget: () => {
    const trigger = triggerRef.value?.$el as HTMLElement | undefined;
    return (
      trigger?.closest(".k-table-row[data-grid-row-index]") ?? null
    ) as HTMLElement | null;
  },
});

// Step 6: run row-specific business action before usePopupMenu closes
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),
});
```

See [UsePopupMenu.vue](https://github.com/NantHealth/featherk/blob/integration/demos/src/views/composables/UsePopupMenu.vue) for the full reference implementation: exactly two shared instances (one row-mode, one button-mode) for the whole grid, with per-row/button trigger registries and offset-positioned Popups instead of per-row `usePopupMenu` calls.

## 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 Kendo's `anchorAlign` and `popupAlign` props when the popup should align to a specific edge of its button. Use `anchor: { getRect: () => ... }` for cursor-positioned menus.
5. Drive show/hide through `registry.activate(id)` / `registry.deactivate()` rather than binding `handleActionButtonClick` directly to every trigger's `@click`. A single instance's `isOpen` is one shared boolean, so `handleActionButtonClick()` cannot tell "close this trigger" apart from "open a different trigger" — calling it while another id is active will close the wrong thing.

```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
};
```

For a second shared instance driven by a real `@click` handler on each trigger (e.g. a per-row action button, where `handleActionButtonClick` toggle semantics would otherwise misfire on a different trigger), close the currently active trigger explicitly through the composable before switching the active id, so its `triggerRef` still resolves to the correct element at close time:

```ts
const toggleButtonMenu = (id: number) => {
  if (buttonRegistry.isIdActive(id)) {
    buttonMenu.handleActionButtonClick(); // closes: triggerRef still matches this id
    return;
  }
  if (buttonRegistry.isActive.value) {
    buttonMenu.handleActionButtonClick(); // 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 [UsePopupMenu.vue](https://github.com/NantHealth/featherk/blob/integration/demos/src/views/composables/UsePopupMenu.vue) for the complete working version of both patterns, including the registry-building `row-render` function and composable-owned offset tracking. `useActiveIdRegistry` is now part of `@featherk/composables`, so shared-instance consumers can import it directly instead of keeping a demo-local copy.

## API

### `usePopupMenu(options)`

#### Options

- `isOpen: Ref<boolean>`: Parent-owned popup visibility state.
- `menuRef: PopupMenuElementRef`: Ref to the Kendo `Menu` element or component.
- `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.
- `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 for dynamically retargeted Popups. `true` uses the resolved trigger's rectangle and nearest scrollable ancestor. `PopupMenuAnchorOptions` accepts `clipRoot`, `hideWhenAnchorClipped`, `intersectionThreshold`, and `getRect` for pointer-positioned menus. The threshold defaults to `0.5`; use `intersectionThreshold: 0` when any partially visible trigger should remain open. A fully clipped trigger closes with `"anchor-hidden"` without restoring focus.

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

#### Returns

- `handleActionButtonClick()`: Toggles the menu and focuses the first enabled item after opening.
- `handleActionButtonKeydown(event)`: `Enter` and `Space` toggle; `Escape` closes, or focuses the configured target when already closed.
- `handleActionMenuEscape(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 KendoMenuSelectEvent = {
  item?: { text?: string };
  event?: { type?: string } | null;
};
```

## 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 runs the optional business callback while the active state is still available, then records modality and closes the popup.
- `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.
