From 5daf4036d64ba719c7d0eb1450635e92a2ce6db2 Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Fri, 15 Mar 2024 16:20:12 -0500 Subject: [PATCH] feat(Menu)!: introduce 2.0 component - add stories - also update constituent sub-components - set up snapshots to update --- src/components/Menu/Menu-v2.module.css | 35 +++ src/components/Menu/Menu-v2.stories.tsx | 258 ++++++++++++++++++ src/components/Menu/Menu-v2.tsx | 237 ++++++++++++++++ .../PopoverContainer-v2.module.css | 24 ++ .../PopoverContainer-v2.stories.tsx | 36 +++ .../PopoverContainer/PopoverContainer-v2.tsx | 114 ++++++++ src/components/PopoverContainer/index.ts | 1 + .../PopoverListItem-v2.module.css | 64 +++++ .../PopoverListItem-v2.stories.ts | 55 ++++ .../PopoverListItem/PopoverListItem-v2.tsx | 125 +++++++++ src/components/PopoverListItem/index.ts | 1 + src/components/Text/Text.tsx | 2 +- 12 files changed, 951 insertions(+), 1 deletion(-) create mode 100644 src/components/Menu/Menu-v2.module.css create mode 100644 src/components/Menu/Menu-v2.stories.tsx create mode 100644 src/components/Menu/Menu-v2.tsx create mode 100644 src/components/PopoverContainer/PopoverContainer-v2.module.css create mode 100644 src/components/PopoverContainer/PopoverContainer-v2.stories.tsx create mode 100644 src/components/PopoverContainer/PopoverContainer-v2.tsx create mode 100644 src/components/PopoverListItem/PopoverListItem-v2.module.css create mode 100644 src/components/PopoverListItem/PopoverListItem-v2.stories.ts create mode 100644 src/components/PopoverListItem/PopoverListItem-v2.tsx diff --git a/src/components/Menu/Menu-v2.module.css b/src/components/Menu/Menu-v2.module.css new file mode 100644 index 0000000000..8f9335e708 --- /dev/null +++ b/src/components/Menu/Menu-v2.module.css @@ -0,0 +1,35 @@ +@import '../../design-tokens/mixins.css'; + +/*------------------------------------*\ + # MENU +\*------------------------------------*/ + +/** + * Menu + */ +.menu { + position: relative; +} + +.menu__button { + font: var(--eds-theme-typography-body-md); + + color: var(--eds-theme-color-text-neutral-subtle); + background-color: var(--eds-theme-color-form-background); + border-color: var(--eds-theme-color-form-border); + font-weight: var(--eds-font-weight-light); +} + +.menu__button--with-chevron { + color: var(--eds-theme-color-icon-neutral-default); +} + +.menu__item { + text-decoration: none; + color: inherit; +} + +/* Unset the hover on the menu item, as this is handled by the PopoverListItem */ +.menu__item:hover { + color: unset; +} \ No newline at end of file diff --git a/src/components/Menu/Menu-v2.stories.tsx b/src/components/Menu/Menu-v2.stories.tsx new file mode 100644 index 0000000000..8df5a2fb09 --- /dev/null +++ b/src/components/Menu/Menu-v2.stories.tsx @@ -0,0 +1,258 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import { userEvent } from '@storybook/testing-library'; +import React from 'react'; + +import { Menu } from './Menu-v2'; +import type { MenuProps } from './Menu-v2'; +import icons from '../../icons/spritemap'; +import type { IconName } from '../../icons/spritemap'; +import { Avatar } from '../Avatar/Avatar'; + +import { Icon } from '../Icon/Icon'; +export default { + title: 'Components/Menu (v2)', + component: Menu, + parameters: { + badges: ['intro-1.2', 'current-2.0'], + layout: 'centered', + }, + argTypes: { + children: { + control: { + type: null, + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} as Meta; + +export const Default: StoryObj = { + args: { + children: ( + <> + Documentation Links + + + Headless UI Docs + + + MDN: Menu + + console.log('Item clicked')}> + Trigger Action + + + Not Possible (disabled) + + + + ), + }, +}; + +export const WithLongButtonText: StoryObj = { + args: { + children: ( + <> + + Long Trigger Button Text to Demonstrate Popover Matching + + + + Headless UI Docs + + + MDN: Menu + + {/* eslint-disable-next-line no-alert */} + alert('Item clicked')}> + Trigger Action + + + Not Possible (disabled) + + + + ), + }, +}; + +export const WithShortButtonText: StoryObj = { + args: { + children: ( + <> + Menu + + + Headless UI Docs + + + MDN: Menu + + {/* eslint-disable-next-line no-alert */} + alert('Item clicked')}> + Trigger Action + + + Not Possible (disabled) + + + + ), + }, +}; + +export const WithCustomButton: StoryObj = { + args: { + children: ( + <> + +
Menu Button
+
+ + + Headless UI Docs + + + MDN: Menu + + {/* eslint-disable-next-line no-alert */} + alert('Item clicked')}> + Trigger Action + + + Not Possible (disabled) + + + + ), + }, +}; + +export const MenuWithAvatarButton: StoryObj = { + parameters: { + badges: ['intro-1.3', 'implementationExample'], + }, + args: { + children: ( + <> + + + + + + Headless UI Docs + + + MDN: Menu + + {/* eslint-disable-next-line no-alert */} + alert('Item clicked')}> + Trigger Action + + + Not Possible (disabled) + + + + ), + }, +}; + +export const Opened: StoryObj = { + ...Default, + parameters: { + ...Default.parameters, + // Sets the delay (in milliseconds) for a specific story. + chromatic: { delay: 300 }, + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard(' ', { delay: 300 }); + }, +}; + +export const MenuWithIconButton: StoryObj = + { + argTypes: { + iconName: { + control: 'radio', + options: Object.keys(icons), + }, + }, + args: { + iconName: 'dots-vertical', + }, + parameters: { + badges: ['intro-1.2', 'implementationExample'], + }, + render: ({ iconName }) => ( + + + + + + + Headless UI Docs + + + MDN: Menu + + {/* eslint-disable-next-line no-alert */} + alert('Item clicked')}> + Trigger Action + + + Not Possible (disabled) + + + + ), + }; diff --git a/src/components/Menu/Menu-v2.tsx b/src/components/Menu/Menu-v2.tsx new file mode 100644 index 0000000000..7a5a9a1c45 --- /dev/null +++ b/src/components/Menu/Menu-v2.tsx @@ -0,0 +1,237 @@ +import { Menu as HeadlessMenu } from '@headlessui/react'; +import clsx from 'clsx'; +import type { ReactNode, MouseEventHandler } from 'react'; +import React, { useContext, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { usePopper } from 'react-popper'; + +import type { ExtractProps } from '../../util/utility-types'; + +import Button from '../Button'; +import Icon from '../Icon'; +import type { IconName } from '../Icon'; + +import { + // TODO-AH: swap back to default once finished + PopoverContainerV2 as PopoverContainer, + defaultPopoverModifiers, +} from '../PopoverContainer'; +import type { PopoverContext, PopoverOptions } from '../PopoverContainer'; + +// TODO-AH: swap back to default once finished +import { PopoverListItemV2 as PopoverListItem } from '../PopoverListItem'; +import styles from './Menu-v2.module.css'; + +// Note: added className here to prevent private interface collision within HeadlessUI +export type MenuProps = ExtractProps & + PopoverOptions & { + /** + * Allow custom classes to be applied to the menu container. + */ + className?: string; + }; + +export type MenuItemProps = ExtractProps & { + // Component API + /** + * Allow custom classes to be applied to the menu container. + */ + className?: string; + /** + * Target URL for the menu item action + */ + href?: string; + /** + * Icons are able to appear next to each Option in the Options list if it is relevant; before using any icons, please refer to the appropriate icon usage guidelines + */ + icon?: IconName; + /** + * Configurable action for the menu item upon click + */ + onClick?: MouseEventHandler; +}; + +export type MenuButtonProps = { + // Component API + /** + * The button contents placed left of the chevron icon. + */ + children: ReactNode; + /** + * Allow custom classes to be applied to the menu button. + */ + className?: string; + /** + * Icon override for component. Default is 'expand-more' + */ + icon?: Extract; +}; + +export type MenuPlainButtonProps = ExtractProps; + +export type MenuItemsProps = ExtractProps; + +type MenuContextType = PopoverContext; + +const MenuContext = React.createContext({}); + +/** + * `import {Menu} from "@chanzuckerberg/eds";` + * + * A dropdown that reveals or hides a list of actions. + */ +export const Menu = ({ + className, + placement = 'bottom-start', + modifiers = defaultPopoverModifiers, + strategy, + onFirstUpdate, + ...other +}: MenuProps) => { + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const { styles: popperStyles, attributes: popperAttributes } = usePopper( + referenceElement, + popperElement, + { placement, modifiers, strategy, onFirstUpdate }, + ); + const menuClassNames = clsx(className, styles['menu']); + return ( + + >, + setPopperElement: setPopperElement as React.Dispatch< + React.SetStateAction + >, + popperStyles: popperStyles.popper, + popperAttributes: popperAttributes.popper, + }} + > + + + ); +}; + +/** + * A styled button that when clicked, shows or hides the Options. + */ +const MenuButton = ({ + children, + className, + icon = 'expand-more', + ...other +}: MenuButtonProps) => { + const buttonClassNames = clsx(styles['menu__button'], className); + const { setReferenceElement } = useContext(MenuContext); + + return ( + + + + ); +}; + +/** + * A minimally styled button that when clicked, shows or hides the Options. + */ +const MenuPlainButton = ({ className, ...other }: MenuPlainButtonProps) => { + const buttonClassNames = clsx(className); + const { setReferenceElement } = useContext(MenuContext); + + return ( + + ); +}; + +/** + * A list of actions that are revealed in the menu + * + * @param props Props used on the set of menu items + * @see https://headlessui.com/react/menu#menu-items + */ +const MenuItems = (props: MenuItemsProps) => { + const { setPopperElement, popperStyles, popperAttributes } = + useContext(MenuContext); + const optionProps = { + as: PopoverContainer, + ref: setPopperElement, + style: popperStyles, + ...props, + ...popperAttributes, + }; + if (typeof document !== 'undefined') { + return ( + <> + {createPortal(, document.body)} + + ); + } + return null; +}; + +/** + * An individual option that represent an action in the menu + * TODO-AH: handling for links that should navigate and support frameworks + */ +const MenuItem = ({ + children, + className, + href, + icon, + onClick, + ...other +}: MenuItemProps) => { + return ( + + {({ active, disabled }) => { + const listItemView = ( + + {children as ReactNode} + + ); + return disabled ? ( + listItemView + ) : ( + + {listItemView} + + ); + }} + + ); +}; + +Menu.displayName = 'Menu'; +MenuButton.displayName = 'Menu.Button'; +MenuPlainButton.displayName = 'Menu.PlainButton'; +MenuItems.displayName = 'Menu.Items'; +MenuItem.displayName = 'Menu.Item'; + +Menu.Button = MenuButton; +Menu.PlainButton = MenuPlainButton; +Menu.Items = MenuItems; +Menu.Item = MenuItem; diff --git a/src/components/PopoverContainer/PopoverContainer-v2.module.css b/src/components/PopoverContainer/PopoverContainer-v2.module.css new file mode 100644 index 0000000000..3fbb3fba45 --- /dev/null +++ b/src/components/PopoverContainer/PopoverContainer-v2.module.css @@ -0,0 +1,24 @@ +@import '../../design-tokens/mixins.css'; + +/*------------------------------------*\ + # POPOVER CONTAINER +\*------------------------------------*/ + +/** + * Popover container + */ +.popover-container { + border-radius: var(--eds-theme-border-radius-objects-md); + overflow: auto; + padding: 0.25rem 0; + z-index: 1150; + + /* TODO-AH: Dial in box shadows from design */ + box-shadow: var(--eds-box-shadow-md); + background-color: var(--eds-theme-color-background-utility-container); +} + +.popover-container > *[role=none] + *[role=none] { + /* create dividers by looking for groups under the component that wrap using the "none" role */ + border-top: 1px solid var(--eds-theme-color-border-utility-neutral-low-emphasis); +} \ No newline at end of file diff --git a/src/components/PopoverContainer/PopoverContainer-v2.stories.tsx b/src/components/PopoverContainer/PopoverContainer-v2.stories.tsx new file mode 100644 index 0000000000..a457a8c774 --- /dev/null +++ b/src/components/PopoverContainer/PopoverContainer-v2.stories.tsx @@ -0,0 +1,36 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import React from 'react'; + +// TODO-AH: map back to non-V2 names once ready for release +import { PopoverContainer } from './PopoverContainer-v2'; +import { PopoverListItemV2 as PopoverListItem } from '../PopoverListItem'; + +export default { + title: 'Components/PopoverContainer (v2)', + component: PopoverContainer, + parameters: { + badges: ['intro-1.2', 'curent-2.0'], + }, + decorators: [(Story) =>
{Story()}
], +} as Meta; + +type Args = React.ComponentProps; + +export const Default: StoryObj = { + args: { + children: ( + <> +
+ test 1 + test 2 + test 3 +
+
+ + test 4 + +
+ + ), + }, +}; diff --git a/src/components/PopoverContainer/PopoverContainer-v2.tsx b/src/components/PopoverContainer/PopoverContainer-v2.tsx new file mode 100644 index 0000000000..01961d4022 --- /dev/null +++ b/src/components/PopoverContainer/PopoverContainer-v2.tsx @@ -0,0 +1,114 @@ +import type { Options } from '@popperjs/core'; +import clsx from 'clsx'; +import React from 'react'; +import type { ReactNode } from 'react'; +import styles from './PopoverContainer-v2.module.css'; + +export interface Props { + /** + * CSS class names that can be appended to the component. + */ + className?: string; + children: ReactNode; +} + +// Default modifiers for any popover container using PopperJS +export const defaultPopoverModifiers: Options['modifiers'] = [ + { + name: 'offset', + options: { + offset: [0, 10], // spaces the popover from the trigger element + }, + }, + { + name: 'preventOverflow', + options: { + mainAxis: false, // prevents popover from offsetting to prevent overflow. Turned off due to resulting misalignment of popover arrow. + }, + }, + { + name: 'computeStyles', + options: { + roundOffsets: false, // This is to prevent off-by-one rendering glitches, but may add some sub-pixel fuzziness + }, + }, + { + name: 'minWidth', + enabled: true, + phase: 'beforeWrite', + requires: ['computeStyles'], + fn: ({ state }) => { + state.styles.popper.minWidth = `${state.rects.reference.width}px`; + }, + effect: ({ state }) => { + state.elements.popper.style.minWidth = `${ + state.elements.reference.getBoundingClientRect().width + }px`; + }, + }, +]; + +export type PopoverOptions = { + // TODO: switch to PopperJS's full placement type + /** + * Popover placement options relative to the trigger element. + */ + placement?: + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'right-start' + | 'right-end' + | 'left-start' + | 'left-end' + | 'top' + | 'bottom' + | 'left' + | 'right'; + + /** + * Object to customize how your popover will behave. + * + * For a full list of what is available, refer to https://popper.js.org/docs/v2/modifiers/. + */ + modifiers?: Options['modifiers']; + /** + * Describes the positioning strategy to use. By default, it is 'absolute', which in the simplest cases does not require repositioning of the popper. + * If your trigger element is in a fixed container, use the fixed strategy. + */ + strategy?: Options['strategy']; + /** + * Callback ran after Popper positions the element the first time. + * + * Refer to https://popper.js.org/docs/v2/lifecycle/#hook-into-the-lifecycle. + */ + onFirstUpdate?: Options['onFirstUpdate']; +}; + +export type PopoverContext = { + placement?: PopoverOptions['placement']; + popperStyles?: React.CSSProperties; + popperAttributes?: { [key: string]: string }; + popperElement?: Element; + setPopperElement?: React.Dispatch< + React.SetStateAction + >; + setReferenceElement?: React.Dispatch< + React.SetStateAction + >; +}; + +export const PopoverContainer = React.forwardRef( + ({ className, children, ...other }, ref) => { + const componentClassName = clsx(styles['popover-container'], className); + + return ( +
+ {children} +
+ ); + }, +); + +PopoverContainer.displayName = 'PopoverContainer'; diff --git a/src/components/PopoverContainer/index.ts b/src/components/PopoverContainer/index.ts index 693402e3e8..eccb159fc5 100644 --- a/src/components/PopoverContainer/index.ts +++ b/src/components/PopoverContainer/index.ts @@ -2,4 +2,5 @@ export { PopoverContainer as default, defaultPopoverModifiers, } from './PopoverContainer'; +export { PopoverContainer as PopoverContainerV2 } from './PopoverContainer-v2'; export type { PopoverOptions, PopoverContext } from './PopoverContainer'; diff --git a/src/components/PopoverListItem/PopoverListItem-v2.module.css b/src/components/PopoverListItem/PopoverListItem-v2.module.css new file mode 100644 index 0000000000..e6ff079d0f --- /dev/null +++ b/src/components/PopoverListItem/PopoverListItem-v2.module.css @@ -0,0 +1,64 @@ +/*------------------------------------*\ + # POPOVER LIST ITEM +\*------------------------------------*/ + +/** + * PopoverListItem + */ +.popover-list-item { + display: flex; + padding: 0.25rem 0.75rem; + cursor: pointer; + width: 100%; + text-align: left; + + color: var(--eds-theme-color-text-utility-neutral-primary); + background-color: var(--eds-theme-color-background-utility-neutral-no-emphasis); + + &:hover { + background-color: var(--eds-theme-color-background-utility-neutral-no-emphasis-hover); + } + + &:active { + background-color: var(--eds-theme-color-background-utility-neutral-no-emphasis-active); + } +} + +.popover-list-item--focused, +.popover-list-item:focus-visible { + box-shadow: 0 0 0 2px var(--eds-theme-color-border-utility-focus); +} + +.popover-list-item--disabled { + pointer-events: none; + + color: var(--eds-theme-color-text-utility-disabled-secondary, red); + background-color: var(--eds-theme-color-background-utility-disabled-no-emphasis, red) +} + +.popover-list-item__icon { + padding-right: 1rem; +} + +.popover-list-item__no-icon { + /* right padding applies space for the icon itself and the padding for that icon container */ + padding-right: 2rem; +} + +.popover-list-item__sub-label { + color: var(--eds-theme-color-text-utility-neutral-secondary); +} + +.popover-list-item--destructive-action { + color: var(--eds-theme-color-text-utility-critical); + + &:hover { + color: var(--eds-theme-color-text-utility-critical-hover); + background-color: var(--eds-theme-color-background-utility-critical-no-emphasis-hover); + } + + &:active { + color: var(--eds-theme-color-text-utility-critical-active); + background-color: var(--eds-theme-color-background-utility-critical-no-emphasis-active); + } +} \ No newline at end of file diff --git a/src/components/PopoverListItem/PopoverListItem-v2.stories.ts b/src/components/PopoverListItem/PopoverListItem-v2.stories.ts new file mode 100644 index 0000000000..5cc346437c --- /dev/null +++ b/src/components/PopoverListItem/PopoverListItem-v2.stories.ts @@ -0,0 +1,55 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import { PopoverListItem } from './PopoverListItem-v2'; + +export default { + title: 'Components/PopoverListItem (v2)', + component: PopoverListItem, + parameters: { + badges: ['intro-1.2', 'current-2.0'], + }, +} as Meta; + +type Args = React.ComponentProps; + +export const Default: StoryObj = { + args: { + children: 'Default list item', + }, +}; + +export const WithAnIcon: StoryObj = { + args: { + children: 'Test with Icon', + icon: 'add-circle', + }, +}; + +export const Disabled: StoryObj = { + args: { + children: 'Disabled', + isDisabled: true, + }, +}; + +export const Descructive: StoryObj = { + args: { + children: 'Is destructive', + icon: 'delete', + isDestructiveAction: true, + }, +}; + +export const WithSublabel: StoryObj = { + args: { + ...Default.args, + children: 'Add comment', + subLabel: 'Everyone can see comments', + }, +}; + +export const WithIconAndSubLabel: StoryObj = { + args: { + ...WithSublabel.args, + icon: 'feedback', + }, +}; diff --git a/src/components/PopoverListItem/PopoverListItem-v2.tsx b/src/components/PopoverListItem/PopoverListItem-v2.tsx new file mode 100644 index 0000000000..158f8e2794 --- /dev/null +++ b/src/components/PopoverListItem/PopoverListItem-v2.tsx @@ -0,0 +1,125 @@ +import clsx from 'clsx'; +import React, { type ReactNode } from 'react'; + +import type { IconName } from '../Icon'; +import Icon from '../Icon'; +import Text from '../Text'; +import styles from './PopoverListItem-v2.module.css'; + +/** + * TODO-AH: for Action Menu Row + * + * - This is called `PopoverListItem` in code, and is currently shared between `Menu` and `Select` + * - in figma, mark isFocused as an interactive state + * - hasSubLabel => subLabel (presence marks existence for cases like this in code, also with icon) + * + */ +export interface PopoverListItemProps { + /** + * Child node(s) that can be nested inside component + */ + children: ReactNode; + /** + * CSS class names that can be appended to the component. + */ + className?: string; + // Design API + /** + * Icon from the set of defined EDS icon set + */ + icon?: IconName; + /** + * Handling behavior for whether the marked menu item is destructive or not (deletes, removes, etc.) + */ + isDestructiveAction?: boolean; + /** + * Whether the component is in focus (programmatically or otherwise) + */ + isFocused?: boolean; + /** + * Whether the list item is treated as disabled + */ + isDisabled?: boolean; + /** + * Text below the main menu item call-to-action, briefly describing the menu item's function + */ + subLabel?: string; +} + +/** + * `import {PopoverListItem} from "@chanzuckerberg/eds";` + * + * This abstracts the structure of an item in a popover, when the popover contains a + * list of items (e.g., Menus and Selects) + * - Contains styles for when active/disabled or not + * - contains styles for when there is an icon on the left + * + * Given headless implements listbox options as a renderProp, this can work for both + * Listbox and Menu examples, in the latter case not specifying an icon + */ +export const PopoverListItem = React.forwardRef< + HTMLDivElement, + PopoverListItemProps +>( + ( + { + className, + isDestructiveAction = false, + isDisabled = false, + isFocused = false, + children, + icon, + subLabel, + ...other + }, + ref, + ) => { + const componentClassName = clsx( + styles['popover-list-item'], + isDisabled && styles['popover-list-item--disabled'], + isDestructiveAction && styles['popover-list-item--destructive-action'], + // TODO-AH: should focus mimic the active state like before + isFocused && styles['popover-list-item--focused'], + className, + ); + + const ariaIsDisabled = isDisabled + ? { + 'aria-disabled': true, + } + : {}; + + return ( +
+ {icon ? ( +
+ +
+ ) : ( +
+ )} +
+ + {children} + + {subLabel && ( + + {subLabel} + + )} +
+
+ ); + }, +); + +PopoverListItem.displayName = 'PopoverListItem'; diff --git a/src/components/PopoverListItem/index.ts b/src/components/PopoverListItem/index.ts index a226d45313..1d38710dda 100644 --- a/src/components/PopoverListItem/index.ts +++ b/src/components/PopoverListItem/index.ts @@ -1 +1,2 @@ export { PopoverListItem as default } from './PopoverListItem'; +export { PopoverListItem as PopoverListItemV2 } from './PopoverListItem-v2'; diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx index 1dedc2b8bb..5a5aed9ca4 100644 --- a/src/components/Text/Text.tsx +++ b/src/components/Text/Text.tsx @@ -12,7 +12,7 @@ export type TextProps = { * * **Default is `"p"`**. */ - as?: 'p' | 'span'; + as?: 'p' | 'span' | 'div'; children: ReactNode; className?: string; tabIndex?: number;