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}
);
});