contextMenu function

BloomNode contextMenu({
  1. required BloomNode child,
  2. required List<MenuItemConfig> items,
  3. String extraClassName = '',
})

Context menu descriptor triggered by right-click.

Reuses MenuItemConfig from dropdown_menu.dart.

Implementation

BloomNode contextMenu({
  required BloomNode child,
  required List<MenuItemConfig> items,
  String extraClassName = '',
}) {
  final isOpen = signal<bool>(false);
  final position = signal<({double x, double y})>((x: 0.0, y: 0.0));

  return Div(
    className: cn(['relative inline-block', extraClassName]),
    on: {
      'contextmenu': (BloomEvent e) {
        e.preventDefault();
        e.stopPropagation();
        final x = e.clientX ?? e.offsetX ?? 0.0;
        final y = e.clientY ?? e.offsetY ?? 0.0;
        position.value = (x: x, y: y);
        isOpen.value = true;
      },
    },
    children: [
      child,
      Live(() {
        if (!isOpen.value) return const Fragment(children: []);

        final pos = position.value;
        final isFixed = pos.x > 0 || pos.y > 0;

        return Fragment(
          children: [
            // Full-screen backdrop
            Div(
              className: 'fixed inset-0 z-50',
              on: {
                'click': (BloomEvent e) {
                  e.stopPropagation();
                  isOpen.value = false;
                },
                'contextmenu': (BloomEvent e) {
                  e.preventDefault();
                  e.stopPropagation();
                  isOpen.value = false;
                },
              },
            ),
            // Floating context menu panel
            Div(
              attrs: const {
                'role': 'menu',
                'aria-orientation': 'vertical',
              },
              style: isFixed ? 'left: ${pos.x}px; top: ${pos.y}px;' : null,
              className: cn([
                isFixed ? 'fixed' : 'absolute left-0 top-full mt-1',
                'min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border)] '
                'bg-[var(--card)] shadow-[var(--shadow-overlay)] p-1 z-50 flex flex-col gap-0.5',
              ]),
              onClick: (BloomEvent e) => e.stopPropagation(),
              children: items.map((item) {
                final itemClass = cn([
                  'px-2.5 py-1.5 text-xs sm:text-sm rounded-[var(--radius-sm)] flex items-center gap-2 text-left w-full transition-colors select-none',
                  item.disabled
                      ? 'opacity-50 cursor-not-allowed pointer-events-none'
                      : 'cursor-pointer hover:bg-[var(--bg-muted)]',
                  item.destructive
                      ? 'text-[var(--destructive)]'
                      : 'text-[var(--text)]',
                ]);

                final content = <BloomNode>[
                  if (item.icon != null)
                    uiIcon(item.icon!, className: 'w-4 h-4 shrink-0'),
                  Span(text: item.label),
                ];

                return El(
                  'button',
                  attrs: {
                    'type': 'button',
                    'role': 'menuitem',
                    if (item.disabled) 'disabled': 'true',
                  },
                  className: itemClass,
                  onClick: (BloomEvent e) {
                    isOpen.value = false;
                    if (!item.disabled) {
                      item.onClick?.call();
                    }
                  },
                  children: content,
                );
              }).toList(),
            ),
          ],
        );
      }),
    ],
  );
}