diff --git a/cypress/component/SubmenuPlacement.spec.tsx b/cypress/component/SubmenuPlacement.spec.tsx new file mode 100644 index 0000000000..96e8852dac --- /dev/null +++ b/cypress/component/SubmenuPlacement.spec.tsx @@ -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(); + 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( + + ); + 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(); + 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( + + ); + 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(); + }); + }); +}); diff --git a/modules/react/menu/lib/Submenu.tsx b/modules/react/menu/lib/Submenu.tsx index d2038dfdf5..16b9204dd5 100644 --- a/modules/react/menu/lib/Submenu.tsx +++ b/modules/react/menu/lib/Submenu.tsx @@ -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'; @@ -40,13 +40,62 @@ export interface SubmenuProps export const SubmenuPopper = createSubcomponent('div')({ modelHook: useMenuModel, elemPropsHook: useMenuPopper, -})>(({children, ...elemProps}) => { - return ( - - {children} - - ); -}); +})>( + ({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 ( + + {children} + + ); + } +); const useIntentTimer = (fn: Function, waitMs: number = 0): {start(): void; clear(): void} => { const timer = React.useRef(undefined); diff --git a/modules/react/menu/stories/Menu.stories.ts b/modules/react/menu/stories/Menu.stories.ts index 8d565371f6..34916a6a07 100644 --- a/modules/react/menu/stories/Menu.stories.ts +++ b/modules/react/menu/stories/Menu.stories.ts @@ -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 { @@ -50,6 +51,9 @@ export const NestedDynamic: Story = { export const NestedSiblings: Story = { render: NestedSiblingsExample, }; +export const ScrollableSubmenu: StoryObj = { + render: ScrollableSubmenuExample, +}; export const Alt: Story = { render: AltExample, }; diff --git a/modules/react/menu/stories/examples/ScrollableSubmenu.tsx b/modules/react/menu/stories/examples/ScrollableSubmenu.tsx new file mode 100644 index 0000000000..29086093d1 --- /dev/null +++ b/modules/react/menu/stories/examples/ScrollableSubmenu.tsx @@ -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; +}) => ( + + + + Open Menu + + + + First Item + Second Item + + More Items + + + + {Array.from({length: 6}, (_, index) => ( + + Additional Menu Item {index + 1} + + ))} + + + + + {Array.from({length: 15}, (_, index) => ( + + Item {index + 1} + + ))} + + + + + + +);