From 1164b90d6a0a49b8aa26bc24e08e600daf7131d8 Mon Sep 17 00:00:00 2001
From: Andrew Holloway <booc0mtaco@users.noreply.github.com>
Date: Fri, 22 Mar 2024 16:32:06 -0500
Subject: [PATCH] feat(Select)!: introduce 2.0 component (#1899)

- add new 2.0 component
- add new stories
- add new api options
- align styling for popover sub-components
---
 .../InputField/InputField-v2.module.css       |   3 +-
 src/components/InputLabel/InputLabel-v2.tsx   |  41 +-
 .../PopoverListItem-v2.module.css             |   4 +-
 src/components/Select/Select-v2.module.css    | 155 ++++
 src/components/Select/Select-v2.stories.tsx   | 769 ++++++++++++++++++
 src/components/Select/Select-v2.tsx           | 559 +++++++++++++
 src/components/Select/index.ts                |   1 +
 7 files changed, 1507 insertions(+), 25 deletions(-)
 create mode 100644 src/components/Select/Select-v2.module.css
 create mode 100644 src/components/Select/Select-v2.stories.tsx
 create mode 100644 src/components/Select/Select-v2.tsx

diff --git a/src/components/InputField/InputField-v2.module.css b/src/components/InputField/InputField-v2.module.css
index 59eed6b87..dca417336 100644
--- a/src/components/InputField/InputField-v2.module.css
+++ b/src/components/InputField/InputField-v2.module.css
@@ -3,7 +3,8 @@
 \*------------------------------------*/
 
 /**
- * Wraps the Label and the optional/required indicator.
+ * Wraps the Label and the optional/required hint.
+ * TODO-AH: map the overline styles between Select and InputField
  */
 .input-field__overline {
   display: flex;
diff --git a/src/components/InputLabel/InputLabel-v2.tsx b/src/components/InputLabel/InputLabel-v2.tsx
index 9485dc152..b168abf61 100644
--- a/src/components/InputLabel/InputLabel-v2.tsx
+++ b/src/components/InputLabel/InputLabel-v2.tsx
@@ -1,10 +1,10 @@
 import clsx from 'clsx';
-import React from 'react';
-import type { ReactNode } from 'react';
+import React, { type ReactNode } from 'react';
 import type { Size } from '../../util/variant-types';
 import styles from './InputLabel-v2.module.css';
 
 export type InputLabelProps = {
+  // Component API
   /**
    * Text to render in label.
    */
@@ -17,6 +17,7 @@ export type InputLabelProps = {
    * ID of input that label is associated with.
    */
   htmlFor: string;
+  // Design API
   /**
    * Size of the label.
    *
@@ -34,25 +35,21 @@ export type InputLabelProps = {
  *
  * Label associated with an input element such as a radio or checkbox.
  */
-export const InputLabel = ({
-  children,
-  className,
-  htmlFor,
-  size = 'lg',
-  disabled,
-}: InputLabelProps) => {
-  const componentClassName = clsx(
-    styles['label'],
-    size === 'md' && styles['label--md'],
-    size === 'lg' && styles['label--lg'],
-    disabled && styles['label--disabled'],
-    className,
-  );
-  return (
-    <label className={componentClassName} htmlFor={htmlFor}>
-      {children}
-    </label>
-  );
-};
+export const InputLabel = React.forwardRef<HTMLLabelElement, InputLabelProps>(
+  ({ children, className, htmlFor, size = 'lg', disabled }, ref) => {
+    const componentClassName = clsx(
+      styles['label'],
+      size === 'md' && styles['label--md'],
+      size === 'lg' && styles['label--lg'],
+      disabled && styles['label--disabled'],
+      className,
+    );
+    return (
+      <label className={componentClassName} htmlFor={htmlFor} ref={ref}>
+        {children}
+      </label>
+    );
+  },
+);
 
 InputLabel.displayName = 'InputLabel';
diff --git a/src/components/PopoverListItem/PopoverListItem-v2.module.css b/src/components/PopoverListItem/PopoverListItem-v2.module.css
index 2c9ce143a..e41fafc6e 100644
--- a/src/components/PopoverListItem/PopoverListItem-v2.module.css
+++ b/src/components/PopoverListItem/PopoverListItem-v2.module.css
@@ -37,12 +37,12 @@
 }
 
 .popover-list-item__icon {
-  padding-right: 1rem;
+  padding-right: 0.5rem;
 }
 
 .popover-list-item__no-icon {
   /* right padding applies space for the icon itself and the padding for that icon container */
-  padding-right: 2rem;
+  padding-right: 1.5rem;
 }
 
 .popover-list-item__sub-label {
diff --git a/src/components/Select/Select-v2.module.css b/src/components/Select/Select-v2.module.css
new file mode 100644
index 000000000..29b1b3b20
--- /dev/null
+++ b/src/components/Select/Select-v2.module.css
@@ -0,0 +1,155 @@
+@import '../../design-tokens/mixins.css';
+
+/*------------------------------------*\
+    # SELECT
+\*------------------------------------*/
+
+/**
+ * Select field used to select one option from a list of options.
+ */
+.select {
+  position: relative;
+}
+
+/**
+ * Wraps the Label and the optional/required hint.
+ * TODO-AH: map the overline styles between Select and InputField
+ */
+.select__overline {
+  display: flex;
+  margin-bottom: 0.25rem;
+  gap: 0.25rem;
+}
+
+.select__overline--no-label {
+  justify-content: flex-start;
+}
+
+/**
+ * The container for the individual select options. 
+ */
+.select__options {
+  max-height: 25vh;
+  z-index: 100;
+}
+
+/**
+ * The button to trigger the display of the select field.
+ */
+.select-button {
+  font: var(--eds-theme-typography-form-input);
+
+  width: 100%;
+  padding: 0.5rem;
+
+  border: var(--eds-border-width-sm) solid;
+  border-radius: calc(var(--eds-theme-border-radius-objects-sm) * 1px);
+
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 0.5rem;
+
+  cursor: pointer;
+
+  /* TODO-AH: handle placeholder color when no value is selected */
+  color: var(--eds-theme-color-text-utility-default-primary);
+  background-color: var(--eds-theme-color-form-background);
+}
+
+/**
+ * The caret icon to decorate the select trigger button, animated to rotate.
+ */
+.select-button__icon {
+  flex-shrink: 0;
+  transform: rotate(0);
+
+  transition: transform var(--eds-anim-move-medium) ease-out;
+
+  @media (prefers-reduced-motion) {
+    transition: none;
+  }
+}
+
+.select-button__text--truncated {
+  /* TODO-AH: use as mixin */
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+
+.select-button__icon--reversed {
+  transform: rotate(180deg);
+}
+
+.select__footer {
+  display: flex;
+  justify-content: space-between;
+}
+
+.select--has-fieldNote {
+  margin-bottom: 0.25rem;
+}
+
+/**
+ * Label on top of the select trigger button to label the select field.
+ */
+.select__label {
+  font: var(--eds-theme-typography-form-label);
+  color: var(--eds-theme-color-text-utility-default-primary);
+}
+
+.select__label--disabled {
+  color: var(--eds-theme-color-text-utility-disabled-primary);
+}
+
+.select__option {
+  color: var(--eds-theme-color-text-utility-interactive-secondary);
+}
+
+.select__option-text {
+  color: var(--eds-theme-color-text-utility-default-primary);
+}
+
+.select__required-text {
+  color: var(--eds-theme-color-text-utility-default-secondary);
+}
+
+.select-button:disabled {
+  cursor: not-allowed;
+
+  color: var(--eds-theme-color-text-utility-disabled-primary);
+  border-color: var(--eds-theme-color-border-utility-disabled);
+  background-color: var(--eds-theme-color-background-utility-disabled-low-emphasis);
+}
+
+.select-button:focus-visible {
+  border-color: var(--eds-theme-color-border-utility-focus);
+  outline: var(--eds-border-width-sm) solid var(--eds-theme-color-border-utility-focus);
+}
+
+.select-button--error {
+  border-color: var(--eds-theme-color-border-utility-critical);
+
+  &:hover {
+    border-color: var(--eds-theme-color-border-utility-critical-hover);
+  }
+
+  &:focus-visible {
+    border-color: var(--eds-theme-color-border-utility-critical);
+    outline: var(--eds-border-width-sm) solid var(--eds-theme-color-border-utility-critical);
+  }
+}
+
+.select-button--warning {
+  border-color: var(--eds-theme-color-border-utility-warning);
+
+  &:hover {
+    border-color: var(--eds-theme-color-border-utility-warning-hover);
+  }
+
+  &:focus-visible {
+    border-color: var(--eds-theme-color-border-utility-warning);
+    outline: var(--eds-border-width-sm) solid var(--eds-theme-color-border-utility-warning);
+  }
+}
\ No newline at end of file
diff --git a/src/components/Select/Select-v2.stories.tsx b/src/components/Select/Select-v2.stories.tsx
new file mode 100644
index 000000000..5efa2feb0
--- /dev/null
+++ b/src/components/Select/Select-v2.stories.tsx
@@ -0,0 +1,769 @@
+import type { StoryObj, Meta } from '@storybook/react';
+import { expect } from '@storybook/test';
+import { userEvent, within } from '@storybook/testing-library';
+import React from 'react';
+import { Select } from './Select-v2';
+import Icon from '../Icon';
+
+const meta: Meta<typeof Select> = {
+  title: 'Components/V2/Select',
+  component: Select,
+  parameters: {
+    badges: ['intro-1.2', 'current-2.0'],
+    layout: 'centered',
+  },
+  argTypes: {
+    multiple: {
+      description: 'Whether multiple values are allowed in this instance',
+    },
+    children: {
+      control: {
+        type: null,
+      },
+    },
+    value: {
+      table: {
+        description: 'The value of the select field (when controlled)',
+      },
+    },
+    defaultValue: {
+      description: 'The default value of the select field (when uncontrolled)',
+    },
+    onClick: {
+      description:
+        'Optional click handler. Fires after `onChange`, when a value in the dropdown popover is picked',
+      table: {
+        type: {
+          summary: 'SyntheticEvent',
+          detail:
+            'See: https://react.dev/reference/react-dom/components/common#react-event-object',
+        },
+        default: 'void',
+      },
+    },
+    onChange: {
+      description:
+        'Optional change handler. Fires when a value is selected (and passes in list of selected values)',
+    },
+  },
+};
+
+export default meta;
+
+type SelectOption = {
+  key: string;
+  label: string;
+};
+
+const exampleOptions: SelectOption[] = [
+  {
+    key: '1',
+    label: 'Dogs',
+  },
+  {
+    key: '2',
+    label: 'Cats',
+  },
+  {
+    key: '3',
+    label: 'Birds',
+  },
+];
+
+/**
+ * Play function to open a menu item
+ */
+const openMenu: StoryObj['play'] = async (playOptions) => {
+  const { canvasElement } = playOptions;
+  const canvas = within(canvasElement);
+
+  // Open the dropdown.
+  const selectButton = await canvas.findByRole('button');
+  await userEvent.click(selectButton);
+};
+
+/**
+ * Play function to use with interactive stories
+ */
+const selectCat: StoryObj['play'] = async (playOptions) => {
+  const { canvasElement } = playOptions;
+  const canvas = within(canvasElement);
+  const selectButton = await canvas.findByRole('button');
+
+  await openMenu(playOptions);
+
+  // Target the body of the iframe since we now use PopperJS
+  const popoverCanvas = within(document.body);
+
+  const bestOption = await popoverCanvas.findByText('Cats');
+  await userEvent.click(bestOption);
+
+  // Reopen the dropdown; selecting an option closed it.
+  await userEvent.click(selectButton);
+};
+
+/**
+ * The simplest and default case, using the options, button, and button wrapper.
+ * This shows how to reflect the value in the button upon selection, and how to generate
+ * a set of options from a list.
+ *
+ * **NOTE**: for select value data types, `{label: string}` is required, but any other key/value pairs are allowed.
+ */
+export const Default: StoryObj = {
+  args: {
+    label: 'Favorite Animal',
+    'data-testid': 'dropdown',
+    defaultValue: exampleOptions[0],
+    name: 'select',
+    children: (
+      <>
+        <Select.Button>
+          {({ value, open }) => (
+            <Select.ButtonWrapper isOpen={open}>
+              {value.label}
+            </Select.ButtonWrapper>
+          )}
+        </Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+  },
+  parameters: {
+    docs: {
+      source: {
+        code: `
+<Select>
+  <Select.Button>
+    {({ value, open, disabled }) => (
+      <Select.ButtonWrapper
+        isOpen={open}
+      >
+        {value.label}
+      </Select.ButtonWrapper>
+    )}
+  </Select.Button>
+  <Select.Options>
+    {exampleOptions.map((option) => (
+      <Select.Option key={option.key} value={option}>
+        {option.label}
+      </Select.Option>
+    ))}
+  </Select.Options>
+</Select>`,
+      },
+    },
+  },
+};
+
+/**
+ * Instead of a render prop for `Select.Button`, you can forego the render prop for the button and use static text instead.
+ * This mode is also useful if you want to use a controlled component and manage state yourself.
+ */
+export const WithStandardButton: StoryObj = {
+  args: {
+    label: 'Favorite Animal',
+    'data-testid': 'dropdown',
+    defaultValue: exampleOptions[0],
+    name: 'standard-button',
+    children: (
+      <>
+        <Select.Button>- Select Option -</Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+  },
+};
+
+/**
+ * `Select` allows for event handlers to be added to the component.
+ *
+ * * `onChange` fires when a value is selected (with value of type `SelectOption`)
+ *
+ * You can also add an `onClick` handler to `.ButtonWrapper` if using a render prop
+ *
+ * * `onClick` fires when the trigger (`.ButtonWrapper`) is clicked
+ */
+export const EventHandlingOnRenderProp: StoryObj = {
+  args: {
+    ...Default.args,
+    onChange: (args: SelectOption) => console.log('changed to', args),
+    children: (
+      <>
+        <Select.Button>
+          {({ value, open }) => (
+            <Select.ButtonWrapper
+              isOpen={open}
+              onClick={(args) => console.log('custom click')}
+            >
+              {value.label}
+            </Select.ButtonWrapper>
+          )}
+        </Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+  },
+  parameters: {
+    docs: {
+      source: {
+        code: `
+<Select onChange={...}>
+  <Select.Button>
+    {({ value, open, disabled }) => (
+      <Select.ButtonWrapper
+        isOpen={open}
+        onClick={...}
+        >
+        {value.label}
+      </Select.ButtonWrapper>
+    )}
+  </Select.Button>
+  <Select.Options>
+    {exampleOptions.map((option) => (
+      <Select.Option key={option.key} value={option}>
+        {option.label}
+      </Select.Option>
+    ))}
+  </Select.Options>
+</Select>`,
+      },
+    },
+  },
+};
+
+/**
+ * `Select` allows for event handlers to be added to the component.
+ *
+ * * `onChange` fires when a value is selected (with value of type `SelectOption`)
+ *
+ * If not using a render prop, you can also add an `onClick` handler to `Select.Button` directly
+ *
+ * * `onClick` fires when the trigger (`.ButtonWrapper`) is clicked
+ *
+ * **NOTE**: `onClick` has no function when using a render prop
+ */
+export const EventHandlingOnStandardButton: StoryObj = {
+  args: {
+    ...Default.args,
+    children: (
+      <>
+        <Select.Button
+          onClick={(ev: MouseEvent) => console.log('external click')}
+        >
+          - Select Option -
+        </Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+    onChange: (args: SelectOption) => console.log('external change', args),
+  },
+};
+
+/**
+ * You can select a different option to show when rendered.
+ */
+export const WithSelectedOption: StoryObj<typeof Select> = {
+  args: {
+    ...Default.args,
+    'aria-label': 'Favorite Animal',
+    defaultValue: exampleOptions[1],
+  },
+};
+
+/**
+ * You can add a `name` prop to generate form fields for the value object.
+ *
+ * In this example, the field name is `"interactive-select"`, and the value is an object storing `{label: string, key: string}`.
+ *
+ * This will generate hidden fields with names:
+ * * `interactive-select[label]`
+ * * `interactive-select[key]`
+ *
+ */
+export const WithFieldName: StoryObj = {
+  args: {
+    ...Default.args,
+    children: (
+      <>
+        <Select.Button>
+          {({ value, open, disabled }) => (
+            <Select.ButtonWrapper isOpen={open}>
+              {value.label}
+            </Select.ButtonWrapper>
+          )}
+        </Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+  },
+};
+
+export const WithFieldNote: StoryObj = {
+  args: {
+    ...Default.args,
+    fieldNote: 'Choose your beast',
+    children: (
+      <>
+        <Select.Button>
+          {({ value, open, disabled }) => (
+            <Select.ButtonWrapper isOpen={open}>
+              {value.label}
+            </Select.ButtonWrapper>
+          )}
+        </Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+  },
+};
+
+/**
+ * You can implement a `Select.Button` with a render prop. This exposes several useful values to
+ * control the appearance of the rendered button. The render prop case is "Headless", in that it has
+ * no styling by default.
+ */
+export const UncontrolledHeadless: StoryObj = {
+  args: {
+    'aria-label': 'some label',
+    'data-testid': 'dropdown',
+    defaultValue: exampleOptions[0],
+    name: 'select',
+    children: (
+      <>
+        <Select.Button>
+          {({ value, open, disabled }) => (
+            <button className="fpo">{value.label}</button>
+          )}
+        </Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+  },
+};
+
+/**
+ * You can use `Select.ButtonWrapper` to borrow the existing style used for controlled `Select` components.
+ */
+export const StyledUncontrolled: StoryObj = {
+  args: {
+    'aria-label': 'some label',
+    'data-testid': 'dropdown',
+    defaultValue: exampleOptions[0],
+    name: 'select',
+    children: (
+      <>
+        <Select.Button>
+          {({ value, open, disabled }) => (
+            <Select.ButtonWrapper isOpen={open}>
+              {value.label}
+            </Select.ButtonWrapper>
+          )}
+        </Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+  },
+};
+
+/**
+ * You can select multiple values by passing `multiple` to the parent element. When doing this,
+ * make sure all props that use the value (e.g., `value` and `defaultValue`) should use an array instead
+ * of an object or value for the individual `Select.Option` entries.
+ *
+ * When handling the button text, `value` represents the data for all options selected. This allows for a flexible
+ * layout to fit the needs of the design.
+ *
+ * Hidden form inputs are generated for each option selected and take the following form:
+ * - `name[arrayIndex][key]`
+ * - `name[arrayIndex][value]`
+ */
+export const Multiple: StoryObj = {
+  args: {
+    ...Default.args,
+    label: 'Favorite Animal(s)',
+    multiple: true,
+    'data-testid': 'select-field',
+    defaultValue: [exampleOptions[0]],
+    className: 'w-60',
+    name: 'standard-button',
+    children: (
+      <>
+        <Select.Button>
+          {({ value, open, disabled }) => (
+            <Select.ButtonWrapper isOpen={open}>
+              {value.length > 0 ? value.length : 'none'} selected
+            </Select.ButtonWrapper>
+          )}
+        </Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+  },
+  parameters: {
+    docs: {
+      source: {
+        code: `
+<Select>
+  <Select.Button>
+    {({ value, open, disabled }) => (
+      <Select.ButtonWrapper
+        isOpen={open}
+        >
+        {value.length > 0 ? value.length : 'none'} selected
+      </Select.ButtonWrapper>
+    )}
+  </Select.Button>
+  <Select.Options>
+    {exampleOptions.map((option) => (
+      <Select.Option key={option.key} value={option}>
+        {option.label}
+      </Select.Option>
+    ))}
+  </Select.Options>
+</Select>`,
+      },
+    },
+  },
+};
+
+/**
+ * The component provides some basic styles to handle long text in the provided field. Use
+ * `shouldTruncate` on `.ButtonWrapper` to truncate the text with an ellipsis.
+ */
+export const MultipleWithTruncation: StoryObj = {
+  args: {
+    ...Default.args,
+    label: 'Favorite Animal(s)',
+    multiple: true,
+    'data-testid': 'dropdown',
+    defaultValue: [exampleOptions[0]],
+    className: 'w-60',
+    name: 'standard-button',
+    children: (
+      <>
+        <Select.Button>
+          {({ value, open, disabled }) => (
+            <Select.ButtonWrapper isOpen={open} shouldTruncate>
+              {value.length > 0 ? value.length : 'none'} long selected
+              description
+            </Select.ButtonWrapper>
+          )}
+        </Select.Button>
+        <Select.Options>
+          {exampleOptions.map((option) => (
+            <Select.Option key={option.key} value={option}>
+              {option.label}
+            </Select.Option>
+          ))}
+        </Select.Options>
+      </>
+    ),
+  },
+};
+
+/**
+ * The field trigger width can be set with utility classes. By default, dropdown popover will exppand to match the width.
+ */
+export const AdjustedWidth: StoryObj = {
+  args: {
+    ...Default.args,
+    className: 'w-60',
+  },
+};
+
+/**
+ * We lock the maximum height of the option list to 1/4 of the available screen height. Scrolling is allowed in the list, and
+ * keyboard navigation (showing the items off the edge of the screen) is handled when used.
+ */
+export const LongOptionList: StoryObj = {
+  args: {
+    ...Default.args,
+    defaultValue: 'test3',
+    className: 'w-60',
+    children: (
+      <>
+        <Select.Button>
+          {({ value, open, disabled }) => (
+            <Select.ButtonWrapper isOpen={open} shouldTruncate>
+              {value}
+            </Select.ButtonWrapper>
+          )}
+        </Select.Button>
+        <Select.Options>
+          {Array(30)
+            .fill('test')
+            .map((option, index) => (
+              // eslint-disable-next-line react/no-array-index-key
+              <Select.Option key={`${option}-${index}`} value={option + index}>
+                {option}
+                {index}
+              </Select.Option>
+            ))}
+        </Select.Options>
+      </>
+    ),
+  },
+  play: async (playOptions) => {
+    const canvas = within(playOptions.canvasElement);
+    const selectButton = await canvas.findByRole('button');
+
+    await openMenu(playOptions);
+    await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}');
+
+    await expect(selectButton.getAttribute('aria-expanded')).toEqual('true');
+  },
+  parameters: {
+    badges: ['intro-1.2'],
+    layout: 'centered',
+    chromatic: { delay: 450 },
+  },
+  decorators: [(Story) => <div className="p-8 pb-16">{Story()}</div>],
+};
+
+/**
+ * If you want a different width for the trigger and the dropdown popover, you can control them separately.
+ */
+export const SeparateButtonAndMenuWidth: StoryObj = {
+  args: {
+    ...Default.args,
+    className: 'w-40',
+    optionsClassName: 'w-96',
+  },
+  play: selectCat,
+  parameters: {
+    chromatic: {
+      diffIncludeAntiAliasing: false,
+      diffThreshold: 0.72,
+    },
+  },
+  decorators: [(Story) => <div className="p-8">{Story()}</div>],
+};
+
+/**
+ * Each Select can be marked as disabled. This will update the visual treatment to indicate the field cannot be changed (but by default
+ * will show the selected value).
+ */
+export const Disabled: StoryObj = {
+  args: {
+    ...Default.args,
+    disabled: true,
+  },
+  parameters: {
+    axe: {
+      disabledRules: ['color-contrast'],
+    },
+  },
+};
+
+export const Required: StoryObj = {
+  args: {
+    ...Default.args,
+    required: true,
+    showHint: true,
+    className: 'w-96',
+  },
+};
+
+export const Optional: StoryObj = {
+  args: {
+    ...Default.args,
+    required: false,
+    showHint: true,
+    className: 'w-96',
+  },
+};
+
+export const Error: StoryObj = {
+  args: {
+    ...Required.args,
+    isError: true,
+    fieldNote: 'Some text describing error',
+  },
+};
+
+export const Warning: StoryObj = {
+  args: {
+    ...Optional.args,
+    isWarning: true,
+    fieldNote: 'Some text describing warning',
+  },
+};
+
+/**
+ * Having a visible label is not necessary. In those cases, use `aria-label` to set a accessible label for the field
+ */
+export const NoVisibleLabel: StoryObj = {
+  args: {
+    ...Default.args,
+    label: undefined,
+    'aria-label': 'hidden label',
+  },
+};
+
+export const NoVisibleLabelButRequired: StoryObj = {
+  args: {
+    ...Default.args,
+    label: undefined,
+    'aria-label': 'hidden label',
+    required: true,
+    className: 'w-96',
+  },
+};
+
+export const DisabledRequired: StoryObj = {
+  args: {
+    ...Default.args,
+    disabled: true,
+    required: true,
+    showHint: true,
+    className: 'w-96',
+  },
+  parameters: {
+    axe: {
+      disabledRules: ['color-contrast'],
+    },
+  },
+};
+
+/**
+ * Options for each `Select` can be aligned on different sides of the target button. Options for `placement` defined by
+ * PopperJS.
+ *
+ * More information: https://popper.js.org/docs/v2/constructors/#options
+ */
+export const OptionsRightAligned: StoryObj = {
+  parameters: {
+    chromatic: {
+      delay: 300,
+    },
+  },
+  args: {
+    ...Default.args,
+    className: 'w-60',
+    optionsClassName: 'w-96',
+    placement: 'bottom-end',
+  },
+  play: openMenu,
+  decorators: [(Story) => <div className="p-8">{Story()}</div>],
+};
+
+/**
+ * As an alternative rendering method, you can use several types of render props for fine-grained control of the button rendering, and
+ * the rendering of the list itself. Here, we use a render prop to control the contents of `Select`
+ *
+ * For more information on `Select` render props, review: https://headlessui.com/react/listbox#using-render-props
+ */
+export const UsingFunctionProps: StoryObj = {
+  render: () => {
+    const [selectedOption, setSelectedOption] =
+      // eslint-disable-next-line react-hooks/rules-of-hooks
+      React.useState<(typeof exampleOptions)[0]>();
+
+    return (
+      <Select
+        aria-label="Favorite Animal"
+        as="div"
+        data-testid="dropdown"
+        name="interactive-with-children"
+        onChange={setSelectedOption}
+        value={selectedOption}
+      >
+        {({ open }) => (
+          <>
+            <Select.Button
+              // Because we're using a render prop to completely control the styling and icon of the
+              // button, we need to configure this component to render as a Fragment. Otherwise we'd
+              // render two, nested buttons.
+              as={React.Fragment}
+            >
+              {() => (
+                <button aria-expanded={open} className="fpo">
+                  {selectedOption?.label || 'Select'}
+                  <Icon
+                    className="ml-4"
+                    name="filter-list"
+                    purpose="decorative"
+                  />
+                </button>
+              )}
+            </Select.Button>
+            <Select.Options>
+              {exampleOptions.map((option) => (
+                <Select.Option key={option.key} value={option}>
+                  {option.label}
+                </Select.Option>
+              ))}
+            </Select.Options>
+          </>
+        )}
+      </Select>
+    );
+  },
+};
+
+/**
+ * This shows the contents of `Select` upon render. Mostly to demonstrate it is possible, to capture a snapshot of the appearance.
+ */
+export const OpenByDefault: StoryObj = {
+  ...Default,
+  parameters: {
+    badges: ['intro-1.2', 'current-2.0'],
+    layout: 'centered',
+    chromatic: { delay: 300, disableSnapshot: true },
+  },
+  play: selectCat,
+};
diff --git a/src/components/Select/Select-v2.tsx b/src/components/Select/Select-v2.tsx
new file mode 100644
index 000000000..48a45167f
--- /dev/null
+++ b/src/components/Select/Select-v2.tsx
@@ -0,0 +1,559 @@
+import { Listbox } from '@headlessui/react';
+import clsx from 'clsx';
+
+import React, {
+  useContext,
+  useState,
+  type ReactNode,
+  type MouseEventHandler,
+} from 'react';
+import { createPortal } from 'react-dom';
+import { usePopper } from 'react-popper';
+
+import { useId } from '../../util/useId';
+import type { ExtractProps } from '../../util/utility-types';
+import { FieldNoteV2 as FieldNote } from '../FieldNote';
+import Icon, { type IconName } from '../Icon';
+import { InputLabelV2 as InputLabel } from '../InputLabel';
+import {
+  defaultPopoverModifiers,
+  PopoverContainerV2 as PopoverContainer,
+} from '../PopoverContainer';
+import type { PopoverContext, PopoverOptions } from '../PopoverContainer';
+import { PopoverListItemV2 as PopoverListItem } from '../PopoverListItem';
+import Text from '../Text';
+
+import styles from './Select-v2.module.css';
+
+/**
+ * TODO-AH: things to add:
+ * - handle placeholder (and color when no value is selected)
+ * - handle labelLayout
+ * - check handling of field status across components (booleans versus status field)
+ */
+
+type SelectProps = ExtractProps<typeof Listbox> &
+  PopoverOptions & {
+    // Component API
+    /**
+     * Screen-reader text for the select's label.
+     *
+     * When possible, use a visible label by passing a <Select.Label> into `children`.
+     * In rare cases where there's no visible label, you must provide an `aria-label` for screen readers.
+     * If you pass in an `aria-label`, <Select.Label>.
+     */
+    'aria-label'?: string;
+    /**
+     * Optional className for additional styling.
+     */
+    className?: string;
+    /**
+     * Name of the form element, which triggers the generation of hidden key/value form fields (e.g. `name=$name[$key]`).
+     *
+     * See: https://headlessui.com/react/listbox#using-with-html-forms
+     */
+    name?: string;
+    /**
+     * Optional className for additional options menu styling.
+     *
+     * When not using the compact variant, if optionsClassName is provided please
+     * include the width property to define the options menu width.
+     */
+    optionsClassName?: string;
+    /**
+     * Indicates that field is required for form to be successfully submitted
+     */
+    required?: boolean;
+    // Design API
+    /**
+     * Text under the text input used to provide a description or error message to describe the input.
+     */
+    fieldNote?: ReactNode;
+    /**
+     * Whether there is an error state for the field note text (and icon)
+     *
+     * **Default is `false`**.
+     */
+    isError?: boolean;
+    /**
+     * Whether there is a warning state for the field note text (and icon)
+     *
+     * **Default is `false`**.
+     */
+    isWarning?: boolean;
+    /**
+     * Visible text label for the component.
+     */
+    label?: string;
+    /**
+     * Whether it should show the field hint or not
+     *
+     * **Default is `"false"`**.
+     */
+    showHint?: boolean;
+  };
+
+type SelectLabelProps = ExtractProps<typeof Listbox.Label> & {
+  disabled?: boolean;
+  htmlFor: string;
+  required?: boolean;
+  showHint?: boolean;
+};
+type SelectOptionsProps = ExtractProps<typeof Listbox.Options>;
+type SelectOptionProps = ExtractProps<typeof Listbox.Option> & {
+  optionClassName?: string;
+};
+type SelectButtonProps = ExtractProps<typeof Listbox.Button> & {
+  // Design API
+  /**
+   * Icon override for component. Default is 'expand-more'
+   */
+  icon?: Extract<IconName, 'expand-more'>;
+  /**
+   * Indicates state of the select, used to style the button.
+   */
+  isOpen?: boolean;
+};
+
+type SelectButtonWrapperProps = {
+  // Component API
+  /**
+   * Text placed inside the button to describe the field.
+   */
+  children?: ReactNode;
+  /**
+   * Optional className for additional styling.
+   */
+  className?: string;
+  // Design API
+  /**
+   * Icon override for component. Default is 'expand-more'
+   */
+  icon?: Extract<IconName, 'expand-more'>;
+  /**
+   * Whether there is an error state for the field note text (and icon)
+   *
+   * **Default is `false`**.
+   */
+  isError?: boolean;
+  /**
+   * Whether there is a warning state for the field note text (and icon)
+   *
+   * **Default is `false`**.
+   */
+  isWarning?: boolean;
+  /**
+   * Indicates state of the select, used to style the button.
+   */
+  isOpen?: boolean;
+  /**
+   * custom click handler for the built-in or wrapper button
+   */
+  onClick?: MouseEventHandler;
+  /**
+   * Whether we should truncate the text displayed in the select field
+   */
+  shouldTruncate?: boolean;
+};
+
+type SelectContextType = PopoverContext & {
+  optionsClassName?: string;
+  isWarning?: boolean;
+  isError?: boolean;
+};
+
+let showNameWarning = true;
+
+function childrenHaveLabelComponent(children?: ReactNode): boolean {
+  const childrenArray = React.Children.toArray(children);
+  return childrenArray.some((child) => {
+    if (typeof child === 'string' || typeof child === 'number') {
+      return false;
+    } else if (
+      'props' in child &&
+      child.type &&
+      typeof child.type !== 'string' &&
+      child.type?.name === 'SelectLabel'
+    ) {
+      return true;
+    } else if ('props' in child && child.props.children) {
+      return childrenHaveLabelComponent(child.props.children);
+    }
+    return false;
+  });
+}
+
+const SelectContext = React.createContext<SelectContextType>({});
+
+/**
+ * `import {Select} from "@chanzuckerberg/eds";`
+ *
+ * A popover that reveals or hides a list of options from which to select
+ *
+ * Supports controlled and uncontrolled behavior, using a render prop in the latter case.
+ *
+ */
+export function Select({
+  'aria-label': ariaLabel,
+  children,
+  className,
+  disabled,
+  fieldNote,
+  id,
+  isError,
+  isWarning,
+  label,
+  modifiers = defaultPopoverModifiers,
+  name,
+  onFirstUpdate,
+  optionsClassName,
+  placement = 'bottom-start',
+  required,
+  showHint,
+  strategy,
+  onChange: theirOnChange,
+  ...other
+}: SelectProps) {
+  if (process.env.NODE_ENV !== 'production') {
+    const childrenHaveLabel =
+      children && childrenHaveLabelComponent(children as ReactNode);
+    if (!ariaLabel && !label && !childrenHaveLabel) {
+      throw new Error('You must provide a visible label or `aria-label`.');
+    }
+    if (!name && showNameWarning) {
+      console.warn(
+        "%c`Select` won't render a form field unless you include a `name` prop.\n\n See https://headlessui.com/react/listbox#using-with-html-forms for more information",
+        'font-weight: bold',
+      );
+      showNameWarning = false;
+    }
+  }
+
+  const [referenceElement, setReferenceElement] = useState(null);
+  const [popperElement, setPopperElement] = useState(null);
+  const { styles: popperStyles, attributes: popperAttributes } = usePopper(
+    referenceElement,
+    popperElement,
+    { placement, modifiers, strategy, onFirstUpdate },
+  );
+
+  // Create a new value to track the internal state of Listbox. Added to work around
+  // behavior inherited from HeadlessUI where it will fire onChange even if there is no change
+  // Adding to support behavior synced to how <select> tags work
+  const [selectedValue, setSelectedValue] = useState(
+    other.value !== undefined ? other.value : other.defaultValue,
+  );
+
+  const generatedIdVar = useId();
+  const idVar = id || generatedIdVar;
+
+  const componentClassName = clsx(
+    styles['select'],
+    fieldNote && styles['select--has-fieldNote'],
+    className,
+  );
+  const sharedProps = {
+    className: componentClassName,
+    // Provide a wrapping <div> element for the select. This is needed so that any props
+    // passed directly to this component have a corresponding DOM element to receive them.
+    // Otherwise we get an error.
+    as: 'div' as const,
+    disabled,
+    id: idVar,
+    name,
+    ...other,
+  };
+
+  const contextValue = Object.assign(
+    {},
+    optionsClassName ? { optionsClassName } : null,
+    { setReferenceElement },
+    { setPopperElement },
+    { popperStyles: popperStyles.popper },
+    { popperAttributes: popperAttributes.popper },
+    { isError },
+    { isWarning },
+  );
+
+  if (typeof children === 'function') {
+    return (
+      <SelectContext.Provider value={contextValue}>
+        <Listbox
+          {...sharedProps}
+          // We prefer to pass the aria-label in via an invisible SelectLabel, but we can't
+          // easily pass down function children with component children, so we'll settle for
+          // using a standard aria-label in this case.
+          aria-label={ariaLabel}
+        >
+          {children}
+        </Listbox>
+      </SelectContext.Provider>
+    );
+  }
+
+  return (
+    <SelectContext.Provider value={contextValue}>
+      <Listbox
+        {...sharedProps}
+        onChange={(changedValue) => {
+          if (selectedValue !== changedValue) {
+            setSelectedValue(changedValue);
+            // Use the value from the event because updates to `useState` are queued
+            theirOnChange && theirOnChange(changedValue);
+          }
+        }}
+      >
+        {(label || required) && (
+          <Select.Label
+            disabled={disabled}
+            htmlFor={idVar}
+            required={required}
+            showHint={showHint}
+          >
+            {label}
+          </Select.Label>
+        )}
+        {children}
+      </Listbox>
+      {fieldNote && (
+        <div className={styles['select__footer']}>
+          <FieldNote
+            disabled={disabled}
+            isError={isError}
+            isWarning={isWarning}
+          >
+            {fieldNote}
+          </FieldNote>
+        </div>
+      )}
+    </SelectContext.Provider>
+  );
+}
+
+const SelectLabel = ({
+  children: label,
+  required,
+  className,
+  disabled,
+  htmlFor,
+  showHint,
+}: SelectLabelProps) => {
+  const componentClassName = clsx(
+    styles['select__label'],
+    disabled && clsx(styles['select__label--disabled']),
+    className,
+  );
+
+  const requiredTextClassName = clsx(
+    styles['select__required-text'],
+    disabled && styles['select__required-text--disabled'],
+  );
+
+  const overlineClassName = clsx(
+    styles['select__overline'],
+    !label && styles['select__overline--no-label'],
+  );
+
+  return (
+    <div className={overlineClassName}>
+      <Listbox.Label
+        as={InputLabel}
+        className={componentClassName}
+        htmlFor={htmlFor}
+      >
+        {label}
+      </Listbox.Label>
+      {required && showHint && (
+        <Text as="span" className={requiredTextClassName} preset="body-sm">
+          (Required)
+        </Text>
+      )}
+      {!required && showHint && (
+        <Text as="span" className={requiredTextClassName} preset="body-sm">
+          (Optional)
+        </Text>
+      )}
+    </div>
+  );
+};
+
+/**
+ * The trigger for the select component, which is usually a form of `Button` or some targetable/clickable component
+ */
+const SelectButton = function (props: SelectButtonProps) {
+  const { children, className, onClick: theirOnClick, ...other } = props;
+  const { setReferenceElement, isWarning, isError } = useContext(SelectContext);
+  return (
+    <Listbox.Button
+      // Render as a fragment instead of the default element. We're rendering our own element in
+      // the render prop to control styling and positiong, and we don't want to end up with
+      // duplicate buttons.
+      as={React.Fragment}
+      ref={setReferenceElement}
+      {...other}
+    >
+      {(renderProps) => {
+        return typeof children === 'function' ? (
+          children(renderProps)
+        ) : (
+          <SelectButtonWrapper
+            className={className}
+            isError={isError}
+            isOpen={renderProps.open}
+            isWarning={isWarning}
+            onClick={(event) => {
+              theirOnClick && theirOnClick(event);
+            }}
+          >
+            {children}
+          </SelectButtonWrapper>
+        );
+      }}
+    </Listbox.Button>
+  );
+};
+
+/**
+ * The content container showing the available options when the trigger is activated
+ */
+const SelectOptions = function (props: SelectOptionsProps) {
+  const { className, ...other } = props;
+  const { optionsClassName, setPopperElement, popperStyles, popperAttributes } =
+    useContext(SelectContext);
+
+  const componentClassName = clsx(
+    styles['select__options'],
+    className,
+    optionsClassName,
+  );
+
+  const optionProps = {
+    as: PopoverContainer,
+    className: componentClassName,
+    ref: setPopperElement,
+    style: popperStyles,
+    ...popperAttributes,
+    ...other,
+  };
+  if (typeof document !== 'undefined') {
+    return (
+      <>{createPortal(<Listbox.Options {...optionProps} />, document.body)}</>
+    );
+  }
+  return null;
+};
+
+/**
+ * Represents one of the available options for selection
+ */
+const SelectOption = function (props: SelectOptionProps) {
+  const { children, className, optionClassName, ...other } = props;
+
+  const optionItemClassName = clsx(optionClassName, styles['select__option']);
+
+  return (
+    <Listbox.Option
+      // Render as a fragment instead of the default <li>. We're rendering our own <li> in the
+      // render prop to control active/selected styling, and we don't want to end up with duplicate
+      // <li>'s.
+      as={React.Fragment}
+      {...other}
+    >
+      {typeof children === 'function'
+        ? children
+        : ({ active, disabled, selected }) => {
+            return (
+              <PopoverListItem
+                className={optionItemClassName}
+                icon={selected ? 'check' : undefined}
+                isDisabled={disabled}
+                isFocused={active}
+              >
+                <span className={styles['select__option-text']}>
+                  {children}
+                </span>
+              </PopoverListItem>
+            );
+          }}
+    </Listbox.Option>
+  );
+};
+
+/**
+ * The component functioning as a styling for the trigger, selection arrow, and space to
+ * show the current value.
+ */
+export const SelectButtonWrapper = React.forwardRef<
+  HTMLButtonElement,
+  SelectButtonWrapperProps
+>(
+  (
+    {
+      children,
+      className,
+      icon = 'expand-more',
+      isError,
+      isOpen,
+      isWarning,
+      shouldTruncate = false,
+      onClick: theirOnClick,
+      ...other
+    },
+    ref,
+  ) => {
+    const { isWarning: contextWarning, isError: contextError } =
+      useContext(SelectContext);
+    const showWarning =
+      typeof isWarning !== 'undefined' ? isWarning : contextWarning;
+    const showError = typeof isError !== 'undefined' ? isError : contextError;
+
+    const componentClassName = clsx(
+      styles['select-button'],
+      showWarning && styles['select-button--warning'],
+      showError && styles['select-button--error'],
+      className,
+    );
+    const iconClassName = clsx(
+      styles['select-button__icon'],
+      isOpen && styles['select-button__icon--reversed'],
+    );
+    const textClassName = clsx(
+      shouldTruncate && styles['select-button__text--truncated'],
+    );
+
+    return (
+      <button
+        className={componentClassName}
+        onClick={(ev) => {
+          theirOnClick && theirOnClick(ev);
+        }}
+        ref={ref}
+        type="button"
+        {...other}
+      >
+        {/* Wrapping span ensures that `children` and icon will be correctly pushed to
+            either side of the button even if `children` contains more than one element. */}
+        <span className={textClassName}>{children}</span>
+        <Icon
+          className={iconClassName}
+          name={icon}
+          purpose="decorative"
+          size="1.5rem"
+        />
+      </button>
+    );
+  },
+);
+
+Select.displayName = 'Select';
+SelectButton.displayName = 'Select.Button';
+SelectButtonWrapper.displayName = 'Select.ButtonWrapper';
+SelectLabel.displayName = 'Select.Label';
+SelectOption.displayName = 'Select.Option';
+SelectOptions.displayName = 'Select.Options';
+
+Select.Button = SelectButton;
+Select.ButtonWrapper = SelectButtonWrapper;
+Select.Label = SelectLabel;
+Select.Option = SelectOption;
+Select.Options = SelectOptions;
diff --git a/src/components/Select/index.ts b/src/components/Select/index.ts
index b7cf7418b..5671b425a 100644
--- a/src/components/Select/index.ts
+++ b/src/components/Select/index.ts
@@ -1 +1,2 @@
 export { Select as default } from './Select';
+export { Select as SelectV2 } from './Select-v2';