Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/toolbar-overflow-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] Toolbar: support responsive OverflowList composition by allowing action slots to shrink, excluding measurement and nested composite controls from roving focus, and letting MoreMenu render stateful compound menu items.

@ernestt
91 changes: 91 additions & 0 deletions apps/storybook/stories/Toolbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import {Layout} from '@astryxdesign/core/Layout';
import {LayoutHeader} from '@astryxdesign/core/Layout';
import {LayoutContent} from '@astryxdesign/core/Layout';
import {MoreMenu} from '@astryxdesign/core/MoreMenu';
import {OverflowList} from '@astryxdesign/core/OverflowList';
import {ToggleButton} from '@astryxdesign/core/ToggleButton';
import {DropdownMenuCheckboxItem} from '@astryxdesign/core/DropdownMenu';
import {Heading} from '@astryxdesign/core/Text';
import {
Cog6ToothIcon,
Expand Down Expand Up @@ -46,6 +49,88 @@ const meta: Meta<typeof Toolbar> = {
export default meta;
type Story = StoryObj<typeof Toolbar>;

const formattingActions = [
{id: 'bold', label: 'Bold'},
{id: 'italic', label: 'Italic'},
{id: 'underline', label: 'Underline'},
{id: 'code', label: 'Code'},
{id: 'link', label: 'Link'},
] as const;

function ResponsiveOverflowToolbar() {
const [width, setWidth] = useState(360);
const [activeActions, setActiveActions] = useState<Set<string>>(
() => new Set(['bold']),
);

const updateAction = (id: string, isActive: boolean) => {
setActiveActions(current => {
const next = new Set(current);
if (isActive) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
};

return (
<div style={{display: 'grid', gap: 16}}>
<label style={{display: 'grid', gap: 8, maxWidth: 420}}>
Toolbar width: {width}px
<input
aria-label="Toolbar width"
type="range"
min={180}
max={620}
value={width}
onChange={event => setWidth(Number(event.currentTarget.value))}
/>
</label>
<div style={{width, maxWidth: '100%'}}>
<Toolbar
label="Formatting actions"
size="sm"
dividers={['top', 'bottom']}
startContent={
<OverflowList
gap={1}
minVisibleItems={1}
overflowRenderer={overflowItems => (
<MoreMenu label="More formatting options">
{overflowItems.map(({index}) => {
const action = formattingActions[index];
return (
<DropdownMenuCheckboxItem
key={action.id}
label={action.label}
value={activeActions.has(action.id)}
onChange={isActive => updateAction(action.id, isActive)}
/>
);
})}
</MoreMenu>
)}>
{formattingActions.map(action => (
<ToggleButton
key={action.id}
label={action.label}
size="sm"
isPressed={activeActions.has(action.id)}
onPressedChange={isActive =>
updateAction(action.id, isActive)
}
/>
))}
</OverflowList>
}
/>
</div>
</div>
);
}

// ---------------------------------------------------------------------------
// Basic slot patterns
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -144,6 +229,12 @@ export const Compact: Story = {
},
};

/** Resize the container to stress-test responsive actions and their stateful overflow menu. */
export const ResponsiveOverflow: Story = {
name: 'Composition: Responsive Overflow',
render: () => <ResponsiveOverflowToolbar />,
};

export const WashVariant: Story = {
args: {
label: 'Highlighted toolbar',
Expand Down
22 changes: 17 additions & 5 deletions packages/core/src/MoreMenu/MoreMenu.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ export const docs = {
name: 'items',
type: 'DropdownMenuOption[]',
description:
'Menu items: data array of actions, dividers, and sections. Same type as DropdownMenu items prop.',
required: true,
'Menu items: data array of actions, dividers, and sections. Same type as DropdownMenu items prop. Mutually exclusive with children.',
},
{
name: 'children',
type: 'ReactNode',
description:
'Compound DropdownMenu item components for dynamic or stateful menus. Mutually exclusive with items.',
},
{
name: 'label',
Expand Down Expand Up @@ -86,8 +91,13 @@ export const docsZh = {
name: 'items',
type: 'DropdownMenuOption[]',
description:
'菜单项,由操作、分割线和分组组成的数据数组。类型与 DropdownMenu 的 items 属性相同。',
required: true,
'菜单项,由操作、分割线和分组组成的数据数组。类型与 DropdownMenu 的 items 属性相同,与 children 互斥。',
},
{
name: 'children',
type: 'ReactNode',
description:
'用于动态或有状态菜单的 DropdownMenu 组合式菜单项组件,与 items 互斥。',
},
{
name: 'label',
Expand Down Expand Up @@ -156,7 +166,9 @@ export const docsDense = {
],
},
propDescriptions: {
items: 'Menu items (actions, dividers, sections). Same type as DropdownMenu items.',
items: 'Menu items (actions, dividers, sections). Mutually exclusive with children.',
children:
'Compound DropdownMenu item components for dynamic or stateful menus. Mutually exclusive with items.',
label: 'Accessible label (aria-label) + tooltip text.',
variant: 'Trigger button visual style variant.',
size: 'Trigger button size.',
Expand Down
28 changes: 27 additions & 1 deletion packages/core/src/MoreMenu/MoreMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/**
* @file MoreMenu.test.tsx
* @input Uses vitest, @testing-library/react, MoreMenu component
* @output Unit tests for MoreMenu component behavior
* @output Unit tests for MoreMenu data and compound-child behavior
* @position Testing; validates MoreMenu.tsx implementation
*
* SYNC: When MoreMenu.tsx changes, update tests to match new behavior
Expand All @@ -13,6 +13,7 @@ import {describe, it, expect, vi, beforeEach} from 'vitest';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {MoreMenu} from './MoreMenu';
import {DropdownMenuCheckboxItem} from '../DropdownMenu';

// Mock showPopover and hidePopover methods since they're not implemented in jsdom
beforeEach(() => {
Expand Down Expand Up @@ -76,6 +77,31 @@ describe('MoreMenu', () => {
).toBeInTheDocument();
});

it('supports compound menu items', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<MoreMenu>
<DropdownMenuCheckboxItem
label="Bold"
value={false}
onChange={handleChange}
/>
</MoreMenu>,
);

const item = screen.getByRole('menuitemcheckbox', {
name: 'Bold',
hidden: true,
});
expect(item).toHaveAttribute('aria-checked', 'false');

await user.click(screen.getByRole('button', {name: 'More options'}));
await user.click(item);

expect(handleChange).toHaveBeenCalledWith(true);
});

it('opens menu when button is clicked', async () => {
const user = userEvent.setup();
render(<MoreMenu items={defaultItems} />);
Expand Down
35 changes: 26 additions & 9 deletions packages/core/src/MoreMenu/MoreMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

/**
* @file MoreMenu.tsx
* @input Uses DropdownMenu, getIcon
* @input Uses DropdownMenu data or compound children, useIcon
* @output Exports MoreMenu component and MoreMenuProps type
* @position Core implementation; consumed by index.ts
*
Expand All @@ -28,19 +28,13 @@ import type {BaseProps} from '../BaseProps';
import {stableClassName} from '../naming';
import {useTranslator} from '../i18n';

export interface MoreMenuProps extends Pick<
interface MoreMenuBaseProps extends Pick<
BaseProps,
'xstyle' | 'className' | 'style'
> {
/** Ref forwarded to the trigger button */
ref?: React.Ref<HTMLButtonElement>;

/**
* Menu items \u2014 data array of actions, dividers, and sections.
* Same type as DropdownMenu's `items` prop.
*/
items: DropdownMenuOption[];

/**
* Accessible label for the trigger button.
* Always used as aria-label (the button is always icon-only).
Expand Down Expand Up @@ -86,10 +80,32 @@ export interface MoreMenuProps extends Pick<
'data-testid'?: string;
}

interface MoreMenuDataProps extends MoreMenuBaseProps {
/**
* Menu items \u2014 data array of actions, dividers, and sections.
* Same type as DropdownMenu's `items` prop. Mutually exclusive with
* `children`.
*/
items: DropdownMenuOption[];
children?: undefined;
}

interface MoreMenuCompoundProps extends MoreMenuBaseProps {
/**
* Compound DropdownMenu item components for dynamic or stateful menus.
* Mutually exclusive with `items`.
*/
children: ReactNode;
items?: undefined;
}

export type MoreMenuProps = MoreMenuDataProps | MoreMenuCompoundProps;

/**
* Overflow menu with a three-dot icon trigger.
*
* A convenience wrapper around DropdownMenu with icon-only button defaults.
* Supports the same data-driven `items` and compound `children` modes.
*
* @example
* ```
Expand All @@ -103,6 +119,7 @@ export interface MoreMenuProps extends Pick<
*/
export function MoreMenu({
items,
children,
label: labelFromProps,
variant = 'ghost',
size: sizeProp,
Expand Down Expand Up @@ -142,9 +159,9 @@ export function MoreMenu({
isIconOnly: true,
ref,
}}
items={items}
hasChevron={false}
data-testid={testId}
{...(items !== undefined ? {items} : {children})}
/>
);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/Toolbar/Toolbar.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const docs = {
{guidance: true, description: 'Make temporary toolbars like bulk selection visually distinct so users can tell they\'re contextual, for example with a background color or border.'},
{guidance: true, description: 'Visually separate the toolbar from the content below it, with a divider, a background variant, or both.'},
{guidance: true, description: 'Use Toolbar as a card header when the header has interactive actions like filter or add; it gives you slot layout, keyboard navigation, and size cascading. If the header is just a title with no actions, a LayoutHeader or Section is enough.'},
{guidance: true, description: 'Use OverflowList for responsive actions; Toolbar focus navigation automatically ignores its measurement copy and nested overflow menus.'},
{guidance: false, description: 'Put too many actions in one toolbar; move less common items into a MoreMenu.'},
{guidance: false, description: 'Set size on individual child buttons; set it once on the toolbar and it cascades automatically.'},
{guidance: false, description: 'Use Toolbar for app-wide navigation like main menu links or sign out; use TopNav or LayoutHeader for that.'},
Expand Down Expand Up @@ -184,6 +185,7 @@ export const docsZh = {
{guidance: true, description: 'Make temporary toolbars like bulk selection visually distinct so users can tell they\'re contextual, for example with a background color or border.'},
{guidance: true, description: 'Visually separate the toolbar from the content below it, with a divider, a background variant, or both.'},
{guidance: true, description: 'Use Toolbar as a card header when the header has interactive actions like filter or add; it gives you slot layout, keyboard navigation, and size cascading. If the header is just a title with no actions, a LayoutHeader or Section is enough.'},
{guidance: true, description: '响应式操作可使用 OverflowList;Toolbar 的焦点导航会自动忽略测量副本和嵌套的溢出菜单。'},
{guidance: false, description: 'Put too many actions in one toolbar; move less common items into a MoreMenu.'},
{guidance: false, description: 'Set size on individual child buttons; set it once on the toolbar and it cascades automatically.'},
{guidance: false, description: 'Use Toolbar for app-wide navigation like main menu links or sign out; use TopNav or LayoutHeader for that.'},
Expand All @@ -202,6 +204,7 @@ export const docsDense = {
{guidance: true, description: 'Make temporary toolbars (bulk selection) visually distinct, e.g. background color or border.'},
{guidance: true, description: 'Separate toolbar from content: divider, background variant, or both.'},
{guidance: true, description: 'Use Toolbar as card header when it has actions (filter, add). Just a title? Use LayoutHeader/Section.'},
{guidance: true, description: 'Use OverflowList for responsive actions; Toolbar ignores measurement copies and nested overflow menus during focus navigation.'},
{guidance: false, description: 'Overload with actions; use MoreMenu for overflow.'},
{guidance: false, description: 'Set size on child buttons; set once on toolbar, it cascades.'},
{guidance: false, description: 'Use for app-wide nav (menu links, sign out); use TopNav/LayoutHeader.'},
Expand Down
Loading
Loading