{items.map((item) => {
const ele = (
item.onClick()}
>
diff --git a/packages/ui/src/components/menu/desktop/TinyMenuGroup.tsx b/packages/ui/src/components/menu/desktop/TinyMenuGroup.tsx
index b7efadfc7d..186626c690 100644
--- a/packages/ui/src/components/menu/desktop/TinyMenuGroup.tsx
+++ b/packages/ui/src/components/menu/desktop/TinyMenuGroup.tsx
@@ -14,8 +14,9 @@
* limitations under the License.
*/
-import type { IDisplayMenuItem, IMenuItem, IValueOption } from '../../../services/menu/menu';
+import type { IDisplayMenuItem, IMenuItem, IValueOption, MenuItemDefaultValueType } from '../../../services/menu/menu';
import type { IMenuSchema } from '../../../services/menu/menu-manager.service';
+import type { TinyMenuLayoutVariant, TinyMenuSizeVariant } from './DesignTinyMenuGroup';
import { convertObservableToBehaviorSubject, LocaleService } from '@univerjs/core';
import { clsx } from '@univerjs/design';
import { useEffect, useMemo, useState } from 'react';
@@ -28,6 +29,9 @@ interface IUIQuickMenuGroupProps {
item: IMenuSchema;
activeItemIds?: string[];
hiddenItemIds?: string[];
+ columns?: number;
+ sizeVariant?: TinyMenuSizeVariant;
+ layoutVariant?: TinyMenuLayoutVariant;
onOptionSelect?: (option: IValueOption) => void;
}
@@ -39,6 +43,10 @@ interface IUIQuickTileMenuItemProps {
const EMPTY_HIDDEN_ITEM_IDS: string[] = [];
+type TinyMenuDisplayItem = IDisplayMenuItem
& {
+ value?: MenuItemDefaultValueType;
+};
+
export function resolveMenuItemActiveState(itemId: string | undefined, observableActive: boolean, activeItemIds?: string[]): boolean {
if (activeItemIds) {
return Boolean(itemId && activeItemIds.includes(itemId));
@@ -60,7 +68,7 @@ function QuickTileMenuItem(props: IUIQuickTileMenuItemProps) {
const { menuSchema, activeItemIds, onOptionSelect } = props;
const componentManager = useDependency(ComponentManager);
const localeService = useDependency(LocaleService);
- const menuItem = menuSchema.item as IDisplayMenuItem | undefined;
+ const menuItem = menuSchema.item as TinyMenuDisplayItem | undefined;
const disabled = useObservable(menuItem?.disabled$, false);
const hidden = useObservable(menuItem?.hidden$, false);
const activated = useObservable(menuItem?.activated$, false);
@@ -105,6 +113,8 @@ function QuickTileMenuItem(props: IUIQuickTileMenuItemProps) {
label: menuItem.id ?? menuSchema.key,
commandId: menuItem.commandId,
id: menuItem.id,
+ params: menuItem.params,
+ value: menuItem.value,
tooltip: menuItem.tooltip && localeService.t(menuItem.tooltip),
});
}}
@@ -125,7 +135,7 @@ function QuickTileMenuItem(props: IUIQuickTileMenuItemProps) {
}
export function UITinyMenuGroup(props: IUIQuickMenuGroupProps) {
- const { item, activeItemIds, hiddenItemIds = EMPTY_HIDDEN_ITEM_IDS, onOptionSelect } = props;
+ const { item, activeItemIds, hiddenItemIds = EMPTY_HIDDEN_ITEM_IDS, columns, sizeVariant = 'default', layoutVariant = 'default', onOptionSelect } = props;
const [activeItems, setActiveItems] = useState([]);
const [hiddenItems, setHiddenItems] = useState([]);
const componentManager = useDependency(ComponentManager);
@@ -184,6 +194,9 @@ export function UITinyMenuGroup(props: IUIQuickMenuGroupProps) {
return (
({
key: child.key,
onClick: () => {
@@ -191,13 +204,18 @@ export function UITinyMenuGroup(props: IUIQuickMenuGroupProps) {
label: child.item?.id ?? child.key,
commandId: child.item?.commandId,
id: child.item?.id,
+ params: child.item?.params,
+ value: (child.item as TinyMenuDisplayItem | undefined)?.value,
tooltip: child.item?.tooltip && localeService.t(child.item?.tooltip),
});
},
className: '',
- iconClassName: child.item?.icon === 'TextTypeIcon' ? '!univer-size-3.5' : undefined,
+ iconClassName: child.item?.icon === 'TextTypeIcon'
+ ? (sizeVariant === 'paragraph-t' ? '!univer-size-4' : '!univer-size-3.5')
+ : undefined,
Icon: componentManager.get(child.item!.icon as string)!,
active: resolveMenuItemActiveState(child.item?.id, activeItems.includes(child.item?.id ?? ''), activeItemIds),
+ tooltip: child.item?.tooltip ? localeService.t(child.item.tooltip) : undefined,
}))}
/>
);
diff --git a/packages/ui/src/components/menu/desktop/__tests__/DesignTinyMenuGroup.spec.tsx b/packages/ui/src/components/menu/desktop/__tests__/DesignTinyMenuGroup.spec.tsx
new file mode 100644
index 0000000000..4c0d207f2e
--- /dev/null
+++ b/packages/ui/src/components/menu/desktop/__tests__/DesignTinyMenuGroup.spec.tsx
@@ -0,0 +1,49 @@
+/**
+ * Copyright 2023-present DreamNum Co., Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+import { DesignTinyMenuGroup } from '../DesignTinyMenuGroup';
+
+describe('DesignTinyMenuGroup', () => {
+ it('uses a tighter compact footprint for paragraph T color swatches', () => {
+ const Icon = ({ className }: { className?: string }) => React.createElement('span', { 'data-testid': 'swatch-icon', className });
+ const { container } = render(
+ {},
+ className: '',
+ Icon,
+ }]}
+ />
+ );
+
+ const group = container.firstChild as HTMLDivElement | null;
+ const button = group?.querySelector('div');
+ const icon = group?.querySelector('[data-testid="swatch-icon"]');
+
+ expect(group?.className ?? '').toContain('univer-gap-0.5');
+ expect(group?.className ?? '').toContain('univer-p-0');
+ expect(button?.className ?? '').toContain('univer-size-6');
+ expect(button?.className ?? '').toContain('univer-rounded-sm');
+ expect(icon?.className ?? '').toContain('univer-size-4');
+ });
+});
diff --git a/packages/ui/src/components/menu/desktop/__tests__/TinyMenuGroup.spec.ts b/packages/ui/src/components/menu/desktop/__tests__/TinyMenuGroup.spec.ts
index b7d744bc42..9f789d7cc7 100644
--- a/packages/ui/src/components/menu/desktop/__tests__/TinyMenuGroup.spec.ts
+++ b/packages/ui/src/components/menu/desktop/__tests__/TinyMenuGroup.spec.ts
@@ -14,10 +14,64 @@
* limitations under the License.
*/
-import { describe, expect, it } from 'vitest';
-import { getVisibleTinyMenuChildren, resolveMenuItemActiveState } from '../TinyMenuGroup';
+import { render } from '@testing-library/react';
+import { LocaleService } from '@univerjs/core';
+import React from 'react';
+import { BehaviorSubject, of } from 'rxjs';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ComponentManager } from '../../../../common';
+import { MenuItemType } from '../../../../services/menu/menu';
+import { getVisibleTinyMenuChildren, resolveMenuItemActiveState, UITinyMenuGroup } from '../TinyMenuGroup';
+
+const dependencyMap = new Map();
+const designTinyMenuGroupSpy = vi.fn();
+
+vi.mock('../../../../utils/di', async () => {
+ const ReactModule = await import('react');
+
+ return {
+ useDependency(token: unknown) {
+ return dependencyMap.get(token);
+ },
+ useObservable(observable: any, defaultValue?: T) {
+ const [value, setValue] = ReactModule.useState(defaultValue);
+
+ ReactModule.useEffect(() => {
+ if (!observable) {
+ return;
+ }
+
+ const source = typeof observable === 'function' ? observable() : observable;
+ const sub = source.subscribe?.((nextValue: T) => setValue(nextValue));
+
+ return () => sub?.unsubscribe?.();
+ }, [observable]);
+
+ return value;
+ },
+ };
+});
+
+vi.mock('../DesignTinyMenuGroup', () => ({
+ DesignTinyMenuGroup: (props: unknown) => {
+ designTinyMenuGroupSpy(props);
+ return React.createElement('div', { 'data-testid': 'design-tiny-menu-group' });
+ },
+}));
describe('TinyMenuGroup', () => {
+ beforeEach(() => {
+ dependencyMap.clear();
+ designTinyMenuGroupSpy.mockClear();
+ dependencyMap.set(ComponentManager, {
+ get: () => () => React.createElement('span'),
+ });
+ dependencyMap.set(LocaleService, {
+ t: (key: string) => `translated:${key}`,
+ direction$: new BehaviorSubject<'ltr'>('ltr'),
+ });
+ });
+
it('uses explicit active items as an override instead of merging with observable active state', () => {
expect(resolveMenuItemActiveState('h1', true, ['normal-text'])).toBe(false);
expect(resolveMenuItemActiveState('normal-text', false, ['normal-text'])).toBe(true);
@@ -33,4 +87,94 @@ describe('TinyMenuGroup', () => {
expect(getVisibleTinyMenuChildren(children, ['title', 'subtitle']).map((item) => item.key)).toEqual(['h1']);
});
+
+ it('passes translated tooltips and command params through tiny icon menu items', () => {
+ const onOptionSelect = vi.fn();
+ const item = {
+ key: 'quick',
+ order: 0,
+ children: [{
+ key: 'text-color',
+ order: 0,
+ item: {
+ id: 'doc.command.set-inline-format-text-color',
+ type: MenuItemType.BUTTON,
+ icon: 'TextTypeIcon',
+ tooltip: 'docs-ui.toolbar.textColor.main',
+ params: { value: '#FE4B4B' },
+ hidden$: of(false),
+ activated$: of(false),
+ },
+ }],
+ } as never;
+
+ render(React.createElement(UITinyMenuGroup, { item, onOptionSelect }));
+
+ const props = designTinyMenuGroupSpy.mock.calls[0][0] as {
+ items: Array<{ onClick: () => void; tooltip?: string }>;
+ };
+
+ expect(props.items[0].tooltip).toBe('translated:docs-ui.toolbar.textColor.main');
+
+ props.items[0].onClick();
+
+ expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({
+ id: 'doc.command.set-inline-format-text-color',
+ label: 'doc.command.set-inline-format-text-color',
+ params: { value: '#FE4B4B' },
+ tooltip: 'translated:docs-ui.toolbar.textColor.main',
+ }));
+ });
+
+ it('passes fixed column layout through to the tiny menu renderer', () => {
+ const item = {
+ key: 'quick',
+ order: 0,
+ children: [{
+ key: 'h1',
+ order: 0,
+ item: {
+ id: 'doc.command.h1-heading',
+ type: MenuItemType.BUTTON,
+ icon: 'H1Icon',
+ hidden$: of(false),
+ activated$: of(false),
+ },
+ }],
+ } as never;
+
+ render(React.createElement(UITinyMenuGroup, { item, columns: 6 }));
+
+ const props = designTinyMenuGroupSpy.mock.calls[0][0] as {
+ columns?: number;
+ };
+
+ expect(props.columns).toBe(6);
+ });
+
+ it('passes paragraph T size variant through to the tiny menu renderer', () => {
+ const item = {
+ key: 'quick',
+ order: 0,
+ children: [{
+ key: 'h1',
+ order: 0,
+ item: {
+ id: 'doc.command.h1-heading',
+ type: MenuItemType.BUTTON,
+ icon: 'H1Icon',
+ hidden$: of(false),
+ activated$: of(false),
+ },
+ }],
+ } as never;
+
+ render(React.createElement(UITinyMenuGroup, { item, sizeVariant: 'paragraph-t' }));
+
+ const props = designTinyMenuGroupSpy.mock.calls[0][0] as {
+ sizeVariant?: string;
+ };
+
+ expect(props.sizeVariant).toBe('paragraph-t');
+ });
});
diff --git a/packages/ui/src/services/menu/menu-manager.service.ts b/packages/ui/src/services/menu/menu-manager.service.ts
index b69cec4024..fb87dec446 100644
--- a/packages/ui/src/services/menu/menu-manager.service.ts
+++ b/packages/ui/src/services/menu/menu-manager.service.ts
@@ -25,6 +25,7 @@ import { ContextMenuGroup, ContextMenuPosition, MenuManagerPosition, RibbonDataG
export const IMenuManagerService = createIdentifier('univer.menu-manager-service');
export type ContextMenuQuickLayout = 'icon' | 'tile';
+export type ContextMenuQuickLayoutVariant = 'default' | 'compact';
export interface IMenuSchema {
key: string;
@@ -32,8 +33,11 @@ export interface IMenuSchema {
title?: string;
contextual?: boolean;
item?: IMenuItem;
+ headerActionItem?: IMenuItem;
children?: IMenuSchema[];
quickLayout?: ContextMenuQuickLayout;
+ quickColumns?: number;
+ quickLayoutVariant?: ContextMenuQuickLayoutVariant;
tiny?: boolean;
}
@@ -52,9 +56,12 @@ export interface IMenuManagerService {
export type MenuSchemaType = {
order?: number;
menuItemFactory?: (accessor: IAccessor) => IMenuItem;
+ headerActionMenuItemFactory?: (accessor: IAccessor) => IMenuItem;
title?: string;
contextual?: boolean;
quickLayout?: ContextMenuQuickLayout;
+ quickColumns?: number;
+ quickLayoutVariant?: ContextMenuQuickLayoutVariant;
tiny?: boolean;
} | {
[key: string]: MenuSchemaType;
@@ -294,6 +301,8 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic
title: value.title,
contextual: value.contextual,
quickLayout: value.quickLayout,
+ quickColumns: value.quickColumns,
+ quickLayoutVariant: value.quickLayoutVariant,
tiny: value.tiny,
};
@@ -311,6 +320,9 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic
}
}
}
+ if (value.headerActionMenuItemFactory) {
+ menuItem.headerActionItem = this._injector.invoke(value.headerActionMenuItemFactory);
+ }
if (typeof value === 'object') {
const children = this._buildMenuSchema(value);
if (children.length > 0) {
diff --git a/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx b/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx
index bce53df6f7..0731aa4b6b 100644
--- a/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx
+++ b/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+import type { Dispatch, SetStateAction } from 'react';
import type {
IDisplayMenuItem,
IMenuButtonItem,
@@ -37,12 +38,15 @@ import { MenuItemType } from '../../../services/menu/menu';
import { IMenuManagerService } from '../../../services/menu/menu-manager.service';
import { useDependency, useObservable } from '../../../utils/di';
+type ContextMenuSizeVariant = 'default' | 'paragraph-t';
+
interface IContextMenuPanelProps {
menuType: string;
menuSessionVersion?: number;
className?: string;
activeItemIds?: string[];
hiddenItemIds?: string[];
+ sizeVariant?: ContextMenuSizeVariant;
onOptionSelect?: (option: IValueOption) => void;
}
@@ -53,6 +57,7 @@ interface IContextMenuMenuProps {
maxMenuHeight: number;
activeItemIds?: string[];
hiddenItemIds?: string[];
+ sizeVariant: ContextMenuSizeVariant;
onOptionSelect?: (option: IValueOption) => void;
}
@@ -62,18 +67,29 @@ interface IContextMenuMenuItemProps {
menuSessionVersion: number;
submenuPortalContainer: HTMLElement | null;
maxMenuHeight: number;
+ activeSubmenuKey: string | null;
+ setActiveSubmenuKey: Dispatch>;
activeItemIds?: string[];
hiddenItemIds?: string[];
compact?: boolean;
+ headerAction?: boolean;
+ sizeVariant: ContextMenuSizeVariant;
onOptionSelect?: (option: IValueOption) => void;
}
-const contentClassName = 'univer-inline-flex univer-items-center univer-gap-2';
+interface IContextMenuSchemaRenderGroup {
+ startIndex: number;
+ endIndex: number;
+ menuSchemas: IMenuSchema[];
+}
+
const menuViewportPadding = 8;
const submenuOverlapOffset = 2;
const submenuVisualGap = 20;
export const CONTEXT_MENU_SUBMENU_CLOSE_DELAY = 500;
export const CONTEXT_MENU_SUBMENU_PORTAL_ATTR = 'data-u-context-menu-submenu';
+const CONTEXT_MENU_CONNECTED_QUICK_GROUP_KEYS = new Set(['quickTop', 'quickBottom']);
+const CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS = new Set(['quickTop', 'quickBottom']);
type MenuLabel = IMenuItem['label'] | IValueOption['label'];
@@ -97,8 +113,181 @@ export function hasRenderableContextMenuSchema(menuSchema: IMenuSchema): boolean
return menuSchema.children.some((childSchema) => Boolean(childSchema.item));
}
+export function shouldShowContextMenuGroupSeparator(visibleSchemas: IMenuSchema[], index: number): boolean {
+ if (index === visibleSchemas.length - 1) {
+ return false;
+ }
+
+ const currentSchema = visibleSchemas[index];
+ const nextSchema = visibleSchemas[index + 1];
+
+ if (
+ currentSchema?.quickLayout
+ && nextSchema?.quickLayout
+ && CONTEXT_MENU_CONNECTED_QUICK_GROUP_KEYS.has(currentSchema.key)
+ && CONTEXT_MENU_CONNECTED_QUICK_GROUP_KEYS.has(nextSchema.key)
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+export function getContextMenuQuickGroupColumns(menuSchema: IMenuSchema): number | undefined {
+ if (isRealNum(menuSchema.quickColumns)) {
+ return menuSchema.quickColumns;
+ }
+
+ if (menuSchema.quickLayout === 'icon' && CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(menuSchema.key)) {
+ return 6;
+ }
+
+ return undefined;
+}
+
+function getContextMenuContentClassName(sizeVariant: ContextMenuSizeVariant) {
+ return clsx(
+ 'univer-inline-flex univer-items-center',
+ sizeVariant === 'paragraph-t' ? 'univer-gap-3' : 'univer-gap-2'
+ );
+}
+
+function getContextMenuPanelClassName(sizeVariant: ContextMenuSizeVariant) {
+ return sizeVariant === 'paragraph-t'
+ ? `
+ univer-box-border univer-grid univer-min-w-64 univer-max-w-full univer-gap-2 univer-overflow-y-auto
+ univer-overscroll-contain univer-rounded-md univer-bg-white univer-px-3 univer-py-2 univer-text-base
+ univer-text-gray-900 univer-shadow-md
+ dark:!univer-bg-gray-700 dark:!univer-text-white
+ `
+ : `
+ univer-box-border univer-grid univer-min-w-52 univer-max-w-full univer-gap-1 univer-overflow-y-auto
+ univer-overscroll-contain univer-rounded-md univer-bg-white univer-px-2 univer-py-1 univer-text-sm
+ univer-text-gray-900 univer-shadow-md
+ dark:!univer-bg-gray-700 dark:!univer-text-white
+ `;
+}
+
+function getContextMenuGroupClassName(sizeVariant: ContextMenuSizeVariant) {
+ return sizeVariant === 'paragraph-t' ? 'univer-grid univer-gap-2 univer-py-2' : 'univer-grid univer-gap-1 univer-py-1';
+}
+
+function isParagraphTHeaderQuickGroup(menuSchema: IMenuSchema, sizeVariant: ContextMenuSizeVariant) {
+ return sizeVariant === 'paragraph-t'
+ && menuSchema.quickLayout === 'icon'
+ && CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(menuSchema.key);
+}
+
+function shouldClusterParagraphTHeaderQuickGroups(
+ currentSchema: IMenuSchema,
+ nextSchema: IMenuSchema | undefined,
+ sizeVariant: ContextMenuSizeVariant
+) {
+ return isParagraphTHeaderQuickGroup(currentSchema, sizeVariant)
+ && !!nextSchema
+ && isParagraphTHeaderQuickGroup(nextSchema, sizeVariant);
+}
+
+function getContextMenuQuickGroupClassName(
+ menuSchema: IMenuSchema,
+ visibleSchemas: IMenuSchema[],
+ index: number,
+ sizeVariant: ContextMenuSizeVariant
+) {
+ if (sizeVariant !== 'paragraph-t' || !CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(menuSchema.key)) {
+ return getContextMenuGroupClassName(sizeVariant);
+ }
+
+ const previousSchema = index > 0 ? visibleSchemas[index - 1] : null;
+ const nextSchema = index < visibleSchemas.length - 1 ? visibleSchemas[index + 1] : null;
+ const connectedToPrevious = !!previousSchema?.quickLayout && CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(previousSchema.key);
+ const connectedToNext = !!nextSchema?.quickLayout && CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(nextSchema.key);
+
+ if (connectedToPrevious && connectedToNext) {
+ return 'univer-grid univer-gap-2 univer-pt-1 univer-pb-1';
+ }
+
+ if (connectedToPrevious) {
+ return 'univer-grid univer-gap-2 univer-pt-1 univer-pb-2';
+ }
+
+ if (connectedToNext) {
+ return 'univer-grid univer-gap-2 univer-pt-2 univer-pb-1';
+ }
+
+ return getContextMenuGroupClassName(sizeVariant);
+}
+
+function getContextMenuQuickGroupClusterClassName(sizeVariant: ContextMenuSizeVariant) {
+ return sizeVariant === 'paragraph-t' ? 'univer-grid univer-gap-0 univer-py-2' : getContextMenuGroupClassName(sizeVariant);
+}
+
+export function getContextMenuSchemaRenderGroups(
+ visibleSchemas: IMenuSchema[],
+ sizeVariant: ContextMenuSizeVariant
+): IContextMenuSchemaRenderGroup[] {
+ const renderGroups: IContextMenuSchemaRenderGroup[] = [];
+
+ for (let index = 0; index < visibleSchemas.length; index++) {
+ const menuSchema = visibleSchemas[index];
+ const nextSchema = visibleSchemas[index + 1];
+
+ if (shouldClusterParagraphTHeaderQuickGroups(menuSchema, nextSchema, sizeVariant)) {
+ renderGroups.push({
+ startIndex: index,
+ endIndex: index + 1,
+ menuSchemas: [menuSchema, nextSchema!],
+ });
+ index += 1;
+ continue;
+ }
+
+ renderGroups.push({
+ startIndex: index,
+ endIndex: index,
+ menuSchemas: [menuSchema],
+ });
+ }
+
+ return renderGroups;
+}
+
+function getContextMenuHeaderClassName(sizeVariant: ContextMenuSizeVariant) {
+ return sizeVariant === 'paragraph-t'
+ ? `
+ univer-px-3 univer-text-sm univer-font-semibold univer-text-gray-600
+ dark:!univer-text-gray-300
+ `
+ : `
+ univer-px-2 univer-text-xs univer-font-semibold univer-text-gray-600
+ dark:!univer-text-gray-300
+ `;
+}
+
+function getContextMenuHeaderRowClassName(sizeVariant: ContextMenuSizeVariant) {
+ return sizeVariant === 'paragraph-t'
+ ? 'univer-flex univer-items-center univer-justify-between univer-gap-2'
+ : 'univer-flex univer-items-center univer-justify-between univer-gap-1.5';
+}
+
+function getContextMenuSubmenuPanelClassName(sizeVariant: ContextMenuSizeVariant) {
+ return sizeVariant === 'paragraph-t'
+ ? `
+ univer-overflow-y-auto univer-overscroll-contain univer-rounded-md univer-border
+ univer-border-solid univer-border-gray-200 univer-bg-white univer-p-2
+ univer-shadow-md
+ dark:!univer-border-gray-600 dark:!univer-bg-gray-700
+ `
+ : `
+ univer-overflow-y-auto univer-overscroll-contain univer-rounded-md univer-border
+ univer-border-solid univer-border-gray-200 univer-bg-white univer-p-1.5
+ univer-shadow-md
+ dark:!univer-border-gray-600 dark:!univer-bg-gray-700
+ `;
+}
+
export function ContextMenuPanel(props: IContextMenuPanelProps) {
- const { menuType, menuSessionVersion = 0, className, activeItemIds, hiddenItemIds, onOptionSelect } = props;
+ const { menuType, menuSessionVersion = 0, className, activeItemIds, hiddenItemIds, sizeVariant = 'default', onOptionSelect } = props;
const menuManagerService = useDependency(IMenuManagerService);
const layoutService = useDependency(ILayoutService);
const [menuElement, setMenuElement] = useState(null);
@@ -162,12 +351,7 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
@@ -191,9 +376,10 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
}
function ContextMenuMenu(props: IContextMenuMenuProps) {
- const { menuSchemas, menuSessionVersion, submenuPortalContainer, activeItemIds, hiddenItemIds, onOptionSelect, maxMenuHeight } = props;
+ const { menuSchemas, menuSessionVersion, submenuPortalContainer, activeItemIds, hiddenItemIds, sizeVariant, onOptionSelect, maxMenuHeight } = props;
const localeService = useDependency(LocaleService);
const hiddenGroupStates = useContextGroupHiddenStates(menuSchemas);
+ const [activeSubmenuKey, setActiveSubmenuKey] = useState(null);
const visibleSchemas = useMemo(() => {
return menuSchemas.filter((item) => {
@@ -208,11 +394,78 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
return !hiddenGroupStates[item.key];
});
}, [hiddenGroupStates, menuSchemas]);
+ const renderGroups = useMemo(
+ () => getContextMenuSchemaRenderGroups(visibleSchemas, sizeVariant),
+ [sizeVariant, visibleSchemas]
+ );
+
+ const renderQuickLayoutGroup = (
+ menuSchema: IMenuSchema,
+ index: number,
+ renderAsClusterChild = false,
+ hasSeparator = false
+ ) => {
+ const titleNode = renderMenuSchemaHeader(menuSchema);
+
+ return (
+
+ {titleNode}
+ {menuSchema.quickLayout === 'tile'
+ ? (
+
+ )
+ : (
+
+ )}
+
+ );
+ };
return (
<>
- {visibleSchemas.map((menuSchema, index) => {
- const hasSeparator = index !== visibleSchemas.length - 1;
+ {renderGroups.map(({ menuSchemas: groupedSchemas, startIndex, endIndex }) => {
+ const menuSchema = groupedSchemas[0];
+ const hasSeparator = shouldShowContextMenuGroupSeparator(visibleSchemas, endIndex);
+ const titleNode = renderMenuSchemaHeader(menuSchema);
+
+ if (groupedSchemas.length > 1) {
+ return (
+ schema.key).join('-')}
+ className={clsx(
+ getContextMenuQuickGroupClusterClassName(sizeVariant),
+ hasSeparator && borderBottomClassName
+ )}
+ >
+ {groupedSchemas.map((groupedMenuSchema, groupedIndex) => renderQuickLayoutGroup(
+ groupedMenuSchema,
+ startIndex + groupedIndex,
+ true
+ ))}
+
+ );
+ }
if (menuSchema.item) {
return (
@@ -222,9 +475,12 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
menuItem={menuSchema.item as IDisplayMenuItem}
menuSessionVersion={menuSessionVersion}
submenuPortalContainer={submenuPortalContainer}
+ activeSubmenuKey={activeSubmenuKey}
+ setActiveSubmenuKey={setActiveSubmenuKey}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
hiddenItemIds={hiddenItemIds}
+ sizeVariant={sizeVariant}
/>
);
}
@@ -234,33 +490,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
}
if (menuSchema.quickLayout) {
- return (
-
- {menuSchema.quickLayout === 'tile'
- ? (
-
- )
- : (
-
- )}
-
- );
+ return renderQuickLayoutGroup(menuSchema, startIndex, false, hasSeparator);
}
if (menuSchema.tiny) {
@@ -268,7 +498,9 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
@@ -280,11 +512,14 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
menuItem={childSchema.item as IDisplayMenuItem
}
menuSessionVersion={menuSessionVersion}
submenuPortalContainer={submenuPortalContainer}
+ activeSubmenuKey={activeSubmenuKey}
+ setActiveSubmenuKey={setActiveSubmenuKey}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
compact
+ sizeVariant={sizeVariant}
/>
)
))}
@@ -296,20 +531,11 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
- {menuSchema.title && (
-
- {localeService.t(menuSchema.title)}
-
- )}
+ {titleNode}
{menuSchema.children.map((childSchema) => (
childSchema.item && (
}
menuSessionVersion={menuSessionVersion}
submenuPortalContainer={submenuPortalContainer}
+ activeSubmenuKey={activeSubmenuKey}
+ setActiveSubmenuKey={setActiveSubmenuKey}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
+ sizeVariant={sizeVariant}
/>
)
))}
@@ -330,10 +559,63 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
})}
>
);
+
+ function renderMenuSchemaHeader(menuSchema: IMenuSchema) {
+ if (!menuSchema.title) {
+ return null;
+ }
+
+ const titleContent = (
+
+ {localeService.t(menuSchema.title)}
+
+ );
+
+ if (!menuSchema.headerActionItem) {
+ return titleContent;
+ }
+
+ return (
+
+ {titleContent}
+ }
+ menuSessionVersion={menuSessionVersion}
+ submenuPortalContainer={submenuPortalContainer}
+ activeSubmenuKey={activeSubmenuKey}
+ setActiveSubmenuKey={setActiveSubmenuKey}
+ activeItemIds={activeItemIds}
+ hiddenItemIds={hiddenItemIds}
+ compact
+ headerAction
+ sizeVariant={sizeVariant}
+ onOptionSelect={onOptionSelect}
+ maxMenuHeight={maxMenuHeight}
+ />
+
+ );
+ }
}
function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
- const { menuKey, menuItem, menuSessionVersion, submenuPortalContainer, activeItemIds, hiddenItemIds = [], compact = false, onOptionSelect, maxMenuHeight } = props;
+ const {
+ menuKey,
+ menuItem,
+ menuSessionVersion,
+ submenuPortalContainer,
+ maxMenuHeight,
+ activeSubmenuKey,
+ setActiveSubmenuKey,
+ activeItemIds,
+ hiddenItemIds = [],
+ compact = false,
+ headerAction = false,
+ sizeVariant,
+ onOptionSelect,
+ } = props;
const localeService = useDependency(LocaleService);
const direction = useObservable(localeService.direction$);
const menuManagerService = useDependency(IMenuManagerService);
@@ -346,7 +628,6 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
isObservable(selectorItem.selections) ? selectorItem.selections : undefined
);
const [inputValue, setInputValue] = useState(value);
- const [submenuVisible, setSubmenuVisible] = useState(false);
const [submenuPosition, setSubmenuPosition] = useState<{
left: number;
top: number;
@@ -383,6 +664,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
const hasSelectionSubmenu = selections.length > 0;
const hasSubItemSubmenu = subMenuItems.length > 0;
const hasSubmenu = hasSelectionSubmenu || hasSubItemSubmenu;
+ const submenuVisible = hasSubmenu && activeSubmenuKey === menuKey;
const selectionsCommandId = selectorItem.selectionsCommandId;
const clearSubmenuCloseTimer = useCallback(() => {
@@ -398,9 +680,9 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
clearSubmenuCloseTimer();
submenuCloseTimerRef.current = setTimeout(() => {
submenuCloseTimerRef.current = null;
- setSubmenuVisible(false);
+ setActiveSubmenuKey((currentKey) => (currentKey === menuKey ? null : currentKey));
}, CONTEXT_MENU_SUBMENU_CLOSE_DELAY);
- }, [clearSubmenuCloseTimer]);
+ }, [clearSubmenuCloseTimer, menuKey, setActiveSubmenuKey]);
useEffect(() => {
setInputValue(value);
@@ -466,22 +748,47 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
const onSubmenuOptionSelect = (option: IValueOption) => {
onOptionSelect?.(option);
clearSubmenuCloseTimer();
- setSubmenuVisible(false);
+ setActiveSubmenuKey((currentKey) => (currentKey === menuKey ? null : currentKey));
};
const itemClassName = clsx(
compact
- ? `
- univer-relative univer-flex univer-size-8 univer-items-center univer-justify-center univer-rounded-md
- univer-border-none univer-bg-transparent univer-p-0 univer-text-left univer-text-sm
- dark:!univer-text-white
- `
- : `
- univer-relative univer-flex univer-min-h-8 univer-w-full univer-items-center univer-justify-between
- univer-gap-3 univer-rounded-md univer-border-none univer-bg-transparent univer-px-2 univer-text-left
- univer-text-sm
- dark:!univer-text-white
- `,
+ ? (
+ sizeVariant === 'paragraph-t'
+ ? `
+ univer-relative univer-flex
+ ${headerAction
+ ? 'univer-size-8 univer-rounded-md'
+ : 'univer-size-10 univer-rounded-lg'}
+ univer-items-center univer-justify-center univer-border-none univer-bg-transparent univer-p-0
+ univer-text-left univer-text-base
+ dark:!univer-text-white
+ `
+ : `
+ univer-relative univer-flex
+ ${headerAction
+ ? 'univer-size-7 univer-rounded-sm'
+ : 'univer-size-8 univer-rounded-md'}
+ univer-items-center univer-justify-center univer-border-none univer-bg-transparent univer-p-0
+ univer-text-left univer-text-sm
+ dark:!univer-text-white
+ `
+ )
+ : (
+ sizeVariant === 'paragraph-t'
+ ? `
+ univer-relative univer-flex univer-min-h-10 univer-w-full univer-items-center
+ univer-justify-between univer-gap-4 univer-rounded-lg univer-border-none univer-bg-transparent
+ univer-px-3 univer-text-left univer-text-base
+ dark:!univer-text-white
+ `
+ : `
+ univer-relative univer-flex univer-min-h-8 univer-w-full univer-items-center
+ univer-justify-between univer-gap-3 univer-rounded-md univer-border-none univer-bg-transparent
+ univer-px-2 univer-text-left univer-text-sm
+ dark:!univer-text-white
+ `
+ ),
disabled
? 'univer-cursor-not-allowed univer-opacity-60'
: `
@@ -496,7 +803,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
);
const contentNode = (
-
+
{
@@ -546,7 +853,8 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
{hasSubmenu && (
@@ -558,7 +866,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
type="button"
className={interactiveItemClassName}
disabled={disabled}
- title={compact && typeof menuItem.tooltip === 'string' ? localeService.t(menuItem.tooltip) : undefined}
+ title={typeof menuItem.tooltip === 'string' ? localeService.t(menuItem.tooltip) : undefined}
onClick={() => {
clearSubmenuCloseTimer();
if (hasSubmenu) {
@@ -575,7 +883,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
}
setSubmenuPositionReady(false);
- setSubmenuVisible(true);
+ setActiveSubmenuKey(menuKey);
return;
}
@@ -597,7 +905,8 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
{hasSubmenu && !compact && (
@@ -636,12 +945,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
>
{hasSelectionSubmenu && (
-
+
{selections.map((option, index) => {
const optionKey = `${menuItem.id}-${option.label ?? option.id}-${index}`;
const optionSelected = typeof inputValue !== 'undefined' && String(inputValue) === String(option.value);
const optionSelectable = !isNonSelectableLabel(option.label);
const optionHoverable = !isNonHoverableLabel(option.label);
const optionClassName = clsx(
- `
- univer-relative univer-box-border univer-flex univer-min-h-8
- univer-w-full univer-items-center univer-rounded-md univer-border-none
- univer-bg-transparent univer-px-2 univer-text-left univer-text-sm
- dark:!univer-text-white
- `,
+ sizeVariant === 'paragraph-t'
+ ? `
+ univer-relative univer-box-border univer-flex univer-min-h-10
+ univer-w-full univer-items-center univer-rounded-lg
+ univer-border-none univer-bg-transparent univer-px-3
+ univer-text-left univer-text-base
+ dark:!univer-text-white
+ `
+ : `
+ univer-relative univer-box-border univer-flex univer-min-h-8
+ univer-w-full univer-items-center univer-rounded-md
+ univer-border-none univer-bg-transparent univer-px-2
+ univer-text-left univer-text-sm
+ dark:!univer-text-white
+ `,
option.disabled
? 'univer-cursor-not-allowed univer-opacity-60'
: optionHoverable && `
@@ -674,14 +991,16 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
<>
{optionSelectable && optionSelected && (
)}
@@ -744,6 +1063,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
submenuPortalContainer={submenuPortalContainer}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
+ sizeVariant={sizeVariant}
onOptionSelect={onSubmenuOptionSelect}
maxMenuHeight={maxMenuHeight}
/>
diff --git a/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx b/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx
new file mode 100644
index 0000000000..8d39dc4dac
--- /dev/null
+++ b/packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx
@@ -0,0 +1,452 @@
+/**
+ * Copyright 2023-present DreamNum Co., Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { LocaleService } from '@univerjs/core';
+import React from 'react';
+import { BehaviorSubject } from 'rxjs';
+import { describe, expect, it, vi } from 'vitest';
+import { ILayoutService } from '../../../../services/layout/layout.service';
+import { MenuItemType } from '../../../../services/menu/menu';
+import { IMenuManagerService } from '../../../../services/menu/menu-manager.service';
+import {
+ CONTEXT_MENU_SUBMENU_CLOSE_DELAY,
+ CONTEXT_MENU_SUBMENU_PORTAL_ATTR,
+ ContextMenuPanel,
+ getContextMenuQuickGroupColumns,
+ getContextMenuSchemaRenderGroups,
+ shouldShowContextMenuGroupSeparator,
+} from '../ContextMenuPanel';
+
+const dependencyMap = new Map();
+const tinyMenuGroupSpy = vi.fn();
+
+vi.mock('../../../../utils/di', async () => {
+ const ReactModule = await import('react');
+
+ return {
+ useDependency(token: unknown) {
+ return dependencyMap.get(token);
+ },
+ useObservable(observable: any, defaultValue?: T) {
+ const [value, setValue] = ReactModule.useState(defaultValue);
+
+ ReactModule.useEffect(() => {
+ if (!observable) {
+ return;
+ }
+
+ const source = typeof observable === 'function' ? observable() : observable;
+ const sub = source.subscribe?.((nextValue: T) => setValue(nextValue));
+
+ return () => sub?.unsubscribe?.();
+ }, [observable]);
+
+ return value;
+ },
+ };
+});
+
+vi.mock('../../../../components/menu/desktop/TinyMenuGroup', () => ({
+ resolveMenuItemActiveState: () => false,
+ UITinyMenuGroup: (props: unknown) => {
+ tinyMenuGroupSpy(props);
+ return ;
+ },
+ UIQuickTileMenuGroup: () => ,
+}));
+
+vi.mock('../../../../components/custom-label/CustomLabel', () => ({
+ CustomLabel: () => ,
+}));
+
+describe('ContextMenuPanel', () => {
+ it('applies the enlarged paragraph T size variant without affecting the shared defaults', () => {
+ dependencyMap.clear();
+ tinyMenuGroupSpy.mockClear();
+
+ dependencyMap.set(IMenuManagerService, {
+ menuChanged$: new BehaviorSubject(undefined),
+ getMenuByPositionKey: vi.fn(() => [{
+ key: 'quickTop',
+ order: 0,
+ title: 'docs-ui.paragraphMenu.align',
+ quickLayout: 'icon',
+ children: [{
+ key: 'quickItem',
+ order: 0,
+ item: {
+ id: 'quick-item',
+ type: MenuItemType.BUTTON,
+ },
+ }],
+ }]),
+ });
+ dependencyMap.set(ILayoutService, {
+ rootContainerElement: document.body,
+ });
+ dependencyMap.set(LocaleService, {
+ t: (key: string) => key,
+ direction$: new BehaviorSubject<'ltr'>('ltr'),
+ });
+
+ const { container } = render(React.createElement(ContextMenuPanel as never, {
+ menuType: 'quick-layout-menu',
+ sizeVariant: 'paragraph-t',
+ }));
+
+ const className = (container.firstChild as HTMLDivElement | null)?.className ?? '';
+ expect(className).toContain('univer-min-w-64');
+ expect(className).toContain('univer-text-base');
+ expect(className).toContain('univer-px-3');
+ expect(className).toContain('univer-py-2');
+ expect(tinyMenuGroupSpy.mock.calls[0][0]).toEqual(expect.objectContaining({
+ columns: 6,
+ sizeVariant: 'paragraph-t',
+ }));
+ });
+
+ it('does not render a separator between consecutive header quick rows', () => {
+ expect(shouldShowContextMenuGroupSeparator([
+ { key: 'quickTop', order: 0, quickLayout: 'icon' },
+ { key: 'quickBottom', order: 1, quickLayout: 'icon' },
+ { key: 'layout', order: 2 },
+ ] as never, 0)).toBe(false);
+
+ expect(shouldShowContextMenuGroupSeparator([
+ { key: 'quickTop', order: 0, quickLayout: 'icon' },
+ { key: 'quickBottom', order: 1, quickLayout: 'icon' },
+ { key: 'layout', order: 2 },
+ ] as never, 1)).toBe(true);
+ });
+
+ it('clusters consecutive paragraph T header quick rows into one shared container', () => {
+ dependencyMap.clear();
+ tinyMenuGroupSpy.mockClear();
+
+ dependencyMap.set(IMenuManagerService, {
+ menuChanged$: new BehaviorSubject(undefined),
+ getMenuByPositionKey: vi.fn(() => [
+ {
+ key: 'quickTop',
+ order: 0,
+ quickLayout: 'icon',
+ children: [{
+ key: 'quickItemTop',
+ order: 0,
+ item: {
+ id: 'quick-item-top',
+ type: MenuItemType.BUTTON,
+ },
+ }],
+ },
+ {
+ key: 'quickBottom',
+ order: 1,
+ quickLayout: 'icon',
+ children: [{
+ key: 'quickItemBottom',
+ order: 0,
+ item: {
+ id: 'quick-item-bottom',
+ type: MenuItemType.BUTTON,
+ },
+ }],
+ },
+ ]),
+ });
+ dependencyMap.set(ILayoutService, {
+ rootContainerElement: document.body,
+ });
+ dependencyMap.set(LocaleService, {
+ t: (key: string) => key,
+ direction$: new BehaviorSubject<'ltr'>('ltr'),
+ });
+
+ const { container } = render(React.createElement(ContextMenuPanel as never, {
+ menuType: 'quick-layout-menu',
+ sizeVariant: 'paragraph-t',
+ }));
+
+ const tinyGroups = screen.getAllByTestId('tiny-menu-group');
+ const sharedCluster = container.querySelector('.univer-gap-0') as HTMLDivElement | null;
+
+ expect(sharedCluster).not.toBeNull();
+ expect(sharedCluster?.className ?? '').toContain('univer-py-2');
+ expect(sharedCluster?.querySelectorAll('[data-testid="tiny-menu-group"]').length).toBe(2);
+ expect(container.querySelectorAll('.univer-gap-0 [data-testid="tiny-menu-group"]').length).toBe(2);
+ });
+
+ it('groups consecutive paragraph T header quick rows before rendering', () => {
+ expect(getContextMenuSchemaRenderGroups([
+ { key: 'quickTop', order: 0, quickLayout: 'icon' },
+ { key: 'quickBottom', order: 1, quickLayout: 'icon' },
+ { key: 'align', order: 2 },
+ ] as never, 'paragraph-t')).toEqual([
+ {
+ startIndex: 0,
+ endIndex: 1,
+ menuSchemas: [
+ { key: 'quickTop', order: 0, quickLayout: 'icon' },
+ { key: 'quickBottom', order: 1, quickLayout: 'icon' },
+ ],
+ },
+ {
+ startIndex: 2,
+ endIndex: 2,
+ menuSchemas: [
+ { key: 'align', order: 2 },
+ ],
+ },
+ ]);
+ });
+
+ it('uses a fixed six-column layout for header quick icon groups only', () => {
+ expect(getContextMenuQuickGroupColumns({
+ key: 'quickTop',
+ order: 0,
+ quickLayout: 'icon',
+ } as never)).toBe(6);
+
+ expect(getContextMenuQuickGroupColumns({
+ key: 'quickBottom',
+ order: 1,
+ quickLayout: 'icon',
+ } as never)).toBe(6);
+
+ expect(getContextMenuQuickGroupColumns({
+ key: 'align',
+ order: 0,
+ quickLayout: 'icon',
+ } as never)).toBeUndefined();
+ });
+
+ it('passes compact quick layout metadata through to tiny menu groups', () => {
+ dependencyMap.clear();
+ tinyMenuGroupSpy.mockClear();
+
+ dependencyMap.set(IMenuManagerService, {
+ menuChanged$: new BehaviorSubject(undefined),
+ getMenuByPositionKey: vi.fn(() => [{
+ key: 'colors',
+ order: 0,
+ quickLayout: 'icon',
+ quickColumns: 8,
+ quickLayoutVariant: 'compact',
+ children: [{
+ key: 'quickItem',
+ order: 0,
+ item: {
+ id: 'quick-item',
+ type: MenuItemType.BUTTON,
+ },
+ }],
+ }]),
+ });
+ dependencyMap.set(ILayoutService, {
+ rootContainerElement: document.body,
+ });
+ dependencyMap.set(LocaleService, {
+ t: (key: string) => key,
+ direction$: new BehaviorSubject<'ltr'>('ltr'),
+ });
+
+ render(React.createElement(ContextMenuPanel as never, {
+ menuType: 'quick-layout-menu',
+ sizeVariant: 'paragraph-t',
+ }));
+
+ expect(tinyMenuGroupSpy.mock.calls[0][0]).toEqual(expect.objectContaining({
+ columns: 8,
+ layoutVariant: 'compact',
+ }));
+ });
+
+ it('renders a right-aligned header action for titled context menu groups', () => {
+ dependencyMap.clear();
+ tinyMenuGroupSpy.mockClear();
+
+ dependencyMap.set(IMenuManagerService, {
+ menuChanged$: new BehaviorSubject(undefined),
+ getMenuByPositionKey: vi.fn(() => [{
+ key: 'colors',
+ order: 0,
+ title: 'docs-ui.toolbar.textColor.main',
+ headerActionItem: {
+ id: 'header-action',
+ type: MenuItemType.BUTTON_SELECTOR,
+ icon: 'HeaderTextColorIcon',
+ tooltip: 'header-action',
+ selections: [],
+ },
+ children: [{
+ key: 'quickItem',
+ order: 0,
+ item: {
+ id: 'quick-item',
+ type: MenuItemType.BUTTON,
+ },
+ }],
+ }]),
+ });
+ dependencyMap.set(ILayoutService, {
+ rootContainerElement: document.body,
+ });
+ dependencyMap.set(LocaleService, {
+ t: (key: string) => key,
+ direction$: new BehaviorSubject<'ltr'>('ltr'),
+ });
+
+ render(React.createElement(ContextMenuPanel as never, {
+ menuType: 'quick-layout-menu',
+ sizeVariant: 'paragraph-t',
+ }));
+
+ expect(document.querySelector('button[title="header-action"]')).not.toBeNull();
+ });
+
+ it('renders quick layout group titles before icon rows', () => {
+ dependencyMap.clear();
+
+ dependencyMap.set(IMenuManagerService, {
+ menuChanged$: new BehaviorSubject(undefined),
+ getMenuByPositionKey: vi.fn(() => [{
+ key: 'quickGroup',
+ order: 0,
+ title: 'docs-ui.paragraphMenu.align',
+ quickLayout: 'icon',
+ children: [{
+ key: 'quickItem',
+ order: 0,
+ item: {
+ id: 'quick-item',
+ type: MenuItemType.BUTTON,
+ },
+ }],
+ }]),
+ });
+ dependencyMap.set(ILayoutService, {
+ rootContainerElement: document.body,
+ });
+ dependencyMap.set(LocaleService, {
+ t: (key: string) => key,
+ direction$: new BehaviorSubject<'ltr'>('ltr'),
+ });
+
+ render();
+
+ expect(screen.getAllByText('docs-ui.paragraphMenu.align').length).toBeGreaterThan(0);
+ expect(screen.getAllByTestId('tiny-menu-group').length).toBeGreaterThan(0);
+ });
+
+ it('keeps the newly hovered submenu open when switching quickly between sibling submenu items', () => {
+ vi.useFakeTimers();
+ dependencyMap.clear();
+
+ dependencyMap.set(IMenuManagerService, {
+ menuChanged$: new BehaviorSubject(undefined),
+ getMenuByPositionKey: vi.fn((key: string) => {
+ if (key === 'submenu-root') {
+ return [
+ {
+ key: 'indent',
+ order: 0,
+ item: {
+ id: 'indent',
+ type: MenuItemType.SUBITEMS,
+ title: 'indent',
+ tooltip: 'indent',
+ },
+ },
+ {
+ key: 'colors',
+ order: 1,
+ item: {
+ id: 'colors',
+ type: MenuItemType.SUBITEMS,
+ title: 'colors',
+ tooltip: 'colors',
+ },
+ },
+ ];
+ }
+
+ if (key === 'indent') {
+ return [{
+ key: 'indent-option',
+ order: 0,
+ item: {
+ id: 'indent-option',
+ type: MenuItemType.BUTTON,
+ title: 'indent option',
+ },
+ }];
+ }
+
+ if (key === 'colors') {
+ return [{
+ key: 'colors-option',
+ order: 0,
+ item: {
+ id: 'colors-option',
+ type: MenuItemType.BUTTON,
+ title: 'colors option',
+ },
+ }];
+ }
+
+ return [];
+ }),
+ });
+ dependencyMap.set(ILayoutService, {
+ rootContainerElement: document.body,
+ });
+ dependencyMap.set(LocaleService, {
+ t: (key: string) => key,
+ direction$: new BehaviorSubject<'ltr'>('ltr'),
+ });
+
+ render();
+
+ const indentButton = document.querySelector('button[title="indent"]') as HTMLButtonElement | null;
+ const colorsButton = document.querySelector('button[title="colors"]') as HTMLButtonElement | null;
+ expect(indentButton).not.toBeNull();
+ expect(colorsButton).not.toBeNull();
+
+ const indentWrapper = indentButton?.parentElement as HTMLDivElement | null;
+ const colorsWrapper = colorsButton?.parentElement as HTMLDivElement | null;
+ expect(indentWrapper).not.toBeNull();
+ expect(colorsWrapper).not.toBeNull();
+
+ act(() => {
+ fireEvent.mouseEnter(indentWrapper!);
+ });
+ expect(document.querySelectorAll(`[${CONTEXT_MENU_SUBMENU_PORTAL_ATTR}="true"]`)).toHaveLength(1);
+
+ act(() => {
+ fireEvent.mouseLeave(indentWrapper!, { relatedTarget: colorsWrapper });
+ fireEvent.mouseEnter(colorsWrapper!);
+ });
+ expect(document.querySelectorAll(`[${CONTEXT_MENU_SUBMENU_PORTAL_ATTR}="true"]`)).toHaveLength(1);
+
+ act(() => {
+ vi.advanceTimersByTime(CONTEXT_MENU_SUBMENU_CLOSE_DELAY + 10);
+ });
+ expect(document.querySelectorAll(`[${CONTEXT_MENU_SUBMENU_PORTAL_ATTR}="true"]`)).toHaveLength(1);
+
+ vi.useRealTimers();
+ });
+});
diff --git a/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx b/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx
index 75b190c7a0..9a1d85e3d1 100644
--- a/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx
+++ b/packages/ui/src/views/components/ribbon/TooltipButtonWrapper.tsx
@@ -114,7 +114,7 @@ export function DropdownWrapper(props: Omit, 'overlay'>
);
}
-function Label({ icon, value, option, onOptionSelect }: {
+export function DropdownMenuLabel({ icon, value, option, onOptionSelect }: {
icon?: IMenuItem['icon'];
value?: string | number;
option: IValueOption;
@@ -125,26 +125,27 @@ function Label({ icon, value, option, onOptionSelect }: {
};
const hasCheckMark = typeof option.label === 'string' || (typeof option.label === 'object' && option.label?.selectable !== false);
+ const selected = hasCheckMark && String(value) === String(option.value);
return (
-
- {hasCheckMark && String(value) === String(option.value) && (
-
+
+
+
+ {hasCheckMark && (
+
+ {selected && (
+
+ )}
+
)}
-
);
}
@@ -244,7 +245,7 @@ export function DropdownMenuWrapper({
(
-