Skip to content
Open
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
144 changes: 144 additions & 0 deletions cypress/component/SubmenuPlacement.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {ScrollableSubmenu} from '../../modules/react/menu/stories/examples/ScrollableSubmenu';

describe('Submenu placement', () => {
(['ltr', 'rtl'] as const).forEach(dir => {
[false, true].forEach(atEnd => {
const side = (dir === 'ltr') !== atEnd ? 'right' : 'left';

context(`given a scrollable ${dir} menu at the ${atEnd ? 'end' : 'start'} edge`, () => {
beforeEach(() => {
cy.mount(<ScrollableSubmenu dir={dir} atEnd={atEnd} />);
cy.document().then(document => {
const style = document.createElement('style');
style.id = 'submenu-scrollbar-test';
style.textContent = `
[data-testid="scrollable-menu"] [data-part="list-box-container"] {
scrollbar-gutter: stable both-edges;
}
[data-testid="scrollable-menu"] [data-part="list-box-container"]::-webkit-scrollbar {
width: 16px;
}
`;
document.head.appendChild(style);
});
cy.findByRole('button', {name: 'Open Menu'}).click();
cy.findByRole('menuitem', {name: 'More Items'}).realHover();
cy.findByTestId('submenu').should('be.visible');
});

afterEach(() => {
cy.document().then(document =>
document.getElementById('submenu-scrollbar-test')?.remove()
);
});

it('should pass axe checks', () => {
cy.checkA11y();
});

it(`should open beside the parent on the ${side}`, () => {
cy.findByTestId('submenu')
.closest('[data-popper-placement]')
.should('have.attr', 'data-popper-placement', `${side}-start`);
});

context('when the parent menu is scrolled', () => {
beforeEach(() => {
cy.findByTestId('scrollable-menu')
.find('[data-part="list-box-container"]')
.scrollTo(0, 280);
cy.findByTestId('submenu')
.closest('[data-popper-placement]')
.should('have.attr', 'data-popper-reference-hidden');
});

it('should remain beside the parent without covering it', () => {
cy.findByTestId('scrollable-menu').then($parent => {
const parent = $parent[0].getBoundingClientRect();
cy.findByTestId('submenu').should($submenu => {
const submenu = $submenu[0].getBoundingClientRect();
if (side === 'right') {
expect(submenu.left).to.be.at.least(parent.right);
} else {
expect(submenu.right).to.be.at.most(parent.left);
}
});
});
});
});
});
});
});

context('given an explicit submenu placement', () => {
beforeEach(() => {
cy.mount(
<ScrollableSubmenu
submenuPopperProps={{placement: 'bottom-start', fallbackPlacements: ['top-start']}}
/>
);
cy.findByRole('button', {name: 'Open Menu'}).click();
cy.findByRole('menuitem', {name: 'More Items'}).realHover();
cy.findByTestId('submenu').should('be.visible');
});

it('should preserve the custom placement', () => {
cy.findByTestId('submenu')
.closest('[data-popper-placement]')
.should('have.attr', 'data-popper-placement', 'bottom-start');
});

it('should pass axe checks', () => {
cy.checkA11y();
});
});

context('given an empty fallback list at the right edge', () => {
beforeEach(() => {
cy.mount(<ScrollableSubmenu atEnd submenuPopperProps={{fallbackPlacements: []}} />);
cy.findByRole('button', {name: 'Open Menu'}).click();
cy.findByRole('menuitem', {name: 'More Items'}).realHover();
cy.findByTestId('submenu').should('be.visible');
});

it('should still allow the opposite placement', () => {
cy.findByTestId('submenu')
.closest('[data-popper-placement]')
.should('have.attr', 'data-popper-placement', 'left-start');
});

it('should pass axe checks', () => {
cy.checkA11y();
});
});

context('given custom Popper options', () => {
beforeEach(() => {
cy.mount(
<ScrollableSubmenu
submenuPopperProps={{
popperOptions: {modifiers: [{name: 'offset', options: {offset: [0, 24]}}]},
}}
/>
);
cy.findByRole('button', {name: 'Open Menu'}).click();
cy.findByRole('menuitem', {name: 'More Items'}).realHover();
cy.findByTestId('submenu').should('be.visible');
});

it('should preserve the custom offset', () => {
cy.findByRole('menuitem', {name: 'More Items'}).then($target => {
const target = $target[0].getBoundingClientRect();
cy.findByTestId('submenu')
.closest('[data-popper-placement]')
.should($popper => {
expect($popper[0].getBoundingClientRect().left).to.be.closeTo(target.right + 24, 1);
});
});
});

it('should pass axe checks', () => {
cy.checkA11y();
});
});
});
65 changes: 57 additions & 8 deletions modules/react/menu/lib/Submenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
subModelHook,
useForkRef,
} from '@workday/canvas-kit-react/common';
import {Popper} from '@workday/canvas-kit-react/popup';
import {Placement, Popper} from '@workday/canvas-kit-react/popup';
import {chevronRightSmallIcon} from '@workday/canvas-system-icons-web';

import {MenuCard} from './MenuCard';
Expand All @@ -40,13 +40,62 @@ export interface SubmenuProps
export const SubmenuPopper = createSubcomponent('div')({
modelHook: useMenuModel,
elemPropsHook: useMenuPopper,
})<ExtractProps<typeof Popper>>(({children, ...elemProps}) => {
return (
<Popper placement="right-start" popperOptions={defaultMenuPopperOptions} {...elemProps}>
{children}
</Popper>
);
});
})<ExtractProps<typeof Popper>>(
({children, fallbackPlacements, popperOptions, ...elemProps}, Element, model) => {
const parentContainerRef = model.UNSTABLE_parentModel?.state.containerRef;
const targetRef = model.state.targetRef;
const submenuPopperOptions = React.useMemo(
() => ({
...popperOptions,
modifiers: [
// Keep the default submenu beside its parent when scrolling clips the target vertically.
// Explicit fallback placements retain the caller's existing positioning behavior.
...(fallbackPlacements === undefined
? [
{name: 'fallbackModifier', enabled: false},
{name: 'flip', options: {altAxis: false, flipVariations: false}},
]
: []),
...(popperOptions?.modifiers ?? [
{
name: 'offset',
options: {
offset: ({placement}: {placement: Placement}) => {
const [skidding, distance] =
defaultMenuPopperOptions.modifiers[0].options.offset();
const parent = parentContainerRef?.current?.getBoundingClientRect();
const target = targetRef.current?.getBoundingClientRect();
// Scrollbar gutters narrow the target without narrowing the parent menu.
const gutter =
parent && target
? placement.startsWith('right')
? parent.right - target.right
: placement.startsWith('left')
? target.left - parent.left
: 0
: 0;
return [skidding, distance + Math.max(0, gutter)];
},
},
},
]),
],
}),
[fallbackPlacements, popperOptions, parentContainerRef, targetRef]
);

return (
<Popper
placement="right-start"
fallbackPlacements={fallbackPlacements}
popperOptions={submenuPopperOptions}
{...elemProps}
>
{children}
</Popper>
);
}
);

const useIntentTimer = (fn: Function, waitMs: number = 0): {start(): void; clear(): void} => {
const timer = React.useRef<number | undefined>(undefined);
Expand Down
4 changes: 4 additions & 0 deletions modules/react/menu/stories/Menu.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {Icons as IconsExample} from './examples/Icons';
import {Nested as NestedExample} from './examples/Nested';
import {NestedDynamic as NestedDynamicExample} from './examples/NestedDynamic';
import {NestedSiblings as NestedSiblingsExample} from './examples/NestedSiblings';
import {ScrollableSubmenu as ScrollableSubmenuExample} from './examples/ScrollableSubmenu';
import {SelectableMenu as SelectableMenuExample} from './examples/SelectableMenu';

export default {
Expand Down Expand Up @@ -50,6 +51,9 @@ export const NestedDynamic: Story = {
export const NestedSiblings: Story = {
render: NestedSiblingsExample,
};
export const ScrollableSubmenu: StoryObj<typeof ScrollableSubmenuExample> = {
render: ScrollableSubmenuExample,
};
export const Alt: Story = {
render: AltExample,
};
57 changes: 57 additions & 0 deletions modules/react/menu/stories/examples/ScrollableSubmenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {CanvasProvider} from '@workday/canvas-kit-react/common';
import {Menu, menuCardStencil} from '@workday/canvas-kit-react/menu';
import {PopperProps} from '@workday/canvas-kit-react/popup';
import {createStencil, createStyles, handleCsProp, px2rem} from '@workday/canvas-kit-styling';

const containerStencil = createStencil({
base: {display: 'flex'},
modifiers: {atEnd: {true: {justifyContent: 'flex-end'}}},
});

// A fixed height makes the parent list scroll independently of the page.
const cardStyles = createStyles({[menuCardStencil.vars.maxHeight]: px2rem(280)});

export const ScrollableSubmenu = ({
dir = 'ltr',
atEnd = false,
submenuPopperProps,
}: {
dir?: 'ltr' | 'rtl';
atEnd?: boolean;
submenuPopperProps?: Pick<PopperProps, 'placement' | 'fallbackPlacements' | 'popperOptions'>;
}) => (
<CanvasProvider dir={dir}>
<div {...handleCsProp({}, containerStencil({atEnd}))}>
<Menu>
<Menu.Target>Open Menu</Menu.Target>
<Menu.Popper>
<Menu.Card cs={cardStyles} data-testid="scrollable-menu">
<Menu.List>
<Menu.Item data-id="first">First Item</Menu.Item>
<Menu.Item data-id="second">Second Item</Menu.Item>
<Menu.Submenu>
<Menu.Submenu.TargetItem data-id="submenu">More Items</Menu.Submenu.TargetItem>
<Menu.Submenu.Popper {...submenuPopperProps}>
<Menu.Submenu.Card>
<Menu.Submenu.List data-testid="submenu">
{Array.from({length: 6}, (_, index) => (
<Menu.Submenu.Item key={index} data-id={`sub-${index}`}>
Additional Menu Item {index + 1}
</Menu.Submenu.Item>
))}
</Menu.Submenu.List>
</Menu.Submenu.Card>
</Menu.Submenu.Popper>
</Menu.Submenu>
{Array.from({length: 15}, (_, index) => (
<Menu.Item key={index} data-id={`item-${index}`}>
Item {index + 1}
</Menu.Item>
))}
</Menu.List>
</Menu.Card>
</Menu.Popper>
</Menu>
</div>
</CanvasProvider>
);
Loading