import * as React from 'react'; import { isCursor, isElementDisabled, useListItemRegister, useListItemRovingFocus, useListItemSelect, } from '@workday/canvas-kit-react/collection'; import { changeFocus, composeHooks, cornerShapeStencil, createComponent, createElemPropsHook, createSubcomponent, useLocalRef, } from '@workday/canvas-kit-react/common'; import {SystemIcon, SystemIconProps, systemIconStencil} from '@workday/canvas-kit-react/icon'; import {mergeStyles} from '@workday/canvas-kit-react/layout'; import {OverflowTooltip} from '@workday/canvas-kit-react/tooltip'; import {CSProps, createStencil, handleCsProp, px2rem} from '@workday/canvas-kit-styling'; import {system} from '@workday/canvas-tokens-web'; import {useMenuModel} from './useMenuModel'; export interface MenuItemProps extends CSProps { /** * Optionally pass index to menu item. This should be done if `Menu.Item` components were created * via a `Array::map` function. This index will ensure keyboard navigation works even if items are * inserted out of order. */ index?: number; /** * The label text of the MenuItem. */ children?: React.ReactNode; /** * The name of the menu item. This name will be used in the `onSelect` callback in the model. If * this property is not provided, it will default to a string representation of the the zero-based * index of the Tab when it was initialized. */ 'data-id'?: string; /** * `aria-disabled` is used for keyboard and screen reader users to discover disabled content with * the keyboard or screen reader caret tool. For more information, see * https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls */ 'aria-disabled'?: boolean; /** * If true, set the StyledMenuItem to the disabled state so it is not clickable. * @default false * @deprecated Use `aria-disabled` instead */ isDisabled?: boolean; } export const menuItemStencil = createStencil({ extends: cornerShapeStencil, parts: { text: 'menu-item-text', icon: 'menu-item-icon', selected: 'menu-item-selected', }, base: ({textPart, iconPart, selectedPart}) => ({ fontFamily: system.fontFamily.default, fontWeight: system.fontWeight.normal, fontSize: system.legacy.fontSize.subtext.lg, lineHeight: system.legacy.lineHeight.subtext.lg, letterSpacing: system.legacy.letterSpacing.subtext.lg, display: 'flex', alignItems: 'center', width: '100%', gap: system.legacy.gap.sm, padding: `${system.legacy.padding.xs} ${system.legacy.padding.xs} ${system.legacy.padding.xs} ${system.legacy.padding.sm}`, boxSizing: 'border-box', cursor: 'pointer', color: system.color.fg.strong, borderWidth: 0, [cornerShapeStencil.vars.shape]: system.legacy.shape.lg, textAlign: 'start', transition: 'background-color 80ms, color 80ms', backgroundColor: 'inherit', minHeight: system.legacy.size.md, overflowWrap: 'anywhere', // We want the icon colors to be the same as the text color [systemIconStencil.vars.color]: 'currentColor', // selected checkmark [`& :where(${selectedPart})`]: { transition: 'opacity 80ms linear', opacity: system.opacity.zero, }, // if the menu item has children we need it to be displayed in flex '&:where(:has(span))': { display: 'flex', }, // Selected styles '&[aria-selected=true]': { color: system.legacy.color.brand.fg.selected, backgroundColor: system.legacy.color.brand.surface.selected, [`& :where(${selectedPart})`]: { opacity: system.opacity.full, }, '&:where(.focus, :focus-visible)': { outline: `${px2rem(2)} solid ${system.legacy.color.brand.border.primary}`, outlineOffset: `-${px2rem(2)}`, }, }, // Hover styles '&:is(.hover, :hover)': { backgroundColor: system.legacy.color.surface.overlay.hover.default, }, // Active styles '&:is(.active, :active)': { backgroundColor: system.legacy.color.surface.overlay.pressed.default, }, // Focus styles '&:is(.focus, :focus-visible)': { outline: `${px2rem(2)} solid ${system.legacy.color.brand.border.primary}`, outlineOffset: `-${px2rem(2)}`, }, // Focus + Expanded Submenu // When a `Submenu.TargetItem`'s submenu is open, real focus has moved into the submenu, so we // de-emphasize this item's focus ring and show selected styling instead to indicate it's part // of the open ancestor trail rather than the actively focused item. '&[aria-expanded="true"]:is(.focus, :focus-visible)': { color: system.legacy.color.brand.fg.selected, backgroundColor: system.legacy.color.brand.surface.selected, outline: 'none', }, // Disabled styles '&:is(:disabled, [aria-disabled=true])': { cursor: 'default', opacity: system.opacity.disabled, '&:where(.hover, :hover)': { background: 'none', }, // Focus + Disabled '&:where(.focus, :focus-visible)': { color: system.legacy.color.brand.fg.selected, backgroundColor: system.legacy.color.brand.surface.selected, opacity: system.opacity.disabled, }, }, [`& :where(${textPart})`]: { flexGrow: 1, alignSelf: 'center', }, }), }); const MenuItemIcon = (elemProps: SystemIconProps) => { return ; }; const MenuItemText = createComponent('span')({ Component: ({...elemProps}: CSProps, ref, Element) => { return ; }, }); export const StyledMenuItem = createComponent('button')({ displayName: 'MenuItem', Component: ({children, isDisabled, ...elemProps}: MenuItemProps, ref, Element) => { return ( {typeof children === 'string' ? {children} : children} ); }, }); export const useMenuItemArrowReturn = createElemPropsHook(useMenuModel)(model => { return { onKeyDown(event: React.KeyboardEvent) { const styles = getComputedStyle(event.currentTarget); if (event.key === 'ArrowLeft' && styles.direction === 'ltr' && model.UNSTABLE_parentModel) { event.preventDefault(); const target = model.state.targetRef.current as HTMLElement | null; model.events.hide(event); requestAnimationFrame(() => { changeFocus(target); }); } }, }; }); export const useMenuItemFocus = createElemPropsHook(useMenuModel)(( model, ref, elemProps: {'data-id': string} = {'data-id': ''} ) => { const {localRef, elementRef} = useLocalRef(ref as React.Ref); const id = elemProps['data-id']; // A menu keeps its cursor between openings, so reopening would restore focus to a disabled item. // Items remount whenever the menu opens, which distinguishes reopening from navigating onto a // disabled item while the menu is already open. Clearing the cursor lets the roving focus // fallback in `useListItemRovingFocus` move focus to the first item. The check waits for the // first render where `id` is known, since items without an explicit `data-id` register it later. const hasCheckedDisabledCursor = React.useRef(false); React.useLayoutEffect(() => { if (hasCheckedDisabledCursor.current || !id) { return; } hasCheckedDisabledCursor.current = true; const isItemDisabled = isElementDisabled(localRef.current); if (model.state.mode === 'single' && isCursor(model.state, id) && isItemDisabled) { model.events.goToFirst(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); // focus on the item with the cursor React.useLayoutEffect(() => { if (model.state.mode === 'single') { if (isCursor(model.state, id)) { // delay focus changes to allow PopperJS to position requestAnimationFrame(() => { localRef.current?.focus(); }); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, localRef, model.state.cursorId, model.state.mode]); return { ref: elementRef, className: isCursor(model.state, elemProps['data-id']) ? 'focus' : undefined, }; }); function hideParent(model: ReturnType) { if (model.UNSTABLE_parentModel) { (model.UNSTABLE_parentModel as any).events.hide(); hideParent(model.UNSTABLE_parentModel as any); } } export const useMenuItem = composeHooks( createElemPropsHook(useMenuModel)(model => { return { role: 'menuitem', onMouseDown(event: React.MouseEvent) { model.events.goTo({id: event.currentTarget.getAttribute('data-id')!}); }, onClick: model.state.mode === 'single' ? (event: React.SyntheticEvent) => { if (isElementDisabled(event.currentTarget as Element)) { return null; } model.events.hide(event); hideParent(model); return undefined; } : undefined, }; }), useMenuItemFocus, useMenuItemArrowReturn, useListItemSelect, useListItemRovingFocus, useListItemRegister ); export const MenuItem = createSubcomponent('button')({ displayName: 'Menu.Item', modelHook: useMenuModel, elemPropsHook: useMenuItem, subComponents: { Icon: MenuItemIcon, Text: MenuItemText, }, })(({children, ...elemProps}, Element) => { return ( {children} ); });