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) => ( + <div className="p-8"> + <Story /> + </div> + ), + ], +} as Meta<MenuProps>; + +export const Default: StoryObj<MenuProps> = { + args: { + children: ( + <> + <Menu.Button>Documentation Links</Menu.Button> + <Menu.Items data-testid="menu-content"> + <Menu.Item + href="https://headlessui.com/react/menu#menu-button" + icon="link" + > + Headless UI Docs + </Menu.Item> + <Menu.Item + href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/menu" + icon="link" + > + MDN: Menu + </Menu.Item> + <Menu.Item href="#index" onClick={() => console.log('Item clicked')}> + Trigger Action + </Menu.Item> + <Menu.Item disabled href="https://example.org/" icon="warning"> + Not Possible (disabled) + </Menu.Item> + </Menu.Items> + </> + ), + }, +}; + +export const WithLongButtonText: StoryObj<MenuProps> = { + args: { + children: ( + <> + <Menu.Button> + Long Trigger Button Text to Demonstrate Popover Matching + </Menu.Button> + <Menu.Items data-testid="menu-content"> + <Menu.Item + href="https://headlessui.com/react/menu#menu-button" + icon="link" + > + Headless UI Docs + </Menu.Item> + <Menu.Item + href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/menu" + icon="link" + > + MDN: Menu + </Menu.Item> + {/* eslint-disable-next-line no-alert */} + <Menu.Item onClick={() => alert('Item clicked')}> + Trigger Action + </Menu.Item> + <Menu.Item disabled href="https://example.org/" icon="warning"> + Not Possible (disabled) + </Menu.Item> + </Menu.Items> + </> + ), + }, +}; + +export const WithShortButtonText: StoryObj<MenuProps> = { + args: { + children: ( + <> + <Menu.Button>Menu</Menu.Button> + <Menu.Items data-testid="menu-content"> + <Menu.Item + href="https://headlessui.com/react/menu#menu-button" + icon="link" + > + Headless UI Docs + </Menu.Item> + <Menu.Item + href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/menu" + icon="link" + > + MDN: Menu + </Menu.Item> + {/* eslint-disable-next-line no-alert */} + <Menu.Item onClick={() => alert('Item clicked')}> + Trigger Action + </Menu.Item> + <Menu.Item disabled href="https://example.org/" icon="warning"> + Not Possible (disabled) + </Menu.Item> + </Menu.Items> + </> + ), + }, +}; + +export const WithCustomButton: StoryObj<MenuProps> = { + args: { + children: ( + <> + <Menu.PlainButton> + <div className="fpo">Menu Button</div> + </Menu.PlainButton> + <Menu.Items data-testid="menu-content"> + <Menu.Item + href="https://headlessui.com/react/menu#menu-button" + icon="link" + > + Headless UI Docs + </Menu.Item> + <Menu.Item + href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/menu" + icon="link" + > + MDN: Menu + </Menu.Item> + {/* eslint-disable-next-line no-alert */} + <Menu.Item onClick={() => alert('Item clicked')}> + Trigger Action + </Menu.Item> + <Menu.Item disabled href="https://example.org/" icon="warning"> + Not Possible (disabled) + </Menu.Item> + </Menu.Items> + </> + ), + }, +}; + +export const MenuWithAvatarButton: StoryObj<MenuProps> = { + parameters: { + badges: ['intro-1.3', 'implementationExample'], + }, + args: { + children: ( + <> + <Menu.PlainButton> + <Avatar user={{ fullName: 'Josie Sandberg' }} /> + </Menu.PlainButton> + <Menu.Items data-testid="menu-content"> + <Menu.Item + href="https://headlessui.com/react/menu#menu-button" + icon="link" + > + Headless UI Docs + </Menu.Item> + <Menu.Item + href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/menu" + icon="link" + > + MDN: Menu + </Menu.Item> + {/* eslint-disable-next-line no-alert */} + <Menu.Item onClick={() => alert('Item clicked')}> + Trigger Action + </Menu.Item> + <Menu.Item disabled href="https://example.org/" icon="warning"> + Not Possible (disabled) + </Menu.Item> + </Menu.Items> + </> + ), + }, +}; + +export const Opened: StoryObj<MenuProps> = { + ...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<MenuProps & { iconName: IconName }> = + { + argTypes: { + iconName: { + control: 'radio', + options: Object.keys(icons), + }, + }, + args: { + iconName: 'dots-vertical', + }, + parameters: { + badges: ['intro-1.2', 'implementationExample'], + }, + render: ({ iconName }) => ( + <Menu> + <Menu.PlainButton> + <Icon + name={iconName} + purpose="informative" + size="2rem" + title="show more" + /> + </Menu.PlainButton> + <Menu.Items data-testid="menu-content"> + <Menu.Item + href="https://headlessui.com/react/menu#menu-button" + icon="link" + > + Headless UI Docs + </Menu.Item> + <Menu.Item + href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/menu" + icon="link" + > + MDN: Menu + </Menu.Item> + {/* eslint-disable-next-line no-alert */} + <Menu.Item onClick={() => alert('Item clicked')}> + Trigger Action + </Menu.Item> + <Menu.Item disabled href="https://example.org/" icon="warning"> + Not Possible (disabled) + </Menu.Item> + </Menu.Items> + </Menu> + ), + }; 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<typeof HeadlessMenu> & + PopoverOptions & { + /** + * Allow custom classes to be applied to the menu container. + */ + className?: string; + }; + +export type MenuItemProps = ExtractProps<typeof HeadlessMenu.Item> & { + // 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<HTMLAnchorElement>; +}; + +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<IconName, 'expand-more'>; +}; + +export type MenuPlainButtonProps = ExtractProps<typeof HeadlessMenu.Button>; + +export type MenuItemsProps = ExtractProps<typeof HeadlessMenu.Items>; + +type MenuContextType = PopoverContext; + +const MenuContext = React.createContext<MenuContextType>({}); + +/** + * `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 ( + <MenuContext.Provider + value={{ + setReferenceElement: setReferenceElement as React.Dispatch< + React.SetStateAction<Element | null | undefined> + >, + setPopperElement: setPopperElement as React.Dispatch< + React.SetStateAction<HTMLElement | null | undefined> + >, + popperStyles: popperStyles.popper, + popperAttributes: popperAttributes.popper, + }} + > + <HeadlessMenu as="div" className={menuClassNames} {...other} /> + </MenuContext.Provider> + ); +}; + +/** + * 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 ( + <HeadlessMenu.Button as={React.Fragment} ref={setReferenceElement}> + <Button className={buttonClassNames} status="neutral" {...other}> + {children} + <Icon + className={styles['menu__button--with-chevron']} + name={icon} + purpose="decorative" + size="1.25rem" + /> + </Button> + </HeadlessMenu.Button> + ); +}; + +/** + * 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 ( + <HeadlessMenu.Button + className={buttonClassNames} + ref={setReferenceElement} + {...other} + /> + ); +}; + +/** + * 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(<HeadlessMenu.Items {...optionProps} />, 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 ( + <HeadlessMenu.Item {...other}> + {({ active, disabled }) => { + const listItemView = ( + <PopoverListItem + className={className} + icon={icon} + isDisabled={disabled} + isFocused={active} + > + {children as ReactNode} + </PopoverListItem> + ); + return disabled ? ( + listItemView + ) : ( + <a + className={clsx(styles['menu__item'])} + href={href} + onClick={onClick} + > + {listItemView} + </a> + ); + }} + </HeadlessMenu.Item> + ); +}; + +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) => <div className="p-8">{Story()}</div>], +} as Meta<Args>; + +type Args = React.ComponentProps<typeof PopoverContainer>; + +export const Default: StoryObj<Args> = { + args: { + children: ( + <> + <div role="none"> + <PopoverListItem icon="arrow-downward">test 1</PopoverListItem> + <PopoverListItem icon="arrow-narrow-left">test 2</PopoverListItem> + <PopoverListItem icon="arrow-upward">test 3</PopoverListItem> + </div> + <div role="none"> + <PopoverListItem icon="arrow-narrow-right" isDestructiveAction> + test 4 + </PopoverListItem> + </div> + </> + ), + }, +}; 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<HTMLElement | null | undefined> + >; + setReferenceElement?: React.Dispatch< + React.SetStateAction<Element | null | undefined> + >; +}; + +export const PopoverContainer = React.forwardRef<HTMLDivElement, Props>( + ({ className, children, ...other }, ref) => { + const componentClassName = clsx(styles['popover-container'], className); + + return ( + <div className={componentClassName} {...other} ref={ref}> + {children} + </div> + ); + }, +); + +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<Args>; + +type Args = React.ComponentProps<typeof PopoverListItem>; + +export const Default: StoryObj<Args> = { + args: { + children: 'Default list item', + }, +}; + +export const WithAnIcon: StoryObj<Args> = { + args: { + children: 'Test with Icon', + icon: 'add-circle', + }, +}; + +export const Disabled: StoryObj<Args> = { + args: { + children: 'Disabled', + isDisabled: true, + }, +}; + +export const Descructive: StoryObj<Args> = { + args: { + children: 'Is destructive', + icon: 'delete', + isDestructiveAction: true, + }, +}; + +export const WithSublabel: StoryObj<Args> = { + args: { + ...Default.args, + children: 'Add comment', + subLabel: 'Everyone can see comments', + }, +}; + +export const WithIconAndSubLabel: StoryObj<Args> = { + 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 ( + <div + className={componentClassName} + {...other} + {...ariaIsDisabled} + ref={ref} + > + {icon ? ( + <div className={styles['popover-list-item__icon']}> + <Icon name={icon} purpose="decorative" size="1rem" /> + </div> + ) : ( + <div className={styles['popover-list-item__no-icon']}></div> + )} + <div className={styles['popover-list-item__menu-labels']}> + <Text as="div" preset="body-md"> + {children} + </Text> + {subLabel && ( + <Text + as="div" + className={styles['popover-list-item__sub-label']} + preset="body-sm" + > + {subLabel} + </Text> + )} + </div> + </div> + ); + }, +); + +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;