Repository: ag-grid/ag-charts
Pull request #4011: AG-8876 Implement submenus in context-menu
Author: login: olegat
Created: 2025-04-22T15:59:17Z | Merged: 2025-04-28T12:02:28Z

https://ag-grid.atlassian.net/browse/AG-8876

Depends on
- https://github.com/ag-grid/ag-charts/pull/3985


Diff:
diff --git a/packages/ag-charts-community/src/util/attributeUtil.ts b/packages/ag-charts-community/src/util/attributeUtil.ts
index 0ddff6dd303..0146290a5a5 100644
--- a/packages/ag-charts-community/src/util/attributeUtil.ts
+++ b/packages/ag-charts-community/src/util/attributeUtil.ts
@@ -27,7 +27,7 @@ export type BaseAttributeTypeMap = {
     'aria-describedby': ElementID;
     'aria-disabled': boolean;
     'aria-expanded': boolean;
-    'aria-haspopup': boolean;
+    'aria-haspopup': 'true' | 'false' | 'menu' | 'dialog' | 'grid' | 'listbox' | 'tree';
     'aria-hidden': boolean;
     'aria-label': string;
     'aria-labelledby': ElementID;
@@ -63,11 +63,11 @@ const AttributeTypeParsers: { [K in keyof InputAttributeTypeMap]: (value: string
     'aria-describedby': stringParser<ElementID>,
     'aria-disabled': booleanParser,
     'aria-expanded': booleanParser,
-    'aria-haspopup': booleanParser,
+    'aria-haspopup': stringParser<BaseAttributeTypeMap['aria-haspopup']>,
     'aria-hidden': booleanParser,
     'aria-label': stringParser,
     'aria-labelledby': stringParser<ElementID>,
-    'aria-live': stringParser<'assertive' | 'polite'>,
+    'aria-live': stringParser<BaseAttributeTypeMap['aria-live']>,
     'aria-orientation': stringParser<Direction>,
     'aria-selected': booleanParser,
     'data-preventdefault': booleanParser,
diff --git a/packages/ag-charts-community/src/util/keynavUtil.ts b/packages/ag-charts-community/src/util/keynavUtil.ts
index e0474861dcd..093430dcbe2 100644
--- a/packages/ag-charts-community/src/util/keynavUtil.ts
+++ b/packages/ag-charts-community/src/util/keynavUtil.ts
@@ -73,19 +73,6 @@ function containsPoint(container: Element, event: Pick<MouseEvent & TouchEvent,
     return false;
 }
 
-export function addAutoCloseOnBlurEventListener(destroyFns: DestroyFns, buttons: HTMLElement[], hideCb: () => void) {
-    const handler = (ev: FocusEvent) => {
-        const buttonArray: (EventTarget | null)[] = buttons;
-        const isLeavingMenu = !buttonArray.includes(ev.relatedTarget);
-        if (isLeavingMenu) {
-            hideCb();
-        }
-    };
-    for (const button of buttons) {
-        addRemovableEventListener(destroyFns, button, 'blur', handler);
-    }
-}
-
 export function addOverrideFocusVisibleEventListener(
     destroyFns: DestroyFns,
     menu: HTMLElement,
@@ -225,19 +212,9 @@ export function initMenuKeyNav(opts: {
     buttons: HTMLElement[];
     // AG-13363 Force disable `:focus-visible` selector (e.g. when opened by a touch device)
     overrideFocusVisible?: false;
-    // CRT-481 Automatically close the context menu when change focus with TAB / Shift+TAB
-    autoCloseOnBlur?: boolean;
     closeCallback: () => void;
 }): MenuCloser {
-    const {
-        sourceEvent,
-        orientation,
-        menu,
-        buttons,
-        closeCallback,
-        overrideFocusVisible,
-        autoCloseOnBlur = false,
-    } = opts;
+    const { sourceEvent, orientation, menu, buttons, closeCallback, overrideFocusVisible } = opts;
     const { nextKey, prevKey } = PREV_NEXT_KEYS[orientation];
 
     const lastFocus = getLastFocus(sourceEvent);
@@ -261,10 +238,6 @@ export function initMenuKeyNav(opts: {
         }
     });
 
-    if (autoCloseOnBlur) {
-        addAutoCloseOnBlurEventListener(destroyFns, buttons, onEscape);
-    }
-
     buttons[0]?.focus({ preventScroll: true });
 
     if (overrideFocusVisible !== undefined) {
diff --git a/packages/ag-charts-community/src/widget/menuWidget.ts b/packages/ag-charts-community/src/widget/menuWidget.ts
index e4c4e4732e1..bcd4218d549 100644
--- a/packages/ag-charts-community/src/widget/menuWidget.ts
+++ b/packages/ag-charts-community/src/widget/menuWidget.ts
@@ -4,24 +4,32 @@ import type { Direction } from 'ag-charts-types';
 import { setAttribute } from '../util/attributeUtil';
 import { DestroyFns } from '../util/destroy';
 import {
-    addAutoCloseOnBlurEventListener,
     addEscapeEventListener,
     addMouseCloseListener,
     addOverrideFocusVisibleEventListener,
     addTouchCloseListener,
     getLastFocus,
 } from '../util/keynavUtil';
+import { ButtonWidget } from './buttonWidget';
 import { RovingTabContainerWidget } from './rovingTabContainerWidget';
 import type { WidgetEvent } from './widgetEvents';
 
 type OpenScope = {
     lastFocus: HTMLElement | undefined;
-    lastFocusAborted: boolean;
+    openSubMenu: MenuWidget | undefined;
     removers: DestroyFns;
     abort: () => void;
     close: () => void;
 };
 
+enum CloseMode {
+    CLOSE = '0',
+    ABORT = '1',
+    DESTROY = '2',
+    PARENT_CLOSED = '3',
+    SIDLING_OPENED = '4',
+}
+
 export class MenuWidget extends RovingTabContainerWidget {
     private openScope?: OpenScope;
 
@@ -30,7 +38,7 @@ export class MenuWidget extends RovingTabContainerWidget {
     }
 
     protected override destructor() {
-        // Nothing to destroy.
+        this.selfClose(CloseMode.DESTROY);
     }
 
     public addSeparator(): Element {
@@ -39,13 +47,34 @@ export class MenuWidget extends RovingTabContainerWidget {
         return sep;
     }
 
-    public open(event: WidgetEvent, opts?: { overrideFocusVisible?: boolean; autoCloseOnBlur?: boolean }): void {
-        const { autoCloseOnBlur = false, overrideFocusVisible = undefined } = opts ?? {};
+    public addSubMenu(): { subMenuButton: ButtonWidget; subMenu: MenuWidget } {
+        const subMenuButton = new ButtonWidget();
+        const subMenu = new MenuWidget();
+        const accessibleOpener = (ev: WidgetEvent) => {
+            const { openScope } = this;
+            // Disabled buttons are focusable and can receive events, but have aria-disabled="true"
+            if (openScope && !subMenuButton.isDisabled()) {
+                openScope.openSubMenu?.selfClose(CloseMode.SIDLING_OPENED);
+                subMenu.open(ev);
+                openScope.openSubMenu = subMenu;
+            }
+        };
+        subMenuButton.setAriaHasPopup('menu');
+        subMenuButton.addListener('click', accessibleOpener);
+        subMenuButton.addListener('mouseenter', accessibleOpener);
+        this.addChild(subMenuButton);
+        return { subMenuButton, subMenu };
+    }
+
+    public open(event: WidgetEvent, opts?: { overrideFocusVisible?: boolean }): void {
+        const { overrideFocusVisible = undefined } = opts ?? {};
+        if (this.openScope != null) return; // already open
+
         this.openScope = {
             lastFocus: getLastFocus(event.sourceEvent),
-            lastFocusAborted: false,
-            abort: () => this.abort(),
-            close: () => this.selfClose(),
+            openSubMenu: undefined,
+            abort: () => this.selfClose(CloseMode.ABORT),
+            close: () => this.selfClose(CloseMode.CLOSE),
             removers: new DestroyFns(),
         };
         const buttons: HTMLElement[] = this.children.map((value) => value.getElement());
@@ -53,24 +82,25 @@ export class MenuWidget extends RovingTabContainerWidget {
 
         addMouseCloseListener(this.openScope.removers, this.elem, this.openScope.abort);
         addTouchCloseListener(this.openScope.removers, this.elem, this.openScope.abort);
-        addEscapeEventListener(this.openScope.removers, this.elem, this.openScope.close);
-        if (autoCloseOnBlur === true) {
-            addAutoCloseOnBlurEventListener(this.openScope.removers, buttons, this.openScope.close);
+        for (const child of this.children) {
+            addEscapeEventListener(this.openScope.removers, child.getElement(), this.openScope.close);
         }
         if (overrideFocusVisible !== undefined) {
             addOverrideFocusVisibleEventListener(this.openScope.removers, this.elem, buttons, overrideFocusVisible);
         }
 
+        this.internalListener?.dispatch('open-widget', this, { type: 'open-widget' });
         this.children[0]?.focus({ preventScroll: true });
     }
 
-    private selfClose() {
+    private selfClose(mode: CloseMode) {
         if (this.openScope === undefined) return;
-        const { lastFocus, lastFocusAborted, removers } = this.openScope;
+        const { lastFocus, removers, openSubMenu } = this.openScope;
         this.openScope = undefined; // stop re-entrance
 
+        openSubMenu?.selfClose(CloseMode.PARENT_CLOSED);
         setAttribute(lastFocus, 'aria-expanded', false);
-        if (!lastFocusAborted) {
+        if (mode === CloseMode.CLOSE) {
             lastFocus?.focus({ preventScroll: true });
         }
         removers.destroy();
@@ -79,11 +109,6 @@ export class MenuWidget extends RovingTabContainerWidget {
     }
 
     public close() {
-        this.selfClose();
-    }
-
-    private abort() {
-        this.openScope!.lastFocusAborted = true;
-        this.selfClose();
+        this.selfClose(CloseMode.CLOSE);
     }
 }
diff --git a/packages/ag-charts-community/src/widget/widget.ts b/packages/ag-charts-community/src/widget/widget.ts
index 3f8de451d2e..b278a3b219c 100644
--- a/packages/ag-charts-community/src/widget/widget.ts
+++ b/packages/ag-charts-community/src/widget/widget.ts
@@ -100,11 +100,17 @@ export abstract class Widget<
         this.children.length = 0;
         this.destructor();
         this.elem.remove();
+        this.remove();
         this.elemContainer?.remove();
         this.internalListener?.destroy();
         this.htmlListener?.destroy(this);
     }
 
+    remove(): void {
+        this.elem.remove();
+        this.elemContainer?.remove();
+    }
+
     setHidden(hidden: boolean): void {
         setElementStyle(this.elem, 'display', hidden ? 'none' : undefined);
     }
@@ -133,6 +139,10 @@ export abstract class Widget<
         setAttribute(this.elem, 'aria-label', ariaLabel);
     }
 
+    setAriaHasPopup(ariaHasPopup: BaseAttributeTypeMap['aria-haspopup'] | undefined) {
+        setAttribute(this.elem, 'aria-haspopup', ariaHasPopup);
+    }
+
     setInnerHTML(html: string) {
         this.elem.innerHTML = html;
     }
diff --git a/packages/ag-charts-community/src/widget/widgetEvents.ts b/packages/ag-charts-community/src/widget/widgetEvents.ts
index 3d8aaf2e730..223c7b3ef88 100644
--- a/packages/ag-charts-community/src/widget/widgetEvents.ts
+++ b/packages/ag-charts-community/src/widget/widgetEvents.ts
@@ -81,11 +81,17 @@ export type CloseWidgetEvent = {
     sourceEvent?: never;
 };
 
+export type OpenWidgetEvent = {
+    type: 'open-widget';
+    sourceEvent?: never;
+};
+
 export type WidgetEventMap = {
     'drag-start': DragWidgetEvent<'drag-start'>;
     'drag-move': DragWidgetEvent<'drag-move'>;
     'drag-end': DragWidgetEvent<'drag-end'>;
     'close-widget': CloseWidgetEvent;
+    'open-widget': OpenWidgetEvent;
 
     blur: FocusWidgetEvent<'blur'>;
     change: WidgetEvent;
diff --git a/packages/ag-charts-enterprise/src/features/context-menu/contextMenu.ts b/packages/ag-charts-enterprise/src/features/context-menu/contextMenu.ts
index d0ef88801cc..599678ecbd8 100644
--- a/packages/ag-charts-enterprise/src/features/context-menu/contextMenu.ts
+++ b/packages/ag-charts-enterprise/src/features/context-menu/contextMenu.ts
@@ -1,8 +1,8 @@
 import type { AgContextMenuItem, AgContextMenuItemShowOn, AgContextMenuOptions } from 'ag-charts-community';
 import { _ModuleSupport, _Widget } from 'ag-charts-community';
-import { Logger, clamp } from 'ag-charts-core';
+import { type AnyFn, Logger, clamp } from 'ag-charts-core';
 
-import { ContextMenuItem, appendItem, expandBuiltin } from './contextMenuItem';
+import { ContextMenuItem, expandItems } from './contextMenuItem';
 import { DEFAULT_CONTEXT_MENU_CLASS, DEFAULT_CONTEXT_MENU_DARK_CLASS } from './contextMenuStyles';
 
 type ContextMenuEvent = _ModuleSupport.ContextMenuEvent;
@@ -91,6 +91,12 @@ export class ContextMenu extends _ModuleSupport.BaseModuleInstance implements _M
         this.element.classList.add(DEFAULT_CONTEXT_MENU_CLASS);
         this.element.style.display = 'none';
         this.element.addEventListener('contextmenu', (event) => event.preventDefault()); // AG-10223
+        // CRT-481 Automatically close the context menu when change focus with TAB / Shift+TAB
+        this.element.addEventListener('focusout', ({ relatedTarget }) => {
+            if (relatedTarget instanceof Node && !this.element.contains(relatedTarget)) {
+                this.hide();
+            }
+        });
         this.destroyFns.push(
             () => this.element.parentNode?.removeChild(this.element),
             () => this.menuWidget.destroy(),
@@ -124,39 +130,32 @@ export class ContextMenu extends _ModuleSupport.BaseModuleInstance implements _M
         this.destroyFns.push(this.registry.addListener('context-complete', (e) => this.onContext(e)));
     }
 
-    private expandItemsOptions(showing: AgContextMenuItemShowOn): ContextMenuItem[] {
-        const { ctx, deprecationMap } = this;
-        const expandedItems: ContextMenuItem[] = [];
-        expandedItems.length = 0;
-
-        for (const item of this.items) {
-            if (typeof item === 'string') {
-                expandBuiltin(showing, ctx.contextMenuRegistry, item, expandedItems);
-            } else if (item.type !== 'submenu') {
-                appendItem(showing, item, expandedItems);
-            } else {
-                throw new Error('`type: "submenu" not yet implemented');
-            }
-        }
-
-        for (const deprecatedKey of Object.keys(deprecationMap) as (keyof typeof deprecationMap)[]) {
-            const { items, showOn } = deprecationMap[deprecatedKey];
-            if (items.length > 0) {
+    private createDeprecatedAdaptorItems(): typeof this.items {
+        const result: AgContextMenuItem[] = [] satisfies typeof this.items;
+        for (const deprecatedKey of Object.keys(this.deprecationMap) as (keyof typeof this.deprecationMap)[]) {
+            const { items, showOn } = this.deprecationMap[deprecatedKey];
+            result.push('separator');
+            for (const { action, label } of items) {
                 const type = 'action';
                 const iconUrl = undefined;
                 const enable = true;
-                expandBuiltin(showing, ctx.contextMenuRegistry, 'separator', expandedItems);
-                for (const { action, label } of items) {
-                    appendItem(showing, { type, showOn, iconUrl, enable, label, action }, expandedItems);
-                }
+                // Signature typing cannot be verified at compile, because callbacks in api options are just JS
+                // functions assigned at runtime (typing info is lost).
+                action satisfies AnyFn;
+                result.push({ type, showOn, iconUrl, enable, label, action: action as AnyFn });
             }
         }
+        return result;
+    }
 
-        // remove trailing 'separator' menu item
-        if (expandedItems[expandedItems.length - 1].type === 'separator') {
-            expandedItems.length = expandedItems.length - 1;
-        }
-        return expandedItems;
+    private expandItemsOptions(showing: AgContextMenuItemShowOn): ContextMenuItem[] {
+        const result: ContextMenuItem[] = [];
+        const deprecatedItems = this.createDeprecatedAdaptorItems();
+
+        expandItems(showing, this.ctx.contextMenuRegistry, this.items, result);
+        expandItems(showing, this.ctx.contextMenuRegistry, deprecatedItems, result);
+
+        return result;
     }
 
     private onContext(event: ContextMenuEvent) {
@@ -192,7 +191,7 @@ export class ContextMenu extends _ModuleSupport.BaseModuleInstance implements _M
 
         this.createMenu(expandedItems);
         this.element.appendChild(this.menuWidget.getElement());
-        this.menuWidget.open(widgetEvent, { overrideFocusVisible, autoCloseOnBlur: true });
+        this.menuWidget.open(widgetEvent, { overrideFocusVisible });
     }
 
     private hide() {
@@ -205,12 +204,25 @@ export class ContextMenu extends _ModuleSupport.BaseModuleInstance implements _M
         this.element.style.display = 'none';
     }
 
+    private onSubMenuOpen(button: _Widget.ButtonWidget, menu: _Widget.MenuWidget) {
+        const bounds = button.getBounds();
+        button.getElement().insertAdjacentElement('afterend', menu.getElement());
+        menu.getElement().style.position = 'absolute';
+        menu.setBounds({ x: bounds.x + bounds.width, y: bounds.y });
+    }
+    private onSubMenuClose(_button: _Widget.ButtonWidget, menu: _Widget.MenuWidget) {
+        menu.remove();
+    }
+
     private createMenu(expandedItems: ContextMenuItem[]) {
         const { menuWidget } = this;
         menuWidget.clear();
         menuWidget.toggleClass(DEFAULT_CONTEXT_MENU_DARK_CLASS, this.darkTheme);
         menuWidget.setTabIndex(-1);
+        this.createMenuItems(menuWidget, expandedItems);
+    }
 
+    private createMenuItems(menuWidget: _Widget.MenuWidget, expandedItems: ContextMenuItem[]) {
         for (const item of expandedItems) {
             switch (item.type) {
                 case 'separator':
@@ -219,9 +231,17 @@ export class ContextMenu extends _ModuleSupport.BaseModuleInstance implements _M
                     sep.classList.toggle(DEFAULT_CONTEXT_MENU_DARK_CLASS, this.darkTheme);
                     break;
                 case 'action':
-                    menuWidget.addChild(this.createButtonElement(item));
+                    const btn = new _Widget.ButtonWidget();
+                    this.initButtonElement(btn, item);
+                    menuWidget.addChild(btn);
                     break;
                 case 'submenu':
+                    const { subMenuButton, subMenu } = menuWidget.addSubMenu();
+                    subMenu.addClass(`${DEFAULT_CONTEXT_MENU_CLASS}__menu`);
+                    subMenu.addListener('open-widget', () => this.onSubMenuOpen(subMenuButton, subMenu));
+                    subMenu.addListener('close-widget', () => this.onSubMenuClose(subMenuButton, subMenu));
+                    this.initButtonElement(subMenuButton, item);
+                    this.createMenuItems(subMenu, item.items);
                     break;
                 default:
                     throw new Error('unhandled case');
@@ -231,7 +251,7 @@ export class ContextMenu extends _ModuleSupport.BaseModuleInstance implements _M
     private createButtonOnClick(
         showOn: AgContextMenuItemShowOn,
         callback: ContextMenuCallback
-    ): (event: _ModuleSupport.MouseWidgetEvent) => void {
+    ): (event: _Widget.MouseWidgetEvent) => void {
         if (ContextMenuRegistry.checkCallback('legend-item', showOn, callback)) {
             return (widgetEvent: _ModuleSupport.MouseWidgetEvent) => {
                 const event: Event = widgetEvent.sourceEvent;
@@ -267,8 +287,7 @@ export class ContextMenu extends _ModuleSupport.BaseModuleInstance implements _M
         };
     }
 
-    private createButtonElement(item: ContextMenuItem): _Widget.ButtonWidget {
-        const button = new _Widget.ButtonWidget();
+    private initButtonElement(button: _Widget.ButtonWidget, item: ContextMenuItem) {
         button.addClass(`${DEFAULT_CONTEXT_MENU_CLASS}__item`);
         button.toggleClass(DEFAULT_CONTEXT_MENU_DARK_CLASS, this.darkTheme);
         button.setEnabled(item.enable);
@@ -278,7 +297,6 @@ export class ContextMenu extends _ModuleSupport.BaseModuleInstance implements _M
             button.addListener('click', this.createButtonOnClick(showOn, action));
         }
         button.addListener('mousemove', () => button.focus({ preventScroll: true }));
-        return button;
     }
 
     private reposition() {
diff --git a/packages/ag-charts-enterprise/src/features/context-menu/contextMenuItem.ts b/packages/ag-charts-enterprise/src/features/context-menu/contextMenuItem.ts
index 6986ce67a2f..0b3bd17d6b9 100644
--- a/packages/ag-charts-enterprise/src/features/context-menu/contextMenuItem.ts
+++ b/packages/ag-charts-enterprise/src/features/context-menu/contextMenuItem.ts
@@ -1,4 +1,5 @@
 import type {
+    AgContextMenuItem,
     AgContextMenuItemLiteral,
     AgContextMenuItemShowOn,
     AgContextMenuItemType,
@@ -14,7 +15,11 @@ function showsFor(showOn: AgContextMenuItemShowOn, showing: AgContextMenuItemSho
     return showOn === showing;
 }
 
-export function appendItem(showing: AgContextMenuItemShowOn, item: Options, result: ContextMenuItem[]) {
+function appendItem(
+    showing: AgContextMenuItemShowOn,
+    item: Options,
+    result: ContextMenuItem[]
+): ContextMenuItem | undefined {
     let mustShow: boolean = true;
     if (item.type === 'separator') {
         const last: ContextMenuItem | undefined = result.at(result.length - 1);
@@ -23,7 +28,9 @@ export function appendItem(showing: AgContextMenuItemShowOn, item: Options, resu
 
     mustShow &&= showsFor(item.showOn ?? 'always', showing);
     if (mustShow) {
-        result.push(new ContextMenuItem(item));
+        const menuItem = new ContextMenuItem(item);
+        result.push(menuItem);
+        return menuItem;
     }
 }
 
@@ -38,7 +45,7 @@ function appendBuiltinItem(
     }
 }
 
-export function expandBuiltin(
+function expandBuiltin(
     showing: AgContextMenuItemShowOn,
     registry: _ModuleSupport.ContextMenuRegistry,
     keyword: AgContextMenuItemLiteral,
@@ -54,6 +61,28 @@ export function expandBuiltin(
     }
 }
 
+export function expandItems(
+    showing: AgContextMenuItemShowOn,
+    registry: _ModuleSupport.ContextMenuRegistry,
+    items: readonly Readonly<AgContextMenuItem>[],
+    result: ContextMenuItem[]
+) {
+    for (const item of items) {
+        if (typeof item === 'string') {
+            expandBuiltin(showing, registry, item, result);
+        } else {
+            const menuItem = appendItem(showing, item, result);
+            if (item.items && menuItem && menuItem.type === 'submenu') {
+                expandItems(showing, registry, item.items, menuItem.items);
+            }
+        }
+    }
+    // remove trailing 'separator' menu item
+    if (result[result.length - 1]?.type === 'separator') {
+        result.pop();
+    }
+}
+
 export class ContextMenuItem implements _ModuleSupport.ContextMenuItemContract {
     type: AgContextMenuItemType = 'action';
     showOn: AgContextMenuItemShowOn = 'always';
@@ -65,6 +94,7 @@ export class ContextMenuItem implements _ModuleSupport.ContextMenuItemContract {
 
     constructor(options?: Options) {
         if (options) this.setOptions(options);
+        this.items = [];
     }
 
     private setField<K extends keyof Options>(key: K, that: { [L in K]: Options[K] }, value: Options[K]): void {
diff --git a/packages/ag-charts-enterprise/src/features/context-menu/contextMenuStyles.css b/packages/ag-charts-enterprise/src/features/context-menu/contextMenuStyles.css
index ad7b41989e7..97efcb64029 100644
--- a/packages/ag-charts-enterprise/src/features/context-menu/contextMenuStyles.css
+++ b/packages/ag-charts-enterprise/src/features/context-menu/contextMenuStyles.css
@@ -1,14 +1,7 @@
 .ag-charts-context-menu {
-    background: var(--ag-charts-chrome-background-color);
-    border: var(--ag-charts-border);
-    border-radius: var(--ag-charts-border-radius);
-    box-shadow: var(--ag-charts-shadow);
-    color: var(--ag-charts-chrome-text-color);
     font-family: var(--ag-charts-chrome-font-family);
     font-size: calc(var(--ag-charts-chrome-font-size) * (13 / 12));
     font-weight: var(--ag-charts-chrome-font-weight);
-    transition: transform 0.1s ease;
-    white-space: nowrap;
     z-index: var(--ag-charts-layer-ui-overlay);
 }
 
@@ -19,9 +12,16 @@
 }
 
 .ag-charts-context-menu__menu {
+    background: var(--ag-charts-chrome-background-color);
+    border-radius: var(--ag-charts-border-radius);
+    border: var(--ag-charts-border);
+    box-shadow: var(--ag-charts-shadow);
+    color: var(--ag-charts-chrome-text-color);
     display: flex;
     flex-direction: column;
     padding: 0.5em 0;
+    transition: transform 0.1s ease;
+    white-space: nowrap;
 }
 
 .ag-charts-context-menu__menu:focus {


Reviews:
user: login: jacobp100
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T15:26:44Z
user: login: jacobp100
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T15:27:03Z
user: login: jacobp100
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T15:28:01Z
user: login: jacobp100
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T15:29:03Z
user: login: olegat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T15:36:09Z
user: login: olegat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T15:45:12Z
user: login: olegat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T15:51:51Z
user: login: alantreadway
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-28T12:02:16Z

Review comments:
diff_hunk: @@ -4,24 +4,32 @@ import type { Direction } from 'ag-charts-types';
 import { setAttribute } from '../util/attributeUtil';
 import { DestroyFns } from '../util/destroy';
 import {
-    addAutoCloseOnBlurEventListener,
     addEscapeEventListener,
     addMouseCloseListener,
     addOverrideFocusVisibleEventListener,
     addTouchCloseListener,
     getLastFocus,
 } from '../util/keynavUtil';
+import { ButtonWidget } from './buttonWidget';
 import { RovingTabContainerWidget } from './rovingTabContainerWidget';
 import type { WidgetEvent } from './widgetEvents';
 
 type OpenScope = {
     lastFocus: HTMLElement | undefined;
-    lastFocusAborted: boolean;
+    openSubMenu: MenuWidget | undefined;
     removers: DestroyFns;
     abort: () => void;
     close: () => void;
 };
 
+enum CloseEnum {
path: packages/ag-charts-community/src/widget/menuWidget.ts
user: login: jacobp100
body: Minor - `CloseMode` or something might be a better name
created_at: 2025-04-25T15:26:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,24 +4,32 @@ import type { Direction } from 'ag-charts-types';
 import { setAttribute } from '../util/attributeUtil';
 import { DestroyFns } from '../util/destroy';
 import {
-    addAutoCloseOnBlurEventListener,
     addEscapeEventListener,
     addMouseCloseListener,
     addOverrideFocusVisibleEventListener,
     addTouchCloseListener,
     getLastFocus,
 } from '../util/keynavUtil';
+import { ButtonWidget } from './buttonWidget';
 import { RovingTabContainerWidget } from './rovingTabContainerWidget';
 import type { WidgetEvent } from './widgetEvents';
 
 type OpenScope = {
     lastFocus: HTMLElement | undefined;
-    lastFocusAborted: boolean;
+    openSubMenu: MenuWidget | undefined;
     removers: DestroyFns;
     abort: () => void;
     close: () => void;
 };
 
+enum CloseEnum {
+    CLOSE = '0',
path: packages/ag-charts-community/src/widget/menuWidget.ts
user: login: jacobp100
body: Are these constants needed? It'll default to integers of the same values
created_at: 2025-04-25T15:27:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -54,6 +61,28 @@ export function expandBuiltin(
     }
 }
 
+export function expandItems(
+    showing: AgContextMenuItemShowOn,
+    registry: _ModuleSupport.ContextMenuRegistry,
+    items: readonly Readonly<AgContextMenuItem>[],
+    result: ContextMenuItem[]
+) {
+    for (const item of items) {
+        if (typeof item === 'string') {
+            expandBuiltin(showing, registry, item, result);
+        } else {
+            const menuItem = appendItem(showing, item, result);
+            if (item.items && menuItem && menuItem.type === 'submenu') {
+                expandItems(showing, registry, item.items, menuItem.items);
+            }
+        }
+    }
+    // remove trailing 'separator' menu item
+    if (result[result.length - 1]?.type === 'separator') {
+        result.length = result.length - 1;
path: packages/ag-charts-enterprise/src/features/context-menu/contextMenuItem.ts
user: login: jacobp100
body: ```suggestion
        result.pop()
```

A bit more idiomatic
created_at: 2025-04-25T15:28:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,9 +12,16 @@
 }
 
 .ag-charts-context-menu__menu {
+    background: var(--ag-charts-chrome-background-color);
path: packages/ag-charts-enterprise/src/features/context-menu/contextMenuStyles.css
user: login: jacobp100
body: I'm not following why the background etc. is able to move?
created_at: 2025-04-25T15:29:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,24 +4,32 @@ import type { Direction } from 'ag-charts-types';
 import { setAttribute } from '../util/attributeUtil';
 import { DestroyFns } from '../util/destroy';
 import {
-    addAutoCloseOnBlurEventListener,
     addEscapeEventListener,
     addMouseCloseListener,
     addOverrideFocusVisibleEventListener,
     addTouchCloseListener,
     getLastFocus,
 } from '../util/keynavUtil';
+import { ButtonWidget } from './buttonWidget';
 import { RovingTabContainerWidget } from './rovingTabContainerWidget';
 import type { WidgetEvent } from './widgetEvents';
 
 type OpenScope = {
     lastFocus: HTMLElement | undefined;
-    lastFocusAborted: boolean;
+    openSubMenu: MenuWidget | undefined;
     removers: DestroyFns;
     abort: () => void;
     close: () => void;
 };
 
+enum CloseEnum {
+    CLOSE = '0',
path: packages/ag-charts-community/src/widget/menuWidget.ts
user: login: olegat
body: Integer enums are not type safe.

```typescript
enum CloseEnum { Close = 1, Abort = 2 };
const myVar1: CloseEnum = CloseEnum.Close + 1000;
const myVar2: CloseEnum = CloseEnum.Close | CloseEnum.Abort;
```

The snippet above is valid Typescript code despite the fact that `myVar1` & `myVar2` are neither 1 nor 2.
created_at: 2025-04-25T15:36:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,9 +12,16 @@
 }
 
 .ag-charts-context-menu__menu {
+    background: var(--ag-charts-chrome-background-color);
path: packages/ag-charts-enterprise/src/features/context-menu/contextMenuStyles.css
user: login: olegat
body: I do not understand the question. But I can say that, without this change, the styling in is not properly applied to submenus, which are also of class `.ag-charts-context-menu__menu` like the root menu.


https://github.com/user-attachments/assets/72651b14-0bd6-43a9-bf7b-fddd2e59845f

Perhaps some CSS properties like `white-space` and `transition` can stay as part of `.ag-charts-context-menu`; in all honesty I do not completely understand what all the properties in the file are intended for.
created_at: 2025-04-25T15:45:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,24 +4,32 @@ import type { Direction } from 'ag-charts-types';
 import { setAttribute } from '../util/attributeUtil';
 import { DestroyFns } from '../util/destroy';
 import {
-    addAutoCloseOnBlurEventListener,
     addEscapeEventListener,
     addMouseCloseListener,
     addOverrideFocusVisibleEventListener,
     addTouchCloseListener,
     getLastFocus,
 } from '../util/keynavUtil';
+import { ButtonWidget } from './buttonWidget';
 import { RovingTabContainerWidget } from './rovingTabContainerWidget';
 import type { WidgetEvent } from './widgetEvents';
 
 type OpenScope = {
     lastFocus: HTMLElement | undefined;
-    lastFocusAborted: boolean;
+    openSubMenu: MenuWidget | undefined;
     removers: DestroyFns;
     abort: () => void;
     close: () => void;
 };
 
+enum CloseEnum {
path: packages/ag-charts-community/src/widget/menuWidget.ts
user: login: olegat
body: No objections. Done.
created_at: 2025-04-25T15:51:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,9 +12,16 @@
 }
 
 .ag-charts-context-menu__menu {
+    background: var(--ag-charts-chrome-background-color);
path: packages/ag-charts-enterprise/src/features/context-menu/contextMenuStyles.css
user: login: alantreadway
body: Let's roll with these changes and see if we can spot any regressions in testing?
created_at: 2025-04-28T12:02:07Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-23T16:44:20Z
body: Snapshots automatically updated, please review before merge: [diff](https://github.com/ag-grid/ag-charts/compare/AG-8876/submenu_context_menu_items...gha/snapshots-AG-8876/submenu_context_menu_items)
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-24T08:45:17Z
body: Snapshots automatically updated, please review before merge: [diff](https://github.com/ag-grid/ag-charts/compare/AG-8876/submenu_context_menu_items...gha/snapshots-AG-8876/submenu_context_menu_items)
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-24T15:50:16Z
body: Snapshots automatically updated, please review before merge: [diff](https://github.com/ag-grid/ag-charts/compare/AG-8876/submenu_context_menu_items...gha/snapshots-AG-8876/submenu_context_menu_items)
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-25T10:43:45Z
body: Snapshots automatically updated, please review before merge: [diff](https://github.com/ag-grid/ag-charts/compare/AG-8876/submenu_context_menu_items...gha/snapshots-AG-8876/submenu_context_menu_items)
author_association: CONTRIBUTOR