Skip to content

Popup Framework

The popup framework provides a unified API for creating, positioning, and managing popup elements across all plugins. It handles click-outside dismissal, Escape key closing, focus restoration, and popup stacking for nested menus.

The PopupManager is the central lifecycle manager. It is registered as a service and available to all plugins.

import { PopupServiceKey } from '@notectl/core/plugins/shared';
const popups = context.getService(PopupServiceKey);
const handle = popups.open({
anchor: buttonElement,
content: (container, close) => {
const item = document.createElement('button');
item.textContent = 'Click me';
item.addEventListener('click', () => {
// do something
close();
});
container.appendChild(item);
},
ariaRole: 'menu',
ariaLabel: 'My menu',
restoreFocusTo: buttonElement,
});
interface PopupCloseOptions {
readonly restoreFocusTo?: HTMLElement | null;
}
interface PopupConfig {
/** The element or DOMRect to anchor the popup to. */
readonly anchor: HTMLElement | DOMRect;
/** Callback that renders content into the popup container. */
readonly content: (container: HTMLElement, close: (options?: PopupCloseOptions) => void) => void;
/** Additional CSS class name(s) for the popup element. */
readonly className?: string;
/** ARIA role for the popup. */
readonly ariaRole?: 'menu' | 'listbox' | 'grid' | 'dialog';
/** Accessible label for the popup. */
readonly ariaLabel?: string;
/** Element to restore focus to when the popup closes. */
readonly restoreFocusTo?: HTMLElement;
/** Callback invoked when the popup is closed. */
readonly onClose?: () => void;
/** Positioning strategy relative to the anchor. Default: 'below-start'. */
readonly position?: PopupPosition;
/** Parent popup handle for nested popup stacking. */
readonly parent?: PopupHandle;
/** Override the reference node used to determine the shadow root for appending. */
readonly referenceNode?: Node;
}
interface PopupHandle {
/** Closes this popup and any child popups. */
close(options?: PopupCloseOptions): void;
/** Returns the popup DOM element. */
getElement(): HTMLElement;
}
interface PopupServiceAPI {
/** Opens a popup with the given configuration. */
open(config: PopupConfig): PopupHandle;
/** Closes the topmost popup. */
close(): void;
/** Closes all open popups. */
closeAll(): void;
/** Returns true if any popup is currently open. */
isOpen(): boolean;
}

To create a submenu or nested popup, pass the parent handle:

const parentHandle = popups.open({ /* ... */ });
// Later, inside the parent popup's content:
const childHandle = popups.open({
anchor: submenuTrigger,
parent: parentHandle,
position: 'right',
// ...
});

Closing a parent popup automatically closes all its children.

  • Click outside: Clicking outside all open popups closes the topmost popup
  • Escape key: Handled by the keyboard pattern attached to the popup content
  • Focus restoration: When restoreFocusTo is set, focus returns to that element on close
  • Shadow DOM aware: Popups are appended to the shadow root when the editor lives inside one

Positions a popup element relative to an anchor rectangle using fixed positioning. Automatically clamps to viewport edges.

import { positionPopup } from '@notectl/core/plugins/shared';
positionPopup(popupElement, anchorRect, {
position: 'below-start',
offset: 4,
});
Value Description
'below-start' Below the anchor, aligned to the left edge
'below-end' Below the anchor, aligned to the right edge
'right' To the right of the anchor, aligned to the top edge
interface PositionOptions {
readonly position: PopupPosition;
/** Gap between anchor and popup in pixels. Default: 2. */
readonly offset?: number;
/** Whether the editor uses right-to-left layout. */
readonly isRtl?: boolean;
}

Appends an element to the appropriate root node. If the reference node lives inside a shadow root, the element is appended there; otherwise it is appended to document.body.

import { appendToRoot } from '@notectl/core/plugins/shared';
appendToRoot(myElement, context.getContainer());

Three WAI-ARIA keyboard navigation patterns are available for popup content. Each function returns a cleanup function that removes the event listener.

Implements the WAI-ARIA Menu pattern with roving tabindex.

import { attachMenuKeyboard } from '@notectl/core/plugins/shared';
const cleanup = attachMenuKeyboard({
container: menuElement,
itemSelector: '[role="menuitem"]',
onActivate: (item) => { /* execute action */ },
onClose: () => handle.close(),
getActiveElement: () => document.activeElement,
});
Key Action
ArrowDown Focus next item
ArrowUp Focus previous item
Home Focus first item
End Focus last item
Enter / Space Activate focused item
Escape Close menu

Implements the WAI-ARIA Listbox pattern.

import { attachListboxKeyboard } from '@notectl/core/plugins/shared';
const cleanup = attachListboxKeyboard({
container: listElement,
itemSelector: '[role="option"]',
onSelect: (item) => { /* handle selection */ },
onClose: () => handle.close(),
});
Key Action
ArrowDown Focus next option
ArrowUp Focus previous option
Enter / Space Select focused option
Escape Close listbox

Implements the WAI-ARIA Grid pattern with 2D arrow navigation.

import { attachGridKeyboard } from '@notectl/core/plugins/shared';
const cleanup = attachGridKeyboard({
container: gridElement,
cellSelector: '[role="gridcell"]',
columns: 10,
totalCells: 70,
onSelect: (cell) => { /* handle selection */ },
onClose: () => handle.close(),
onNavigate: (index) => { /* optional: update hover state */ },
});
Key Action
ArrowRight Focus next cell
ArrowLeft Focus previous cell
ArrowDown Focus cell below
ArrowUp Focus cell above
Home Focus first cell in current row
End Focus last cell in current row
Enter / Space Select focused cell
Escape Close grid

Renders an accessible color picker grid with role="grid" semantics, full keyboard navigation, and active-color indication. Used internally by TextColorPlugin, HighlightPlugin, and table border color pickers.

import { renderColorGrid } from '@notectl/core/plugins/shared';
renderColorGrid(container, {
colors: ['#000000', '#FF0000', '#00FF00', '#0000FF'],
columns: 2,
ariaLabel: 'Color picker',
ariaLabelPrefix: 'Select color',
activeColor: '#FF0000',
onSelect: (color) => { /* apply color */ },
onClose: () => handle.close(),
});
interface ColorGridConfig {
/** Array of hex color strings. */
readonly colors: readonly string[];
/** Number of columns per row. */
readonly columns: number;
/** Accessible label for the grid element. */
readonly ariaLabel: string;
/** Prefix for individual swatch aria-labels (e.g. "Text color"). */
readonly ariaLabelPrefix: string;
/** Currently selected color (highlighted with active state). */
readonly activeColor: string | null;
/** Called when a color swatch is selected. */
readonly onSelect: (color: string) => void;
/** Called when the grid should close (Escape key). */
readonly onClose: () => void;
/** Custom label formatter for swatch aria-labels. */
readonly swatchLabel?: (colorName: string) => string;
/** When true, swatch title shows human-readable color name instead of hex. */
readonly titleAsName?: boolean;
}

All popup framework types and functions are available from the shared plugin package:

import {
// Popup lifecycle
PopupManager,
PopupServiceKey,
type PopupConfig,
type PopupHandle,
type PopupServiceAPI,
// Keyboard patterns
attachMenuKeyboard,
attachListboxKeyboard,
attachGridKeyboard,
type MenuKeyboardConfig,
type ListboxKeyboardConfig,
type GridKeyboardConfig,
// Positioning
positionPopup,
appendToRoot,
type PopupPosition,
type PositionOptions,
// Color grid
renderColorGrid,
type ColorGridConfig,
} from '@notectl/core/plugins/shared';