From 9d398cc0810b591dee4a1ab0e58017b73bff19ad Mon Sep 17 00:00:00 2001
From: Andrew Holloway <booc0mtaco@users.noreply.github.com>
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           |  43 +++
 .../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, 958 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) => (
+      <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..2e7135027c
--- /dev/null
+++ b/src/components/PopoverContainer/PopoverContainer-v2.stories.tsx
@@ -0,0 +1,43 @@
+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> = {
+  argTypes: {
+    children: {
+      control: {
+        type: null,
+      },
+    },
+  },
+  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;