# Grid Popup Menu Orchestration

[Back to usePopupMenu](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenu.md)

This guide is the starting point for a Kendo Grid that uses `useGridA11y` and
needs both a row context menu and an action-button menu.

## Big Picture

A grid with 100 rows may appear to need 100 row popups and 100 button popups.
It does not. Those are logical menus: each interaction belongs to a particular
record, but only one record in each menu family can be active at a time.

Render one physical `Popup`/`Menu` pair for all row triggers and one physical
pair for all equivalent button triggers. A registry connects each shared pair
to the trigger that is active now.

For a grid with both trigger kinds, the final architecture has:

- two `useActiveIdRegistry` instances: one for rows and one for buttons;
- one `useExclusiveGroup` containing those registries;
- two `usePopupMenu` instances: one with `triggerMode: "row"` and one with
  `triggerMode: "button"`;
- two rendered `Popup`/`Menu` pairs total, regardless of row count.

It is one shared popup controller and template **per menu behavior**, not one
controller per row. A row trigger and a button trigger should not share the same
`usePopupMenu` instance because they have different trigger semantics,
positioning, menu items, and focus behavior.

```mermaid
flowchart LR
  A[Grid row click or key] --> B[useGridRowAction]
  B --> C[Row registry]
  C --> D[Shared row usePopupMenu]
  D --> E[One row Popup and Menu]

  F[Action button click or key] --> G[Button registry]
  G --> H[Shared button usePopupMenu]
  H --> I[One button Popup and Menu]

  C <--> J[useExclusiveGroup]
  G <--> J
  K[Grid keyboard event] --> L[useGridA11y]
  K --> B
```

## Why This Orchestration Exists

A boolean answers whether a menu is open, but a shared popup also needs to know
which record owns it and which current DOM element should anchor it. This matters
when sorting, paging, or virtualization replaces grid rows.

Each composable owns one part of that problem:

| Composable            | Responsibility                                                                                                                          |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `useGridA11y`         | Moves focus through the grid and maintains grid keyboard behavior. It does not open menus.                                              |
| `useGridRowAction`    | Converts valid row clicks and Enter/Space presses into one normalized row action. It ignores buttons and other interactive descendants. |
| `useActiveIdRegistry` | Tracks the active record id and resolves that id to its currently mounted row or button element.                                        |
| `useExclusiveGroup`   | Closes the other registry before a row menu or button menu opens. It does not decide which id to activate.                              |
| `usePopupMenu`        | Owns menu toggling, ARIA synchronization, positioning, outside-click handling, selection modality, and focus restoration.               |

The registry is the bridge. Its `isActive` becomes `usePopupMenu.isOpen`, and
its `activeElement` becomes `usePopupMenu.triggerRef`. Computed menu items and
labels use `activeId` to resolve the active record.

The exclusivity group coordinates independent registries without coupling
`usePopupMenu` to grid policy. The consumer calls `deactivateOthers(registry)`
before activating that registry. This guarantees that the row and button menus
cannot remain open together.

The shared popup uses `offset` positioning because Kendo's `anchor` prop resolves
a template-ref name when the Popup mounts; it cannot reliably retarget one
mounted Popup from row A to row B. `usePopupMenu` can remeasure the registry's
current element after scrolling, resizing, or DOM replacement.

## Quick Start

1. Create one registry for each menu behavior and register both with one
   `useExclusiveGroup`.
2. Register every rendered row and button under its record id.
3. Create one `usePopupMenu` instance per registry.
4. Route row activation through `useGridRowAction` and button activation through
   the button popup controller, deactivating the other group member first.
5. Render one Popup/Menu pair per controller and derive its content from the
   active id.

## Complete Example

The template contains many triggers but only two popup trees:

```html
<template>
  <div ref="gridContainerRef" class="grid-container">
    <Grid
      ref="gridRef"
      :data-items="records"
      :columns="columns"
      :data-item-key="'id'"
      :row-render="renderRow"
      @rowclick="handleRowClick"
      @keydown="handleGridKeydown"
    >
      <template #actionsCell="{ props }">
        <td>
          <Button
            :ref="
              (element) =>
                buttonRegistry.register(props.dataItem.id, element)
            "
            :svg-icon="moreVerticalIcon"
            type="button"
            aria-label="Quick actions"
            aria-haspopup="menu"
            aria-expanded="false"
            tabindex="-1"
            @click="toggleButtonMenu(props.dataItem.id)"
            @keydown="handleButtonKeydown(props.dataItem.id, $event)"
          />
        </td>
      </template>
    </Grid>
  </div>

  <!-- Step 5: one physical row menu for every row trigger. -->
  <Popup
    id="shared-row-menu-popup"
    :show="rowRegistry.isActive.value"
    :offset="rowMenu.offset.value"
    @close="rowMenu.handlePopupClose"
  >
    <strong>{{ rowMenuLabel }}</strong>
    <Menu
      ref="rowMenuRef"
      :items="rowMenuItems"
      :vertical="true"
      @keydown.escape="rowMenu.handleMenuEscape"
      @select="handleRowMenuSelect"
    />
  </Popup>

  <!-- Step 5: one physical button menu for every action button. -->
  <Popup
    id="shared-button-menu-popup"
    :show="buttonRegistry.isActive.value"
    :offset="buttonMenu.offset.value"
    @close="buttonMenu.handlePopupClose"
  >
    <strong>{{ buttonMenuLabel }}</strong>
    <Menu
      ref="buttonMenuRef"
      :items="buttonMenuItems"
      :vertical="true"
      @keydown.escape="buttonMenu.handleMenuEscape"
      @select="handleButtonMenuSelect"
    />
  </Popup>
</template>

<script setup lang="ts">
import {
  computed,
  onMounted,
  ref,
  type ComponentPublicInstance,
} from "vue";
import { Grid } from "@progress/kendo-vue-grid";
import { Button } from "@progress/kendo-vue-buttons";
import { Menu } from "@progress/kendo-vue-layout";
import { Popup } from "@progress/kendo-vue-popup";
import { moreVerticalIcon } from "@progress/kendo-svg-icons";
import {
  useGridA11y,
  useGridRowAction,
  type RowActionContext,
} from "@featherk/composables/grid";
import {
  usePopupMenu,
  type KendoMenuSelectEvent,
} from "@featherk/composables/menu";
import {
  useActiveIdRegistry,
  useExclusiveGroup,
} from "@featherk/composables/registry";

type RecordItem = {
  id: number;
  name: string;
  status: string;
};

const records = ref<RecordItem[]>([
  { id: 1, name: "Alpha", status: "Active" },
  { id: 2, name: "Beta", status: "Pending" },
]);

const columns = [
  { field: "name", title: "Name" },
  { field: "status", title: "Status" },
  { cell: "actionsCell", title: "Actions", width: "90px" },
];

const gridRef = ref<ComponentPublicInstance | null>(null);
const gridContainerRef = ref<HTMLElement | null>(null);
const rowMenuRef = ref<ComponentPublicInstance | null>(null);
const buttonMenuRef = ref<ComponentPublicInstance | null>(null);

// Step 1: each menu behavior has one registry. The group coordinates them.
const rowRegistry = useActiveIdRegistry<number>();
const buttonRegistry = useActiveIdRegistry<number>();
const menuGroup = useExclusiveGroup();
menuGroup.register(rowRegistry);
menuGroup.register(buttonRegistry);

const activeRowRecord = computed(() =>
  records.value.find((record) => record.id === rowRegistry.activeId.value),
);
const activeButtonRecord = computed(() =>
  records.value.find((record) => record.id === buttonRegistry.activeId.value),
);

const rowMenuLabel = computed(() =>
  activeRowRecord.value ? `Row actions for ${activeRowRecord.value.name}` : "",
);
const buttonMenuLabel = computed(() =>
  activeButtonRecord.value
    ? `Quick actions for ${activeButtonRecord.value.name}`
    : "",
);
const rowMenuItems = computed(() => [
  { text: "Open record", id: "open" },
  { text: "View history", id: "history" },
]);
const buttonMenuItems = computed(() => [
  { text: "Edit", id: "edit" },
  { text: "Archive", id: "archive" },
]);

const closeRowMenu = () => rowRegistry.deactivate();
const closeButtonMenu = () => buttonRegistry.deactivate();

// Step 3: this single instance follows whichever row id is active.
const rowMenu = usePopupMenu({
  isOpen: rowRegistry.isActive,
  triggerRef: rowRegistry.activeElement,
  menuRef: rowMenuRef,
  triggerMode: "row",
  menuLabel: rowMenuLabel,
  anchor: {
    clipRoot: gridContainerRef,
    activationPoint: true,
  },
  // Row activation captures its point before activating the registry.
  requestShow: () => {},
  requestHide: closeRowMenu,
});

// Step 3: this single instance follows whichever action button is active.
const buttonMenu = usePopupMenu({
  isOpen: buttonRegistry.isActive,
  triggerRef: buttonRegistry.activeElement,
  menuRef: buttonMenuRef,
  triggerMode: "button",
  menuLabel: buttonMenuLabel,
  anchor: { clipRoot: gridContainerRef },
  requestShow: (id) => {
    if (typeof id === "number") buttonRegistry.activate(id);
  },
  requestHide: closeButtonMenu,
});

// Step 2: Kendo rowRender registers every current row DOM element. Static ARIA
// remains on every row; usePopupMenu changes only aria-expanded.
const renderRow = (h: any, tr: any, slots: any, props: any) =>
  h(
    "tr",
    {
      ...tr.props,
      "aria-label": `${props.dataItem.name}, ${props.dataItem.status}`,
      "aria-haspopup": "menu",
      "aria-expanded": "false",
      ref: (element: HTMLElement | null) =>
        rowRegistry.register(props.dataItem.id, element),
    },
    slots,
  );

// Step 4: useGridRowAction ignores the nested Button automatically, so a
// button click cannot also open the row menu.
const { handleRowClick, handleRowKeyDown } = useGridRowAction<RecordItem>({
  dataItems: records,
  onRowAction: (record, context) => toggleRowMenu(record.id, context),
});

const toggleRowMenu = (
  id: number,
  context: RowActionContext<RecordItem>,
) => {
  if (rowRegistry.isIdActive(id)) {
    closeRowMenu();
    return;
  }

  const row = rowRegistry.resolve(id);
  if (!row || !rowMenu.setAnchorActivation(row, context)) return;

  // The group owns cross-menu exclusivity; this consumer owns the row id.
  menuGroup.deactivateOthers(rowRegistry);
  rowRegistry.activate(id);
};

const toggleButtonMenu = (id: number) => {
  if (buttonRegistry.isIdActive(id)) {
    buttonMenu.handleTriggerClick(id);
    return;
  }

  // The group closes the row menu. Switching within this registry still needs
  // a close first so handleTriggerClick takes its open path for the new id.
  menuGroup.deactivateOthers(buttonRegistry);
  if (buttonRegistry.isActive.value) closeButtonMenu();
  buttonMenu.handleTriggerClick(id);
};

const handleButtonKeydown = (id: number, event: KeyboardEvent) => {
  if (event.key === "Enter" || event.code === "Space") {
    event.preventDefault();
    toggleButtonMenu(id);
    return;
  }

  buttonMenu.handleTriggerKeydown(event);
};

const { handleGridKeyDown, initA11y } = useGridA11y(gridRef);
const handleGridKeydown = (event: KeyboardEvent) => {
  handleGridKeyDown(event);
  handleRowKeyDown(event);
};

const handleRowMenuSelect = (event: KendoMenuSelectEvent) => {
  const record = activeRowRecord.value;
  rowMenu.handleMenuSelect(event, (selection) => {
    if (selection.item && record) {
      console.log(selection.item.id, record);
    }
  });
};

const handleButtonMenuSelect = (event: KendoMenuSelectEvent) => {
  const record = activeButtonRecord.value;
  buttonMenu.handleMenuSelect(event, (selection) => {
    if (selection.item && record) {
      console.log(selection.item.id, record);
    }
  });
};

onMounted(initA11y);
</script>
```

## What Happens When a Row Opens

1. `useGridRowAction` validates the click or keyboard event and supplies the
   record plus activation coordinates.
2. The consumer resolves the row element from `rowRegistry`.
3. `setAnchorActivation` stores a trigger-relative pointer or keyboard point.
4. `menuGroup.deactivateOthers(rowRegistry)` closes the button menu.
5. `rowRegistry.activate(id)` changes both `isOpen` and `activeElement`.
6. The one row Popup renders content for that id, and `usePopupMenu` sets the
   active row's `aria-expanded` to `true`.

When another row opens, the registry changes identity. `usePopupMenu` demotes the
old row, follows the new row element, and reuses the same Popup/Menu tree.

## What Happens When a Button Opens

1. The button is already registered under its record id by its template ref.
2. `menuGroup.deactivateOthers(buttonRegistry)` closes the row menu.
3. `handleTriggerClick(id)` forwards the id to `requestShow`.
4. `buttonRegistry.activate(id)` makes that button the active trigger.
5. The one button Popup follows the active button and derives its content from
   `buttonRegistry.activeId`.

`useGridRowAction` ignores button descendants by default. This prevents a button
click from also being interpreted as a row activation; no propagation workaround
is required.

## Multiple Buttons Per Row

The button registry is not limited to one button in each row. It can register
any number of equivalent buttons that all use the same shared Popup/Menu. Give
each trigger a composite id containing both its button key and row id:

```ts
type ButtonKey = "edit" | "more";
type ButtonTriggerId = `${ButtonKey}:${number}`;

const buttonRegistry = useActiveIdRegistry<ButtonTriggerId>();

const getButtonTriggerId = (
  key: ButtonKey,
  rowId: number,
): ButtonTriggerId => `${key}:${rowId}`;
```

Register and activate each button with that composite id:

```vue
<Button
  :ref="
    (element) =>
      buttonRegistry.register(
        getButtonTriggerId('edit', props.dataItem.id),
        element,
      )
  "
  aria-label="Edit actions"
  aria-haspopup="menu"
  aria-expanded="false"
  @click="toggleButtonMenu(getButtonTriggerId('edit', props.dataItem.id))"
/>

<Button
  :ref="
    (element) =>
      buttonRegistry.register(
        getButtonTriggerId('more', props.dataItem.id),
        element,
      )
  "
  aria-label="More actions"
  aria-haspopup="menu"
  aria-expanded="false"
  @click="toggleButtonMenu(getButtonTriggerId('more', props.dataItem.id))"
/>
```

The active composite id tells the consumer both which row and which button owns
the popup. Parse it to derive the shared menu's label and items:

```ts
const activeButton = computed(() => {
  const id = buttonRegistry.activeId.value;
  if (!id) return null;

  const [key, rowId] = id.split(":") as [ButtonKey, string];
  const record = records.value.find((item) => item.id === Number(rowId));
  return record ? { key, record } : null;
});

const buttonMenuItems = computed(() =>
  activeButton.value?.key === "edit"
    ? [{ text: "Rename", id: "rename" }]
    : [{ text: "Archive", id: "archive" }],
);
```

This still uses one button registry, one `usePopupMenu`, and one Popup/Menu tree
for the whole grid. Use separate registries and popup controllers only when the
buttons represent genuinely different menu behaviors, such as different focus,
positioning, lifecycle, or exclusivity rules. For a reusable collection of
ActionCell triggers, `@featherk/ui` also provides `useActionCellMenu`, which owns
this composite-id orchestration.

## Rules to Keep

- Keep separate registries and popup controllers for row and button behavior.
- One button registry may contain many buttons per row when they share one menu
  behavior; use composite ids so every trigger remains unique.
- Register both registries with `useExclusiveGroup` and call
  `deactivateOthers` before activating either one.
- Render `aria-haspopup="menu"` and `aria-expanded="false"` on every trigger.
- Register elements through template refs so rerenders and virtualization update
  the registry.
- Capture row activation before activating the row id.
- Bind `usePopupMenu.offset` instead of dynamically changing Kendo's `anchor`.
- Derive labels, items, and business context from the active registry id.
- Do not add row roles or focus behavior in popup code; `useGridA11y` and the
  grid's interaction model own those semantics.

## Trapping Focus in a Shared Popup

Neither `rowMenu` nor `buttonMenu` above traps `Tab` inside its Popup - that's
intentional (see `usePopupMenu.md`'s intro). If a specific shared popup needs
it anyway, pair it with `usePopupTrap` using the same registry, and disable
both its own Escape-focus logic and the underlying `focus-trap` library's
independent `returnFocusOnDeactivate` default, so `usePopupMenu` remains the
sole owner of close/focus-restoration decisions. Both shared Popups above stay
permanently mounted (only `:show` toggles visibility) with pure `:offset`
positioning (no real Kendo `anchor` ref), so `usePopupTrap`'s default
class-based lookup can't reliably tell them apart - pin each trap to its own
Popup by the `id` it already has:

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

usePopupTrap({
  isOpen: rowRegistry.isActive,
  triggerEl: rowRegistry.activeElement,
  resolvePopupEl: () => document.getElementById("shared-row-menu-popup"),
  initialFocus: () => document.activeElement as HTMLElement | null,
  ...POPUP_MENU_TRAP_OPTIONS,
});
usePopupTrap({
  isOpen: buttonRegistry.isActive,
  triggerEl: buttonRegistry.activeElement,
  resolvePopupEl: () => document.getElementById("shared-button-menu-popup"),
  initialFocus: () => document.activeElement as HTMLElement | null,
  ...POPUP_MENU_TRAP_OPTIONS,
});
```

`usePopupTrap` already defaults `allowOutsideClick` to `true` - without it, a
genuinely active trap on one menu would swallow clicks on the *other* trigger
in the same row (e.g. the row menu trapping Tab while the user tries to click
the action button). See `usePopupMenu.md`'s "Pairing with `usePopupTrap`"
section for why each of these options is required.

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