# Shared Popup Menu Lifecycle with `useActiveIdRegistry`

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

## The Story

Imagine a grid with 500 rows, but only one popup menu instance mounted for the entire grid.

When the user activates row `42`, the application needs to answer three questions:

1. Which ID is active?
2. Which DOM element belongs to that ID?
3. Which shared popup should display the corresponding content?

`useActiveIdRegistry` answers all three. It is the bridge between repeated grid items and a shared popup menu.

## Registering Each Trigger

As rows render, the consumer connects each stable row ID to its DOM element:

```ts
rowRegistry.register(props.dataItem.id, rowElement);
```

In a render callback, this commonly looks like:

```ts
ref: (el: HTMLElement | null) =>
  rowRegistry.register(props.dataItem.id, el),
```

The registry does not discover row elements automatically. Registration is what makes `activeElement` useful.

When a row is unmounted or virtualized away, the ref callback receives `null`, and the registry removes that ID.

## Activating the Shared Popup

When the user activates a row, the consumer first checks whether that same row is already active:

```ts
if (rowRegistry.isIdActive(id)) {
  hideRowMenu();
  return;
}
```

For a new row, the consumer resolves the element, captures any positioning information, and activates the ID:

```ts
const row = rowRegistry.resolve(id);
if (!row) return;

if (!rowMenu.setAnchorActivation(row, context)) return;

rowRegistry.activate(id);
```

The important ordering is:

1. Resolve the element.
2. Validate or capture activation geometry.
3. Activate the ID.

That prevents the shared popup from becoming open for an ID whose DOM element is unavailable.

## Connecting the Registry to `usePopupMenu`

The shared `usePopupMenu` instance consumes derived registry state:

```ts
const rowMenu = usePopupMenu({
  isOpen: rowRegistry.isActive,
  triggerRef: rowRegistry.activeElement,
  menuRef: rowMenuRef,
  triggerMode: "row",
  requestHide: () => hideRowMenu(),
});
```

The registry supplies the shared menu's current context:

- `activeId` selects the current data item.
- `activeElement` supplies the current trigger.
- `isActive` controls popup visibility.
- Computed menu items and labels derive from `activeId`.

This is the central pattern used by the local shared-popup demos.

## Deactivation Is the Other Half

Every close path must eventually clear the registry:

```ts
const hideRowMenu = () => {
  rowRegistry.deactivate();
  nextTick(updateRowDomStates);
};
```

That includes:

- clicking the active trigger again
- selecting a menu item
- pressing Escape
- clicking outside
- the active row leaving the clipping area
- switching from a row menu to a button menu
- replacing the active ID with another ID

If deactivation is missed, the popup can remain logically open after the visual UI disappears. A stale `activeId` can also cause the wrong row to be treated as active, `activeElement` to point at an old element, focus restoration to target a stale trigger, or the next popup open to reuse stale menu data.

## One Registry per Popup Family

The shared popup demo uses separate registries for row and button menus:

```ts
const rowRegistry = useActiveIdRegistry<number>();
const buttonRegistry = useActiveIdRegistry<number>();
```

This is intentional. The two menu families have different trigger types, positioning rules, content, and focus semantics.

When opening a row menu, close the button menu first:

```ts
hideButtonMenu();
rowRegistry.activate(id);
```

When opening a button menu, close the row menu first:

```ts
hideRowMenu();
buttonMenu.handleTriggerClick(id);
```

The rule is:

> One registry represents one mutually exclusive active-popup family.

Do not use one registry merely because two menus happen to appear on the same screen.

## When Menu Families Compete

Keep the registry lifecycle and popup lifecycle separate. Use
`useExclusiveGroup` when independent menu families must never be open at the
same time, and deactivate competing registries before activating the next
one.

For the complete exclusivity pattern and focus behavior, see [Focus and
Exclusivity](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/menu/usePopupMenuFocusAndExclusivity.md).

## Composite IDs for ActionCell

ActionCell has multiple triggers per row, so a row number alone is not enough. The consumer creates a deterministic composite ID:

```ts
const getActionTriggerId = (
  trigger: ActionCellTrigger<Patient>,
  patient: Patient,
) => `${trigger.key}:${patient.id}`;
```

An active ID such as `patient:42` identifies both the trigger type and the data item. The consumer can derive the active trigger, patient, title, and menu items from that one ID.

This lets one registry control all ActionCell trigger types while still identifying the exact active trigger.

## Consumer Checklist

For a shared popup backed by `useActiveIdRegistry`:

1. Create one registry per popup family.
2. Register each rendered trigger with a stable ID.
3. Unregister through the ref callback when the element disappears.
4. Pass `registry.isActive` as `usePopupMenu.isOpen`.
5. Pass `registry.activeElement` as `triggerRef`.
6. Derive labels and menu items from `registry.activeId`.
7. Activate only after resolving and validating the trigger element.
8. Deactivate on every close path.
9. Close other registries before activating a competing menu.
10. Use `useExclusiveGroup` when independent registry families must be mutually exclusive.

The shortest mental model is:

> Registration answers “where is this ID?” Activation answers “which ID owns the popup?” Deactivation answers “does the popup still have an owner?”
