Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(menu): Add MenuOption and convert to MenuItem to Stencil #2969

Merged
merged 12 commits into from
Oct 9, 2024
19 changes: 19 additions & 0 deletions modules/docs/mdx/12.0-UPGRADE-GUIDE.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ A note to the reader:
- [Search Form](#search-form)
- [Select](#select)
- [Text Area](#text-area)
- [Menu Item](#menu-item)
- [Troubleshooting](#troubleshooting)
- [Glossary](#glossary)
- [Main](#main)
Expand Down Expand Up @@ -453,6 +454,24 @@ interface MyProps {
}
```

### Menu Item

**PR: ** [2969](https://github.com/Workday/canvas-kit/pull/2969)

`Menu.Item` was converted to use Stencils for styling and uses SystemIcon stencil variables to
change icon color instead of deeply nested selectors. We also added a `type` prop that can be either
`selectable` or `actionable`. The default for `Menu.Item` is `actionable`, while components with the
primary function of selection (like `Select`) default to `selectable`.

- `actionable` - used for menu items where the user performs an action rather than selecting. A
selected checkmark does not show for `actionable` menu items.
- `selectable` - used for menu items where the user selects an option. A selected checkmark will
show. Note this may cause visual regression tests to trigger, but this is intended.

We've deprecated the `isDisabled` prop. It didn't do anything in v10 or v11. It was part of the
preview Menu deprecation, but was never hooked up. We mapped it to `aria-disabled` and added a
deprecation comment to use `aria-disabled` instead.

## Troubleshooting

### My Styles Seem Broken?
Expand Down
2 changes: 1 addition & 1 deletion modules/labs-react/combobox/spec/Combobox.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe('Combobox', () => {
const menuText = 'menuText';
const id = 'my-id';
const autocompleteItems = [
<StyledMenuItem isDisabled={true} onClick={cb}>
<StyledMenuItem aria-disabled={true} onClick={cb}>
{menuText}
</StyledMenuItem>,
];
Expand Down
2 changes: 1 addition & 1 deletion modules/labs-react/combobox/stories/examples/Basic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const autocompleteResult = (
textModifier: number,
disabled: boolean
): ReactElement<MenuItemProps> => (
<StyledMenuItem isDisabled={disabled}>
<StyledMenuItem aria-disabled={disabled}>
Result
<span>
num<span>ber</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const autocompleteResult = (
textModifier: number,
disabled: boolean
): ReactElement<MenuItemProps> => (
<StyledMenuItem isDisabled={disabled}>
<StyledMenuItem aria-disabled={disabled}>
Result
<span>
num<span>ber</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const autocompleteResult = (
textModifier: number,
disabled: boolean
): ReactElement<MenuItemProps> => (
<StyledMenuItem isDisabled={disabled}>
<StyledMenuItem aria-disabled={disabled}>
Result{' '}
<span>
num<span>ber</span>
Expand Down
2 changes: 1 addition & 1 deletion modules/labs-react/combobox/stories/examples/Grow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const autocompleteResult = (
textModifier: number,
disabled: boolean
): ReactElement<MenuItemProps> => (
<StyledMenuItem isDisabled={disabled}>
<StyledMenuItem aria-disabled={disabled}>
Result
<span>
num<span>ber</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const autocompleteResult = (
textModifier: number,
disabled: boolean
): ReactElement<MenuItemProps> => (
<StyledMenuItem isDisabled={disabled}>
<StyledMenuItem aria-disabled={disabled}>
Result
<span>
num<span>ber</span>
Expand Down
248 changes: 154 additions & 94 deletions modules/react/menu/lib/MenuItem.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import * as React from 'react';

import {colors, iconColors, typeColors, space, type} from '@workday/canvas-kit-react/tokens';
import {createStencil} from '@workday/canvas-kit-styling';
import {brand, system} from '@workday/canvas-tokens-web';
import {checkSmallIcon} from '@workday/canvas-system-icons-web';

import {
createSubcomponent,
styled,
StyledType,
composeHooks,
createElemPropsHook,
useLocalRef,
createComponent,
} from '@workday/canvas-kit-react/common';
import {SystemIcon} from '@workday/canvas-kit-react/icon';
import {SystemIcon, SystemIconProps, systemIconStencil} from '@workday/canvas-kit-react/icon';
import {OverflowTooltip} from '@workday/canvas-kit-react/tooltip';
import {Box} from '@workday/canvas-kit-react/layout';
import {mergeStyles} from '@workday/canvas-kit-react/layout';
import {
useListItemRegister,
useListItemRovingFocus,
Expand All @@ -30,113 +32,171 @@ export interface MenuItemProps {
/**
* The label text of the MenuItem.
*/
children: React.ReactNode;
children?: React.ReactNode;
/**
* The semantic side effect of selecting a menu item. What is the intent when a user activates this
* menu item?
* - `selectable`: The menu item is intended to be an option that can be selected and retain a
* selected state. It will be shown using selected styling when the menu is open. This should
* be used for components like `Select` or `MultiSelect` or toolbars that have a few valid
* options
* - `actionable`: The menu item is intended to perform an action rather than selecting something.
* This could be a navigation dropdown menu or creating a new user. There is no selected state
* that is portrayed to the user because selection is not the intent of the item.
*/
type?: 'selectable' | 'actionable';
/**
* 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;
}

interface StyledMenuProps {
/**
* 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 StyledMenuItem = styled(Box.as('button'))<StyledType & StyledMenuProps>(
({theme}) => {
return {
...type.levels.subtext.large,
display: 'grid',
alignItems: 'center',
width: '100%',
gap: space.s,
padding: `${space.xxs} ${space.s}`,
boxSizing: 'border-box',
cursor: 'pointer',
color: colors.blackPepper300,
borderWidth: 0,
textAlign: 'left',
transition: 'background-color 80ms, color 80ms',
'&:hover, &[aria-selected=true]': {
backgroundColor: theme.canvas.palette.primary.lightest,
color: colors.blackPepper300,
'.wd-icon-fill, .wd-icon-accent, .wd-icon-accent2': {
fill: iconColors.hover,
},
'.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': {
fill: iconColors.hover,
},
},
'&:focus, &.focus': {
outline: 'none',
backgroundColor: theme.canvas.palette.primary.main,
color: typeColors.inverse,
'.wd-icon-fill, .wd-icon-accent, .wd-icon-accent2': {
fill: iconColors.inverse,
},
'*:hover .wd-icon-fill': {
fill: iconColors.inverse,
},
'.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': {
fill: iconColors.inverse,
},
export const menuItemStencil = createStencil({
base: {
...system.type.subtext.large,
display: 'flex',
alignItems: 'center',
width: '100%',
gap: system.space.x4,
padding: `${system.space.x2} ${system.space.x4}`,
boxSizing: 'border-box',
cursor: 'pointer',
color: system.color.text.default,
borderWidth: 0,
textAlign: 'left',
transition: 'background-color 80ms, color 80ms',
backgroundColor: 'inherit',
minHeight: system.space.x10,

// selected checkmark
'& :where([data-part="menu-item-selected"])': {
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',
},

// Hover styles
'&:is(.hover, :hover)': {
[systemIconStencil.vars.color]: system.color.icon.strong,
backgroundColor: brand.neutral.lightest,
},

// Focus styles
'&:is(.focus, :focus)': {
[systemIconStencil.vars.color]: brand.primary.accent,
outline: 'none',
backgroundColor: brand.primary.base,
color: systemIconStencil.vars.color,
},

// Disabled styles
'&:is(:disabled, [aria-disabled=true])': {
[systemIconStencil.vars.color]: 'color',
color: system.color.text.disabled,
cursor: 'default',

// Focus + Disabled
'&:where(.focus, :focus)': {
backgroundColor: brand.primary.light,
},
// We want the focus styles no matter what
[`[data-whatinput]`]: {
backgroundColor: 'inherit',
color: colors.blackPepper300,
'&:hover, &[aria-selected=true]': {
backgroundColor: theme.canvas.palette.primary.lightest,
'.wd-icon-fill, .wd-icon-accent, .wd-icon-accent2': {
fill: iconColors.hover,
},

'& :where([data-part="menu-item-text"])': {
flexGrow: 1,
alignSelf: 'center',
},

'& :where([data-part="menu-icon-icon"])': {
alignSelf: 'start',
},
},
modifiers: {
/**
* The semantic side effect of selecting a menu item. What is the intent when a user activates this
* menu item?
* - `selectable`: The menu item is intended to be an option that can be selected and retain a
* selected state. It will be shown using selected styling when the menu is open. This should
* be used for components like `Select` or `MultiSelect` or toolbars that have a few valid
* options
* - `actionable`: The menu item is intended to perform an action rather than selecting something.
* This could be a navigation dropdown menu or creating a new user. There is no selected state
* that is portrayed to the user because selection is not the intent of the item.
*/
type: {
selectable: {
'&[aria-selected=true]': {
[systemIconStencil.vars.color]: brand.primary.dark,
color: systemIconStencil.vars.color,
backgroundColor: brand.primary.lightest,
'& :where([data-part="menu-item-selected"])': {
opacity: system.opacity.full,
},
},
'&:focus, &.focus': {
'.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': {
fill: iconColors.hover,
'&:where(.focus, :focus)': {
[systemIconStencil.vars.color]: brand.primary.accent,
outline: 'none',
backgroundColor: brand.primary.base,
color: systemIconStencil.vars.color,
},
},
},
backgroundColor: 'inherit',
'&:disabled, &[aria-disabled=true]': {
color: colors.licorice100,
cursor: 'default',
'.wd-icon-fill, .wd-icon-accent, .wd-icon-accent2': {
fill: iconColors.disabled,
},
'&:focus, &.focus': {
backgroundColor: colors.blueberry200,
'.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': {
fill: iconColors.disabled,
},
},
'&:hover, &[aria-selected=true]': {
'.wd-icon-background ~ .wd-icon-accent, .wd-icon-background ~ .wd-icon-accent2': {
fill: iconColors.disabled,
},
'*:hover .wd-icon-fill': {
fill: iconColors.disabled,
},
actionable: {
'& [data-part="menu-item-selected"]': {
display: 'none',
},
},
};
},
},
({children}) => {
if (typeof children === 'string') {
return {};
} else {
return {
display: 'flex',
};
}
}
);
});

const MenuItemIcon = (elemProps: SystemIconProps) => {
return <SystemIcon data-part="menu-item-icon" {...elemProps} />;
};

const MenuItemText = ({children}: React.PropsWithChildren) => {
return (
<>
<span data-part="menu-item-text">{children}</span>
<SystemIcon icon={checkSmallIcon} data-part="menu-item-selected" />
</>
);
};

export const StyledMenuItem = createComponent('button')({
displayName: 'MenuItem',
Component: (
{children, type = 'actionable', isDisabled, ...elemProps}: MenuItemProps,
ref,
Element
) => {
return (
<Element
ref={ref}
aria-disabled={isDisabled}
{...mergeStyles(elemProps, menuItemStencil({type}))}
>
{typeof children === 'string' ? <MenuItemText>{children}</MenuItemText> : children}
</Element>
);
},
});

export const useMenuItem = composeHooks(
createElemPropsHook(useMenuModel)(
Expand Down Expand Up @@ -181,13 +241,13 @@ export const MenuItem = createSubcomponent('button')({
modelHook: useMenuModel,
elemPropsHook: useMenuItem,
subComponents: {
Icon: styled(SystemIcon)({alignSelf: 'start'}),
Text: styled('span')({flexGrow: 1, alignSelf: 'center'}),
Icon: MenuItemIcon,
Text: MenuItemText,
},
})<MenuItemProps>(({children, ...elemProps}, Element) => {
})<MenuItemProps>(({children, type = 'actionable', ...elemProps}, Element) => {
return (
<OverflowTooltip placement="left">
<StyledMenuItem minHeight={space.xl} as={Element} {...elemProps}>
<StyledMenuItem as={Element} {...elemProps}>
{children}
</StyledMenuItem>
</OverflowTooltip>
Expand Down
Loading