Browse Source

ref(compactSelect): Use `react-aria` instead of `react-select` (#43449)

Update `CompactSelect` with a custom list box implementation (with
accessibility features from `react-aria`'s `useListBox`), rather than
the current approach of restyling `SelectControl` (which in turn uses
`react-select`). Benefits of the new implementation include:
* **Higher customizability:** `react-aria` only provides state
management and accessibility features/constraints, leaving visual
styling entirely to us. It also exposes a number of methods for
controlling the selection state, so we can implement additional features
(e.g. selecting an entire section) more easily if we want to.
* **Better TS type inference:** `CompactSelect` now has a better, more
constrained `Value` type, which will be automatically inferred based on
the incoming prop types.
<img width="857" alt="Screenshot 2023-01-19 at 11 22 59 AM"
src="https://user-images.githubusercontent.com/44172267/213540170-82c84e71-ee79-4c16-9326-4cc13a1b753c.png">

* **Easier testing:** we can now query the select menu/options using
simple accessible roles (`listbox`/`option`). While currently with
`react-select` we have _some_ accessible roles, there are still gaps
(e.g. list boxes have no accessible roles/attributes) and the roles are
more wordy than they need to be (e.g. `menuitemradio` rather than just
`option`).
<img width="813" alt="Screenshot 2023-01-19 at 11 25 03 AM"
src="https://user-images.githubusercontent.com/44172267/213540555-78c858f3-52eb-460f-99f7-629f6c3b4b01.png">

* **A tidier CompositeSelect:** currently `CompositeSelect` is a
frankenstein version of `CompactSelect`, with custom mapping functions
to simulate self-contained select regions. With the higher
customizability of the new implementation, we can now implement
`CompositeSelect` in a tidier, more sensible way: as a series of list
boxes, each with its own selection state.
* **Simpler DOM structure:** there are much fewer `divs` and more
semantic HTML (`ul`/`li` elements), a plus for developer experience.
<img width="602" alt="Screenshot 2023-01-19 at 11 25 56 AM"
src="https://user-images.githubusercontent.com/44172267/213540724-d9651425-f7e0-4cb3-a857-35a70fcfe800.png">


I've tried to keep the prop names mostly the same. See comments below
for more details on what's changed.
Vu Luong 2 years ago
parent
commit
a10023499b

+ 68 - 0
docs-ui/stories/components/compactSelect.stories.js

@@ -0,0 +1,68 @@
+import CompactSelect from 'sentry/components/compactSelect';
+
+export default {
+  title: 'Components/Compact Select',
+  component: CompactSelect,
+};
+
+export const _CompactSelect = props => (
+  <CompactSelect
+    defaultValue={props.multiple ? ['choice_one'] : 'choice_one'}
+    options={[
+      {
+        label: 'Section 1',
+        options: [
+          {value: 'choice_one', label: 'Choice One'},
+          {value: 'choice_two', label: 'Choice Two'},
+        ],
+      },
+      {
+        label: 'Section 2',
+        options: [
+          {value: 'choice_three', label: 'Choice Three'},
+          {value: 'choice_four', label: 'Choice Four'},
+        ],
+      },
+    ]}
+    {...props}
+  />
+);
+
+_CompactSelect.args = {
+  disabled: false,
+  multiple: false,
+  menuTitle: '',
+  disallowEmptySelection: false,
+  isSearchable: false,
+  isLoading: false,
+  isClearable: false,
+  placeholder: 'Search…',
+  shouldCloseOnBlur: true,
+  isDismissable: true,
+  offset: 8,
+  crossOffset: 0,
+  containerPadding: 8,
+  placement: 'bottom left',
+  triggerProps: {
+    prefix: 'Prefix',
+  },
+};
+_CompactSelect.argTypes = {
+  placement: {
+    options: [
+      'top',
+      'bottom',
+      'left',
+      'right',
+      'top left',
+      'top right',
+      'bottom left',
+      'bottom right',
+      'left top',
+      'left bottom',
+      'right top',
+      'right bottom',
+    ],
+    control: {type: 'radio'},
+  },
+};

+ 66 - 0
docs-ui/stories/components/compositeSelect.stories.js

@@ -0,0 +1,66 @@
+import CompositeSelect from 'sentry/components/compactSelect/composite';
+
+export default {
+  title: 'Components/Composite Select',
+  component: CompositeSelect,
+};
+
+export const _CompositeSelect = props => (
+  <CompositeSelect {...props}>
+    <CompositeSelect.Region
+      label="Region 1"
+      defaultValue="choice_one"
+      onChange={() => {}}
+      options={[
+        {value: 'choice_one', label: 'Choice One'},
+        {value: 'choice_two', label: 'Choice Two'},
+      ]}
+    />
+    <CompositeSelect.Region
+      multiple
+      label="Region 2"
+      defaultValue={['choice_three']}
+      onChange={() => {}}
+      options={[
+        {value: 'choice_three', label: 'Choice Three'},
+        {value: 'choice_four', label: 'Choice Four'},
+      ]}
+    />
+  </CompositeSelect>
+);
+_CompositeSelect.args = {
+  disabled: false,
+  menuTitle: '',
+  isSearchable: false,
+  isLoading: false,
+  isClearable: false,
+  placeholder: 'Search…',
+  shouldCloseOnBlur: true,
+  isDismissable: true,
+  offset: 8,
+  crossOffset: 0,
+  containerPadding: 8,
+  placement: 'bottom left',
+  triggerProps: {
+    prefix: 'Prefix',
+  },
+};
+_CompositeSelect.argTypes = {
+  placement: {
+    options: [
+      'top',
+      'bottom',
+      'left',
+      'right',
+      'top left',
+      'top right',
+      'bottom left',
+      'bottom right',
+      'left top',
+      'left bottom',
+      'right top',
+      'right bottom',
+    ],
+    control: {type: 'radio'},
+  },
+};

+ 0 - 96
docs-ui/stories/components/form-fields.stories.js

@@ -1,7 +1,5 @@
 import {action} from '@storybook/addon-actions';
 import {action} from '@storybook/addon-actions';
 
 
-import CompactSelect from 'sentry/components/compactSelect';
-import CompositeSelect from 'sentry/components/compositeSelect';
 import RadioGroup from 'sentry/components/forms/controls/radioGroup';
 import RadioGroup from 'sentry/components/forms/controls/radioGroup';
 import RangeSlider from 'sentry/components/forms/controls/rangeSlider';
 import RangeSlider from 'sentry/components/forms/controls/rangeSlider';
 import NewBooleanField from 'sentry/components/forms/fields/booleanField';
 import NewBooleanField from 'sentry/components/forms/fields/booleanField';
@@ -283,100 +281,6 @@ SelectFieldInFieldLabel.parameters = {
   },
   },
 };
 };
 
 
-export const CompactSelectField = props => (
-  <CompactSelect
-    defaultValue="opt_one"
-    options={[
-      {value: 'opt_one', label: 'Option One'},
-      {value: 'opt_two', label: 'Option Two'},
-    ]}
-    {...props}
-  />
-);
-
-CompactSelectField.storyName = 'Select - Compact';
-CompactSelectField.parameters = {
-  docs: {
-    description: {
-      story: 'Compact',
-    },
-  },
-};
-CompactSelectField.args = {
-  size: 'md',
-  menuTitle: '',
-  isSearchable: false,
-  isDisabled: false,
-  isClearable: false,
-  isLoading: false,
-  multiple: false,
-  placeholder: 'Search…',
-  closeOnSelect: true,
-  shouldCloseOnBlur: true,
-  isDismissable: true,
-  offset: 8,
-  crossOffset: 0,
-  containerPadding: 8,
-  placement: 'bottom left',
-  triggerProps: {
-    prefix: 'Prefix',
-  },
-};
-CompactSelectField.argTypes = {
-  placement: {
-    options: [
-      'top',
-      'bottom',
-      'left',
-      'right',
-      'top left',
-      'top right',
-      'bottom left',
-      'bottom right',
-      'left top',
-      'left bottom',
-      'right top',
-      'right bottom',
-    ],
-    control: {type: 'radio'},
-  },
-  size: {
-    options: ['md', 'sm', 'xs'],
-    control: {type: 'radio'},
-  },
-};
-
-export const CompositeSelectField = props => (
-  <CompositeSelect
-    sections={[
-      {
-        label: 'Group 1',
-        value: 'group_1',
-        defaultValue: 'choice_one',
-        options: [
-          {value: 'choice_one', label: 'Choice One'},
-          {value: 'choice_two', label: 'Choice Two'},
-        ],
-      },
-      {
-        label: 'Group 2',
-        value: 'group_2',
-        defaultValue: ['choice_three'],
-        multiple: true,
-        options: [
-          {value: 'choice_three', label: 'Choice Three'},
-          {value: 'choice_four', label: 'Choice Four'},
-        ],
-      },
-    ]}
-    {...props}
-  />
-);
-CompositeSelectField.storyName = 'Select - Composite';
-CompositeSelectField.args = {...CompactSelectField.args};
-delete CompositeSelectField.args.multiple;
-CompositeSelectField.argTypes = CompactSelectField.argTypes;
-
 export const NonInlineField = () => (
 export const NonInlineField = () => (
   <Form>
   <Form>
     <FormField name="radio" label="Radio Field" inline={false}>
     <FormField name="radio" label="Radio Field" inline={false}>

+ 1 - 0
package.json

@@ -26,6 +26,7 @@
     "@react-aria/button": "^3.3.4",
     "@react-aria/button": "^3.3.4",
     "@react-aria/focus": "^3.5.0",
     "@react-aria/focus": "^3.5.0",
     "@react-aria/interactions": "^3.7.0",
     "@react-aria/interactions": "^3.7.0",
+    "@react-aria/listbox": "^3.5.1",
     "@react-aria/menu": "^3.3.0",
     "@react-aria/menu": "^3.3.0",
     "@react-aria/numberfield": "3.1.0",
     "@react-aria/numberfield": "3.1.0",
     "@react-aria/overlays": "^3.7.3",
     "@react-aria/overlays": "^3.7.3",

+ 25 - 23
static/app/components/charts/optionSelector.spec.tsx

@@ -1,7 +1,7 @@
 import {useState} from 'react';
 import {useState} from 'react';
 
 
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
 
 import OptionSelector from 'sentry/components/charts/optionSelector';
 import OptionSelector from 'sentry/components/charts/optionSelector';
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
@@ -58,33 +58,35 @@ describe('Charts > OptionSelector (Multiple)', function () {
 
 
   it('renders yAxisOptions with yAxisValue selected', function () {
   it('renders yAxisOptions with yAxisValue selected', function () {
     renderComponent();
     renderComponent();
-    expect(
-      within(screen.getByTestId('count()')).getByTestId('icon-check-mark')
-    ).toBeInTheDocument();
-    expect(
-      within(screen.getByTestId('failure_count()')).getByTestId('icon-check-mark')
-    ).toBeInTheDocument();
-    expect(
-      // eslint-disable-next-line testing-library/prefer-presence-queries
-      within(screen.getByTestId('count_unique(user)')).queryByTestId('icon-check-mark')
-    ).not.toBeInTheDocument();
+    expect(screen.getByRole('option', {name: 'count()'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByRole('option', {name: 'failure_count()'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByRole('option', {name: 'count_unique(user)'})).toHaveAttribute(
+      'aria-selected',
+      'false'
+    );
   });
   });
 
 
   it('calls onChange prop with new checkbox option state', function () {
   it('calls onChange prop with new checkbox option state', function () {
     renderComponent();
     renderComponent();
-    userEvent.click(screen.getByTestId('count()'));
+    userEvent.click(screen.getByRole('option', {name: 'count()'}));
     expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']);
     expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']);
     onChangeStub.mockClear();
     onChangeStub.mockClear();
-    userEvent.click(screen.getByTestId('count()'));
-    expect(onChangeStub).toHaveBeenCalledWith(['failure_count()', 'count()']);
+    userEvent.click(screen.getByRole('option', {name: 'count()'}));
+    expect(onChangeStub).toHaveBeenCalledWith(['count()', 'failure_count()']);
     onChangeStub.mockClear();
     onChangeStub.mockClear();
-    userEvent.click(screen.getByTestId('failure_count()'));
+    userEvent.click(screen.getByRole('option', {name: 'failure_count()'}));
     expect(onChangeStub).toHaveBeenCalledWith(['count()']);
     expect(onChangeStub).toHaveBeenCalledWith(['count()']);
     onChangeStub.mockClear();
     onChangeStub.mockClear();
-    userEvent.click(screen.getByTestId('failure_count()'));
+    userEvent.click(screen.getByRole('option', {name: 'failure_count()'}));
     expect(onChangeStub).toHaveBeenCalledWith(['count()', 'failure_count()']);
     expect(onChangeStub).toHaveBeenCalledWith(['count()', 'failure_count()']);
     onChangeStub.mockClear();
     onChangeStub.mockClear();
-    userEvent.click(screen.getByTestId('count_unique(user)'));
+    userEvent.click(screen.getByRole('option', {name: 'count_unique(user)'}));
     expect(onChangeStub).toHaveBeenCalledWith([
     expect(onChangeStub).toHaveBeenCalledWith([
       'count()',
       'count()',
       'failure_count()',
       'failure_count()',
@@ -94,22 +96,22 @@ describe('Charts > OptionSelector (Multiple)', function () {
 
 
   it('does not uncheck options when clicked if only one option is currently selected', function () {
   it('does not uncheck options when clicked if only one option is currently selected', function () {
     renderComponent();
     renderComponent();
-    userEvent.click(screen.getByTestId('count()'));
+    userEvent.click(screen.getByRole('option', {name: 'count()'}));
     expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']);
     expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']);
-    userEvent.click(screen.getByTestId('failure_count()'));
+    userEvent.click(screen.getByRole('option', {name: 'failure_count()'}));
     expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']);
     expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']);
   });
   });
 
 
   it('only allows up to 3 options to be checked at one time', function () {
   it('only allows up to 3 options to be checked at one time', function () {
     renderComponent();
     renderComponent();
-    userEvent.click(screen.getByTestId('count_unique(user)'));
+    userEvent.click(screen.getByRole('option', {name: 'count_unique(user)'}));
     expect(onChangeStub).toHaveBeenCalledWith([
     expect(onChangeStub).toHaveBeenCalledWith([
       'count()',
       'count()',
       'failure_count()',
       'failure_count()',
       'count_unique(user)',
       'count_unique(user)',
     ]);
     ]);
     onChangeStub.mockClear();
     onChangeStub.mockClear();
-    userEvent.click(screen.getByTestId('avg(transaction.duration)'));
+    userEvent.click(screen.getByRole('option', {name: 'avg(transaction.duration)'}));
     expect(onChangeStub).not.toHaveBeenCalledWith([
     expect(onChangeStub).not.toHaveBeenCalledWith([
       'count()',
       'count()',
       'failure_count()',
       'failure_count()',
@@ -117,10 +119,10 @@ describe('Charts > OptionSelector (Multiple)', function () {
       'avg(transaction.duration)',
       'avg(transaction.duration)',
     ]);
     ]);
     onChangeStub.mockClear();
     onChangeStub.mockClear();
-    userEvent.click(screen.getByTestId('count_unique(user)'));
+    userEvent.click(screen.getByRole('option', {name: 'count_unique(user)'}));
     expect(onChangeStub).toHaveBeenCalledWith(['count()', 'failure_count()']);
     expect(onChangeStub).toHaveBeenCalledWith(['count()', 'failure_count()']);
     onChangeStub.mockClear();
     onChangeStub.mockClear();
-    userEvent.click(screen.getByTestId('count_unique(user)'));
+    userEvent.click(screen.getByRole('option', {name: 'count_unique(user)'}));
     expect(onChangeStub).toHaveBeenCalledWith([
     expect(onChangeStub).toHaveBeenCalledWith([
       'count()',
       'count()',
       'failure_count()',
       'failure_count()',

+ 44 - 15
static/app/components/charts/optionSelector.tsx

@@ -1,36 +1,48 @@
 import {Fragment, useMemo} from 'react';
 import {Fragment, useMemo} from 'react';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
 
 
-import CompactSelect from 'sentry/components/compactSelect';
+import CompactSelect, {
+  MultipleSelectProps,
+  SelectOption,
+  SingleSelectProps,
+} from 'sentry/components/compactSelect';
 import FeatureBadge from 'sentry/components/featureBadge';
 import FeatureBadge from 'sentry/components/featureBadge';
 import Truncate from 'sentry/components/truncate';
 import Truncate from 'sentry/components/truncate';
-import {SelectValue} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import {defined} from 'sentry/utils';
 
 
-type BaseProps = React.ComponentProps<typeof CompactSelect> & {
-  options: SelectValue<string>[];
+type BaseProps = {
   title: string;
   title: string;
   featureType?: 'alpha' | 'beta' | 'new';
   featureType?: 'alpha' | 'beta' | 'new';
 };
 };
 
 
-interface SingleProps extends Omit<BaseProps, 'onChange'> {
+interface SingleProps
+  extends Omit<SingleSelectProps<string>, 'onChange' | 'defaultValue' | 'multiple'>,
+    BaseProps {
   onChange: (value: string) => void;
   onChange: (value: string) => void;
   selected: string;
   selected: string;
+  defaultValue?: string;
+  multiple?: false;
 }
 }
-interface MultipleProps extends Omit<BaseProps, 'onChange'> {
+
+interface MultipleProps
+  extends Omit<MultipleSelectProps<string>, 'onChange' | 'defaultValue' | 'multiple'>,
+    BaseProps {
+  multiple: true;
   onChange: (value: string[]) => void;
   onChange: (value: string[]) => void;
   selected: string[];
   selected: string[];
+  defaultValue?: string[];
 }
 }
 
 
-function OptionSelector<MultipleType extends boolean>({
+function OptionSelector({
   options,
   options,
   onChange,
   onChange,
   selected,
   selected,
   title,
   title,
   featureType,
   featureType,
   multiple,
   multiple,
+  defaultValue,
   ...rest
   ...rest
-}: MultipleType extends true ? MultipleProps : SingleProps) {
+}: SingleProps | MultipleProps) {
   const mappedOptions = useMemo(() => {
   const mappedOptions = useMemo(() => {
     return options.map(opt => ({
     return options.map(opt => ({
       ...opt,
       ...opt,
@@ -38,6 +50,27 @@ function OptionSelector<MultipleType extends boolean>({
     }));
     }));
   }, [options]);
   }, [options]);
 
 
+  const selectProps = useMemo(() => {
+    // Use an if statement to help TS separate MultipleProps and SingleProps
+    if (multiple) {
+      return {
+        multiple,
+        value: selected,
+        defaultValue,
+        onChange: (sel: SelectOption<string>[]) => {
+          onChange?.(sel.map(o => o.value));
+        },
+      };
+    }
+
+    return {
+      multiple,
+      value: selected,
+      defaultValue,
+      onChange: opt => onChange?.(opt.value),
+    };
+  }, [multiple, selected, defaultValue, onChange]);
+
   function isOptionDisabled(option) {
   function isOptionDisabled(option) {
     return (
     return (
       // Option is explicitly marked as disabled
       // Option is explicitly marked as disabled
@@ -51,14 +84,12 @@ function OptionSelector<MultipleType extends boolean>({
 
 
   return (
   return (
     <CompactSelect
     <CompactSelect
+      {...rest}
+      {...selectProps}
       size="sm"
       size="sm"
       options={mappedOptions}
       options={mappedOptions}
-      value={selected}
-      onChange={option => {
-        onChange(multiple ? option.map(o => o.value) : option.value);
-      }}
       isOptionDisabled={isOptionDisabled}
       isOptionDisabled={isOptionDisabled}
-      multiple={multiple}
+      position="bottom-end"
       triggerProps={{
       triggerProps={{
         borderless: true,
         borderless: true,
         prefix: (
         prefix: (
@@ -68,8 +99,6 @@ function OptionSelector<MultipleType extends boolean>({
           </Fragment>
           </Fragment>
         ),
         ),
       }}
       }}
-      position="bottom-end"
-      {...rest}
     />
     />
   );
   );
 }
 }

+ 0 - 388
static/app/components/compactSelect.tsx

@@ -1,388 +0,0 @@
-import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
-import {components as selectComponents, OptionTypeBase} from 'react-select';
-import isPropValid from '@emotion/is-prop-valid';
-import {useTheme} from '@emotion/react';
-import styled from '@emotion/styled';
-import {useButton} from '@react-aria/button';
-import {FocusScope} from '@react-aria/focus';
-import {useMenuTrigger} from '@react-aria/menu';
-import {useResizeObserver} from '@react-aria/utils';
-
-import Badge from 'sentry/components/badge';
-import {Button} from 'sentry/components/button';
-import DropdownButton, {DropdownButtonProps} from 'sentry/components/dropdownButton';
-import SelectControl, {
-  ControlProps,
-  GeneralSelectValue,
-} from 'sentry/components/forms/controls/selectControl';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import {Overlay, PositionWrapper} from 'sentry/components/overlay';
-import space from 'sentry/styles/space';
-import {FormSize} from 'sentry/utils/theme';
-import toArray from 'sentry/utils/toArray';
-import useOverlay, {UseOverlayProps} from 'sentry/utils/useOverlay';
-
-interface Props<OptionType extends OptionTypeBase, MultipleType extends boolean>
-  extends Omit<ControlProps<OptionType>, 'choices' | 'multiple' | 'onChange'>,
-    UseOverlayProps {
-  options: Array<OptionType & {options?: OptionType[]}>;
-  /**
-   * Pass class name to the outer wrap
-   */
-  className?: string;
-  /**
-   * Whether new options are being loaded. When true, CompactSelect will
-   * display a loading indicator in the header.
-   */
-  isLoading?: boolean;
-  multiple?: MultipleType;
-  onChange?: MultipleType extends true
-    ? (values: OptionType[]) => void
-    : (value: OptionType) => void;
-  onChangeValueMap?: (value: OptionType[]) => ControlProps<OptionType>['value'];
-  /**
-   * Tag name for the outer wrap, defaults to `div`
-   */
-  renderWrapAs?: React.ElementType;
-  /**
-   * Affects the size of the trigger button and menu items.
-   */
-  size?: FormSize;
-  /**
-   * Optionally replace the trigger button with a different component. Note
-   * that the replacement must have the `props` and `ref` (supplied in
-   * TriggerProps) forwarded its outer wrap, otherwise the accessibility
-   * features won't work correctly.
-   */
-  trigger?: (props: Omit<DropdownButtonProps, 'children'>) => React.ReactNode;
-  /**
-   * By default, the menu trigger will be rendered as a button, with
-   * triggerLabel as the button label.
-   */
-  triggerLabel?: React.ReactNode;
-  /**
-   * If using the default button trigger (i.e. the custom `trigger` prop has
-   * not been provided), then `triggerProps` will be passed on to the button
-   * component.
-   */
-  triggerProps?: DropdownButtonProps;
-}
-
-/**
- * Recursively finds the selected option(s) from an options array. Useful for
- * non-flat arrays that contain sections (groups of options).
- */
-function getSelectedOptions<
-  OptionType extends GeneralSelectValue,
-  MultipleType extends boolean
->(
-  opts: Props<OptionType, MultipleType>['options'],
-  value: Props<OptionType, MultipleType>['value']
-): Props<OptionType, MultipleType>['options'] {
-  return opts.reduce((acc: Props<OptionType, MultipleType>['options'], cur) => {
-    if (cur.options) {
-      return acc.concat(getSelectedOptions(cur.options, value));
-    }
-    if (cur.value === value) {
-      return acc.concat(cur);
-    }
-    return acc;
-  }, []);
-}
-
-// Exported so we can further customize this component with react-select's
-// components prop elsewhere
-export const CompactSelectControl = ({
-  innerProps,
-  ...props
-}: React.ComponentProps<typeof selectComponents.Control>) => {
-  const {hasValue, selectProps} = props;
-  const {isSearchable, menuTitle, isClearable, isLoading} = selectProps;
-
-  return (
-    <Fragment>
-      {(menuTitle || isClearable || isLoading) && (
-        <MenuHeader>
-          <MenuTitle>
-            <span>{menuTitle}</span>
-          </MenuTitle>
-          {isLoading && <StyledLoadingIndicator size={12} mini />}
-          {hasValue && isClearable && !isLoading && (
-            <ClearButton
-              size="zero"
-              borderless
-              onClick={() => props.clearValue()}
-              // set tabIndex -1 to autofocus search on open
-              tabIndex={isSearchable ? -1 : undefined}
-            >
-              Clear
-            </ClearButton>
-          )}
-        </MenuHeader>
-      )}
-      <selectComponents.Control
-        {...props}
-        innerProps={{...innerProps, ...(!isSearchable && {'aria-hidden': true})}}
-      />
-    </Fragment>
-  );
-};
-
-/**
- * A select component with a more compact trigger button. Accepts the same
- * props as SelectControl, plus some more for the trigger button & overlay.
- */
-function CompactSelect<
-  OptionType extends GeneralSelectValue = GeneralSelectValue,
-  MultipleType extends boolean = false
->({
-  // Select props
-  options,
-  onChange,
-  defaultValue,
-  value: valueProp,
-  isDisabled: disabledProp,
-  isSearchable = false,
-  multiple,
-  placeholder = 'Search…',
-  onChangeValueMap,
-  // Trigger button & wrapper props
-  trigger,
-  triggerLabel,
-  triggerProps,
-  isOpen: isOpenProp,
-  size = 'md',
-  className,
-  renderWrapAs,
-  closeOnSelect = true,
-  menuTitle,
-  onClose,
-  // Overlay props
-  offset = 8,
-  position = 'bottom-start',
-  shouldCloseOnBlur = true,
-  isDismissable = true,
-  maxMenuHeight: maxMenuHeightProp = 400,
-  ...props
-}: Props<OptionType, MultipleType>) {
-  // Manage the dropdown menu's open state
-  const isDisabled = disabledProp || options?.length === 0;
-
-  const {
-    isOpen,
-    state,
-    triggerProps: overlayTriggerProps,
-    triggerRef,
-    overlayProps,
-  } = useOverlay({
-    isOpen: isOpenProp,
-    onClose,
-    offset,
-    position,
-    isDismissable,
-    shouldCloseOnBlur,
-    shouldCloseOnInteractOutside: target =>
-      target && triggerRef.current !== target && !triggerRef.current?.contains(target),
-  });
-
-  const {menuTriggerProps} = useMenuTrigger(
-    {type: 'listbox', isDisabled},
-    {...state, focusStrategy: 'first'},
-    triggerRef
-  );
-
-  const {buttonProps} = useButton({isDisabled, ...menuTriggerProps}, triggerRef);
-
-  // Keep an internal copy of the current select value and update the control
-  // button's label when the value changes
-  const [internalValue, setInternalValue] = useState(valueProp ?? defaultValue);
-
-  // Keep track of the default trigger label when the value changes
-  const defaultTriggerLabel = useMemo(() => {
-    const newValue = valueProp ?? internalValue;
-    const valueSet = toArray(newValue);
-    const selectedOptions = valueSet
-      .map(val => getSelectedOptions<OptionType, MultipleType>(options, val))
-      .flat();
-
-    return (
-      <Fragment>
-        <ButtonLabel>{selectedOptions[0]?.label ?? ''}</ButtonLabel>
-        {selectedOptions.length > 1 && (
-          <StyledBadge text={`+${selectedOptions.length - 1}`} />
-        )}
-      </Fragment>
-    );
-  }, [options, valueProp, internalValue]);
-
-  const onValueChange = useCallback(
-    option => {
-      const valueMap = onChangeValueMap ?? (opts => opts.map(opt => opt.value));
-      const newValue = Array.isArray(option) ? valueMap(option) : option?.value;
-      setInternalValue(newValue);
-      onChange?.(option);
-
-      if (closeOnSelect && !multiple) {
-        state.close();
-      }
-    },
-    [state, closeOnSelect, multiple, onChange, onChangeValueMap]
-  );
-
-  // Calculate the current trigger element's width. This will be used as
-  // the min width for the menu.
-  const [triggerWidth, setTriggerWidth] = useState<number>();
-  // Update triggerWidth when its size changes using useResizeObserver
-  const updateTriggerWidth = useCallback(async () => {
-    // Wait until the trigger element finishes rendering, otherwise
-    // ResizeObserver might throw an infinite loop error.
-    await new Promise(resolve => window.setTimeout(resolve));
-    const newTriggerWidth = triggerRef.current?.offsetWidth;
-    newTriggerWidth && setTriggerWidth(newTriggerWidth);
-  }, [triggerRef]);
-  useResizeObserver({ref: triggerRef, onResize: updateTriggerWidth});
-  // If ResizeObserver is not available, manually update the width
-  // when any of [trigger, triggerLabel, triggerProps] changes.
-  useEffect(() => {
-    if (typeof window.ResizeObserver !== 'undefined') {
-      return;
-    }
-    updateTriggerWidth();
-  }, [updateTriggerWidth]);
-
-  function renderTrigger() {
-    if (trigger) {
-      return trigger({
-        size,
-        isOpen,
-        ...triggerProps,
-        ...overlayTriggerProps,
-        ...buttonProps,
-      });
-    }
-    return (
-      <DropdownButton
-        size={size}
-        isOpen={isOpen}
-        {...triggerProps}
-        {...overlayTriggerProps}
-        {...buttonProps}
-      >
-        {triggerLabel ?? defaultTriggerLabel}
-      </DropdownButton>
-    );
-  }
-
-  const theme = useTheme();
-  const maxMenuHeight = useMemo(
-    () =>
-      overlayProps.style?.maxHeight
-        ? Math.min(
-            typeof overlayProps.style?.maxHeight === 'number'
-              ? overlayProps.style?.maxHeight
-              : Infinity,
-            maxMenuHeightProp
-          )
-        : maxMenuHeightProp,
-    [overlayProps, maxMenuHeightProp]
-  );
-  function renderMenu() {
-    if (!isOpen) {
-      return null;
-    }
-
-    return (
-      <FocusScope restoreFocus autoFocus>
-        <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayProps}>
-          <StyledOverlay minWidth={triggerWidth}>
-            <SelectControl
-              components={{
-                Control: CompactSelectControl,
-                ClearIndicator: null,
-              }}
-              {...props}
-              options={options}
-              value={valueProp ?? internalValue}
-              multiple={multiple}
-              onChange={onValueChange}
-              size={size}
-              menuTitle={menuTitle}
-              placeholder={placeholder}
-              isSearchable={isSearchable}
-              maxMenuHeight={maxMenuHeight}
-              menuPlacement="bottom"
-              menuIsOpen
-              isCompact
-              controlShouldRenderValue={false}
-              hideSelectedOptions={false}
-              menuShouldScrollIntoView={false}
-              blurInputOnSelect={false}
-              closeMenuOnSelect={false}
-              closeMenuOnScroll={false}
-              openMenuOnFocus
-            />
-          </StyledOverlay>
-        </PositionWrapper>
-      </FocusScope>
-    );
-  }
-
-  return (
-    <MenuControlWrap className={className} as={renderWrapAs} role="presentation">
-      {renderTrigger()}
-      {renderMenu()}
-    </MenuControlWrap>
-  );
-}
-
-export default CompactSelect;
-
-const MenuControlWrap = styled('div')``;
-
-const ButtonLabel = styled('span')`
-  ${p => p.theme.overflowEllipsis}
-  text-align: left;
-`;
-
-const StyledBadge = styled(Badge)`
-  flex-shrink: 0;
-  top: auto;
-`;
-
-const StyledOverlay = styled(Overlay, {
-  shouldForwardProp: prop => typeof prop === 'string' && isPropValid(prop),
-})<{minWidth?: number}>`
-  overflow: hidden;
-  ${p => p.minWidth && `min-width: ${p.minWidth}px;`}
-`;
-
-const MenuHeader = styled('div')`
-  position: relative;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: ${space(0.25)} ${space(1)} ${space(0.25)} ${space(1.5)};
-  box-shadow: 0 1px 0 ${p => p.theme.translucentInnerBorder};
-  z-index: 2;
-`;
-
-const MenuTitle = styled('span')`
-  font-weight: 600;
-  font-size: ${p => p.theme.fontSizeSmall};
-  color: ${p => p.theme.headingColor};
-  white-space: nowrap;
-  margin: ${space(0.5)} ${space(2)} ${space(0.5)} 0;
-`;
-
-const StyledLoadingIndicator = styled(LoadingIndicator)`
-  && {
-    margin: ${space(0.5)} ${space(0.5)} ${space(0.5)} ${space(1)};
-    height: ${space(1.5)};
-    width: ${space(1.5)};
-  }
-`;
-
-const ClearButton = styled(Button)`
-  font-size: ${p => p.theme.fontSizeSmall};
-  color: ${p => p.theme.subText};
-`;

+ 245 - 0
static/app/components/compactSelect/composite.spec.tsx

@@ -0,0 +1,245 @@
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import CompositeSelect from 'sentry/components/compactSelect/composite';
+
+describe('CompactSelect', function () {
+  it('renders', function () {
+    const {container} = render(
+      <CompositeSelect menuTitle="Menu title">
+        <CompositeSelect.Region
+          label="Region 1"
+          defaultValue="choice_one"
+          onChange={() => {}}
+          options={[
+            {value: 'choice_one', label: 'Choice One'},
+            {value: 'choice_two', label: 'Choice Two'},
+          ]}
+        />
+        <CompositeSelect.Region
+          multiple
+          label="Region 2"
+          defaultValue={['choice_three', 'choice_four']}
+          onChange={() => {}}
+          options={[
+            {value: 'choice_three', label: 'Choice Three'},
+            {value: 'choice_four', label: 'Choice Four'},
+          ]}
+        />
+      </CompositeSelect>
+    );
+
+    expect(container).toSnapshot();
+
+    // Trigger button
+    const triggerButton = screen.getByRole('button', {expanded: false});
+    expect(triggerButton).toBeInTheDocument();
+    userEvent.click(triggerButton);
+    expect(triggerButton).toHaveAttribute('aria-expanded', 'true');
+
+    // Menu title
+    expect(screen.getByText('Menu title')).toBeInTheDocument();
+
+    // Region 1
+    expect(screen.getByRole('listbox', {name: 'Region 1'})).toBeInTheDocument();
+    expect(screen.getByRole('option', {name: 'Choice One'})).toBeInTheDocument();
+    expect(screen.getByRole('option', {name: 'Choice One'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByRole('option', {name: 'Choice Two'})).toBeInTheDocument();
+
+    // Region 2
+    expect(screen.getByRole('listbox', {name: 'Region 2'})).toBeInTheDocument();
+    expect(screen.getByRole('listbox', {name: 'Region 2'})).toHaveAttribute(
+      'aria-multiselectable',
+      'true'
+    );
+    expect(screen.getByRole('option', {name: 'Choice Three'})).toBeInTheDocument();
+    expect(screen.getByRole('option', {name: 'Choice Three'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByRole('option', {name: 'Choice Four'})).toBeInTheDocument();
+    expect(screen.getByRole('option', {name: 'Choice Four'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+  });
+
+  it('renders disabled trigger button', function () {
+    render(
+      <CompositeSelect disabled>
+        <CompositeSelect.Region
+          label="Region 1"
+          onChange={() => {}}
+          options={[
+            {value: 'choice_one', label: 'Choice One'},
+            {value: 'choice_two', label: 'Choice Two'},
+          ]}
+        />
+      </CompositeSelect>
+    );
+    expect(screen.getByRole('button')).toBeDisabled();
+  });
+
+  // CompositeSelect renders a series of separate list boxes, each of which has its own
+  // focus state. This test ensures that focus moves seamlessly between regions.
+  it('manages focus between regions', async function () {
+    render(
+      <CompositeSelect>
+        <CompositeSelect.Region
+          label="Region 1"
+          onChange={() => {}}
+          options={[
+            {value: 'choice_one', label: 'Choice One'},
+            {value: 'choice_two', label: 'Choice Two'},
+          ]}
+        />
+        <CompositeSelect.Region
+          multiple
+          label="Region 2"
+          onChange={() => {}}
+          options={[
+            {value: 'choice_three', label: 'Choice Three'},
+            {value: 'choice_four', label: 'Choice Four'},
+          ]}
+        />
+      </CompositeSelect>
+    );
+
+    // click on the trigger button
+    userEvent.click(screen.getByRole('button'));
+
+    // first option is focused
+    await waitFor(() =>
+      expect(screen.getByRole('option', {name: 'Choice One'})).toHaveFocus()
+    );
+
+    // press arrow down and second option gets focus
+    userEvent.keyboard('{ArrowDown}');
+    expect(screen.getByRole('option', {name: 'Choice Two'})).toHaveFocus();
+
+    // press arrow down again and third option in the second region gets focus
+    userEvent.keyboard('{ArrowDown}');
+    expect(screen.getByRole('option', {name: 'Choice Three'})).toHaveFocus();
+
+    // press arrow up and second option in the first region gets focus
+    userEvent.keyboard('{ArrowUp}');
+    expect(screen.getByRole('option', {name: 'Choice Two'})).toHaveFocus();
+
+    // press arrow down 3 times and focus moves to the third and fourth option, before
+    // wrapping back to the first option
+    userEvent.keyboard('{ArrowDown>3}');
+    expect(screen.getByRole('option', {name: 'Choice One'})).toHaveFocus();
+  });
+
+  it('has separate, self-contained select regions', function () {
+    const region1Mock = jest.fn();
+    const region2Mock = jest.fn();
+    render(
+      <CompositeSelect>
+        <CompositeSelect.Region
+          label="Region 1"
+          onChange={region1Mock}
+          options={[
+            {value: 'choice_one', label: 'Choice One'},
+            {value: 'choice_two', label: 'Choice Two'},
+          ]}
+        />
+        <CompositeSelect.Region
+          multiple
+          label="Region 2"
+          onChange={region2Mock}
+          options={[
+            {value: 'choice_three', label: 'Choice Three'},
+            {value: 'choice_four', label: 'Choice Four'},
+          ]}
+        />
+      </CompositeSelect>
+    );
+
+    // click on the trigger button
+    userEvent.click(screen.getByRole('button'));
+
+    // select Choice One
+    userEvent.click(screen.getByRole('option', {name: 'Choice One'}));
+
+    // Region 1's callback is called, and trigger label is updated
+    expect(region1Mock).toHaveBeenCalledWith('choice_one');
+    expect(screen.getByRole('button', {name: 'Choice One'})).toBeInTheDocument();
+
+    // open the menu again
+    userEvent.click(screen.getByRole('button'));
+
+    // in the first region, only Choice One is selected
+    expect(screen.getByRole('option', {name: 'Choice One'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByRole('option', {name: 'Choice Two'})).toHaveAttribute(
+      'aria-selected',
+      'false'
+    );
+
+    // the second region isn't affected, nothing is selected
+    expect(screen.getByRole('option', {name: 'Choice Three'})).toHaveAttribute(
+      'aria-selected',
+      'false'
+    );
+    expect(screen.getByRole('option', {name: 'Choice Four'})).toHaveAttribute(
+      'aria-selected',
+      'false'
+    );
+
+    // select Choice Three
+    userEvent.click(screen.getByRole('option', {name: 'Choice Three'}));
+
+    // Choice Three is marked as selected, callback is called, and trigger button updated
+    expect(screen.getByRole('option', {name: 'Choice Three'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(region2Mock).toHaveBeenCalledWith(['choice_three']);
+    expect(screen.getByRole('button', {name: 'Choice One +1'})).toBeInTheDocument();
+  });
+
+  it('can search', function () {
+    render(
+      <CompositeSelect isSearchable placeholder="Search placeholder…">
+        <CompositeSelect.Region
+          label="Region 1"
+          onChange={() => {}}
+          options={[
+            {value: 'choice_one', label: 'Choice One'},
+            {value: 'choice_two', label: 'Choice Two'},
+          ]}
+        />
+        <CompositeSelect.Region
+          multiple
+          label="Region 2"
+          onChange={() => {}}
+          options={[
+            {value: 'choice_three', label: 'Choice Three'},
+            {value: 'choice_four', label: 'Choice Four'},
+          ]}
+        />
+      </CompositeSelect>
+    );
+
+    // click on the trigger button
+    userEvent.click(screen.getByRole('button'));
+
+    // type 'Two' into the search box
+    userEvent.click(screen.getByPlaceholderText('Search placeholder…'));
+    userEvent.keyboard('Two');
+
+    // only Option Two should be available
+    expect(screen.getByRole('option', {name: 'Choice Two'})).toBeInTheDocument();
+    expect(screen.queryByRole('option', {name: 'Choice One'})).not.toBeInTheDocument();
+    expect(screen.queryByRole('option', {name: 'Choice Three'})).not.toBeInTheDocument();
+    expect(screen.queryByRole('option', {name: 'Choice Four'})).not.toBeInTheDocument();
+
+    // Region 2's label isn't rendered because the region is empty
+    expect(screen.queryByRole('Region 2')).not.toBeInTheDocument();
+  });
+});

+ 208 - 0
static/app/components/compactSelect/composite.tsx

@@ -0,0 +1,208 @@
+import {Children, useMemo} from 'react';
+import styled from '@emotion/styled';
+import {FocusScope} from '@react-aria/focus';
+import {Item} from '@react-stately/collections';
+
+import space from 'sentry/styles/space';
+
+import {Control, ControlProps} from './control';
+import {ListBox, MultipleListBoxProps, SingleListBoxProps} from './listBox';
+import {SelectOption} from './types';
+
+interface BaseCompositeSelectRegion<Value extends React.Key> {
+  options: SelectOption<Value>[];
+  key?: React.Key;
+  label?: React.ReactNode;
+}
+
+/**
+ * A single-selection (only one option can be selected at a time) "region" inside a
+ * composite select. Each "region" is a separated, self-contained select box (each
+ * renders as a list box with its own list state) whose selection values don't interfere
+ * with one another.
+ */
+export interface SingleCompositeSelectRegion<Value extends React.Key>
+  extends BaseCompositeSelectRegion<Value>,
+    Omit<
+      SingleListBoxProps<Value>,
+      'children' | 'items' | 'compositeIndex' | 'size' | 'onChange'
+    > {
+  onChange: (value: Value) => void;
+}
+
+/**
+ * A multiple-selection (multiple options can be selected at the same time) "region"
+ * inside a composite select. Each "region" is a separated, self-contained select box
+ * (each renders as a list box with its own list state) whose selection values don't
+ * interfere with one another.
+ */
+export interface MultipleCompositeSelectRegion<Value extends React.Key>
+  extends BaseCompositeSelectRegion<Value>,
+    Omit<
+      MultipleListBoxProps<Value>,
+      'children' | 'items' | 'compositeIndex' | 'size' | 'onChange'
+    > {
+  onChange: (values: Value[]) => void;
+}
+
+/**
+ * A "region" inside a composite select. Each "region" is a separated, self-contained
+ * select box (each renders as a list box with its own list state) whose selection
+ * values don't interfere with one another.
+ */
+export type CompositeSelectRegion<Value extends React.Key> =
+  | SingleCompositeSelectRegion<Value>
+  | MultipleCompositeSelectRegion<Value>;
+
+/**
+ * A React child inside CompositeSelect. This helps ensure that the only non-falsy child
+ * allowed inside CompositeSelect is CompositeSelect.Region
+ */
+type CompositeSelectChild =
+  | React.ReactElement<CompositeSelectRegion<React.Key>>
+  | false
+  | null
+  | undefined;
+
+export interface CompositeSelectProps extends ControlProps {
+  /**
+   * The "regions" inside this composite selector. Each region functions as a separated,
+   * self-contained select box (each renders as a list box with its own list state)
+   * whose values don't interfere with one another.
+   */
+  children: CompositeSelectChild | CompositeSelectChild[];
+  /**
+   * Whether to close the menu upon selection. This prop applies to the entire selector
+   * and functions as a fallback value. Each composite region also accepts the same
+   * prop, which will take precedence over this one.
+   */
+  closeOnSelect?: SingleListBoxProps<React.Key>['closeOnSelect'];
+}
+
+/**
+ * Flexible select component with a customizable trigger button
+ */
+function CompositeSelect({
+  children,
+  // Control props
+  disabled,
+  size = 'md',
+  closeOnSelect,
+  ...controlProps
+}: CompositeSelectProps) {
+  return (
+    <Control {...controlProps} size={size} disabled={disabled}>
+      <FocusScope>
+        <RegionsWrap>
+          {Children.map(children, (child, index) => {
+            if (!child) {
+              return null;
+            }
+
+            return (
+              <Region
+                {...child.props}
+                size={size}
+                compositeIndex={index}
+                closeOnSelect={child.props.closeOnSelect ?? closeOnSelect}
+              />
+            );
+          })}
+        </RegionsWrap>
+      </FocusScope>
+    </Control>
+  );
+}
+
+/**
+ * A "region" inside composite selectors. Each "region" is a separated, self-contained
+ * select box (each renders as a list box with its own list state) whose selection
+ * values don't interfere with one another.
+ */
+CompositeSelect.Region = function <Value extends React.Key>(
+  _props: CompositeSelectRegion<Value>
+) {
+  // This pseudo-component not meant to be rendered. It only functions as a props vessel
+  // and composable child to `CompositeSelect`. `CompositeSelect` iterates over all child
+  // instances of `CompositeSelect.Region` and renders `Region` with the specified props.
+  return null;
+};
+
+export default CompositeSelect;
+
+type RegionProps<Value extends React.Key> = CompositeSelectRegion<Value> & {
+  compositeIndex: SingleListBoxProps<Value>['compositeIndex'];
+  size: SingleListBoxProps<Value>['size'];
+};
+
+function Region<Value extends React.Key>({
+  options,
+  value,
+  defaultValue,
+  onChange,
+  multiple,
+  disallowEmptySelection,
+  isOptionDisabled,
+  closeOnSelect,
+  size,
+  compositeIndex,
+  label,
+  ...props
+}: RegionProps<Value>) {
+  // Combine list box props into an object with two clearly separated types, one where
+  // `multiple` is true and the other where it's not. Necessary to avoid TS errors.
+  const listBoxProps = useMemo(() => {
+    if (multiple) {
+      return {
+        multiple,
+        value,
+        defaultValue,
+        closeOnSelect,
+        onChange: opts => onChange?.(opts.map(opt => opt.value)),
+      };
+    }
+    return {
+      multiple,
+      value,
+      defaultValue,
+      closeOnSelect,
+      onChange: opt => onChange?.(opt ? opt.value : null),
+    };
+  }, [multiple, value, defaultValue, onChange, closeOnSelect]);
+
+  const optionsWithKey = useMemo<SelectOption<Value>[]>(
+    () => options.map(item => ({...item, key: item.value})),
+    [options]
+  );
+
+  return (
+    <ListBox
+      {...props}
+      {...listBoxProps}
+      items={optionsWithKey}
+      disallowEmptySelection={disallowEmptySelection}
+      isOptionDisabled={isOptionDisabled}
+      shouldFocusWrap={false}
+      compositeIndex={compositeIndex}
+      size={size}
+      label={label}
+    >
+      {(opt: SelectOption<Value>) => (
+        <Item key={opt.value} {...opt}>
+          {opt.label}
+        </Item>
+      )}
+    </ListBox>
+  );
+}
+
+const RegionsWrap = styled('div')`
+  min-height: 0;
+  overflow: auto;
+  padding: ${space(0.5)} 0;
+
+  /* Remove padding inside list boxes */
+  > ul {
+    padding: 0;
+  }
+`;

+ 536 - 0
static/app/components/compactSelect/control.tsx

@@ -0,0 +1,536 @@
+import {createContext, Fragment, useCallback, useMemo, useState} from 'react';
+import isPropValid from '@emotion/is-prop-valid';
+import {useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+import {FocusScope} from '@react-aria/focus';
+import {useKeyboard} from '@react-aria/interactions';
+import {AriaPositionProps} from '@react-aria/overlays';
+import {mergeProps} from '@react-aria/utils';
+import {ListState} from '@react-stately/list';
+import {OverlayTriggerState} from '@react-stately/overlays';
+
+import Badge from 'sentry/components/badge';
+import {Button} from 'sentry/components/button';
+import DropdownButton, {DropdownButtonProps} from 'sentry/components/dropdownButton';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {Overlay, PositionWrapper} from 'sentry/components/overlay';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {defined} from 'sentry/utils';
+import {FormSize} from 'sentry/utils/theme';
+import useOverlay, {UseOverlayProps} from 'sentry/utils/useOverlay';
+
+import {SelectOption} from './types';
+
+export interface SelectContextValue {
+  /**
+   * Filter function to determine whether an option should be rendered in the list box.
+   * A true return value means the option should be rendered. This function is
+   * automatically updated based on the current search string.
+   */
+  filterOption: (opt: SelectOption<React.Key>) => boolean;
+  overlayIsOpen: boolean;
+  /**
+   * Function to be called once when a list box is initialized, to register its list
+   * state in SelectContext. In composite selectors, where there can be multiple list
+   * boxes, the `index` parameter is the list box's index number (the order in which it
+   * appears). In non-composite selectors, where there's only one list box, that list
+   * box's index is 0.
+   */
+  registerListState: (index: number, listState: ListState<any>) => void;
+  /**
+   * Function to be called when a list box's selection state changes. We need a complete
+   * list of all selected options to label the trigger button. The `index` parameter
+   * indentifies the list box, in the same way as in `registerListState`.
+   */
+  saveSelectedOptions: (
+    index: number,
+    newSelectedOptions: SelectOption<React.Key> | SelectOption<React.Key>[]
+  ) => void;
+  /**
+   * The control's overlay state. Useful for opening/closing the menu from inside the
+   * selector.
+   */
+  overlayState?: OverlayTriggerState;
+}
+
+export const SelectContext = createContext<SelectContextValue>({
+  registerListState: () => {},
+  saveSelectedOptions: () => {},
+  filterOption: () => true,
+  overlayIsOpen: false,
+});
+
+export interface ControlProps extends UseOverlayProps {
+  children?: React.ReactNode;
+  className?: string;
+  disabled?: boolean;
+  /**
+   * If true, there will be a "Clear" button in the menu header.
+   */
+  isClearable?: boolean;
+  /**
+   * If true, there will be a loading indicator in the menu header.
+   */
+  isLoading?: boolean;
+  /**
+   * If true, there will be a search box on top of the menu, useful for quickly finding
+   * menu items.
+   */
+  isSearchable?: boolean;
+  maxMenuHeight?: number | string;
+  maxMenuWidth?: number | string;
+  /**
+   * Title to display in the menu's header. Keep the title as short as possible.
+   */
+  menuTitle?: React.ReactNode;
+  menuWidth?: number | string;
+  /**
+   * Called when the clear button is clicked (applicable only when `isClearable` is
+   * true).
+   */
+  onClear?: () => void;
+  /**
+   * Called when the search input's value changes (applicable only when `isSearchable`
+   * is true).
+   */
+  onInputChange?: (value: string) => void;
+  /**
+   * The search input's placeholder text (applicable only when `isSearchable` is true).
+   */
+  placeholder?: string;
+  /**
+   * Position of the overlay menu relative to the trigger button. Allowed for backward
+   * compatibility only. Use the `position` prop instead.
+   * @deprecated
+   */
+  placement?: AriaPositionProps['placement'];
+  size?: FormSize;
+  /**
+   * Optional replacement for the default trigger button. Note that the replacement must
+   * forward `props` and `ref` its outer wrap, otherwise many accessibility features
+   * won't work correctly.
+   */
+  trigger?: (args: {
+    props: Omit<DropdownButtonProps, 'children'>;
+    ref: React.RefObject<HTMLButtonElement>;
+  }) => React.ReactNode;
+  /**
+   * Label text inside the default trigger button. This is optional — by default the
+   * selected option's label will be used.
+   */
+  triggerLabel?: React.ReactNode;
+  /**
+   * Props to be passed to the default trigger button.
+   */
+  triggerProps?: DropdownButtonProps;
+}
+
+/**
+ * Controls Select's open state and exposes SelectContext to all chidlren.
+ */
+export function Control({
+  // Control props
+  trigger,
+  triggerLabel: triggerLabelProp,
+  triggerProps,
+  isOpen,
+  onClose,
+  disabled,
+  position = 'bottom-start',
+  placement,
+  offset,
+  menuTitle,
+  maxMenuHeight = '32rem',
+  maxMenuWidth,
+  menuWidth,
+
+  // Select props
+  size = 'md',
+  isSearchable = false,
+  placeholder = 'Search…',
+  onInputChange,
+  isClearable = false,
+  onClear,
+  isLoading = false,
+  children,
+  ...wrapperProps
+}: ControlProps) {
+  // Set up list states (in composite selects, each region has its own state, that way
+  // selection values are contained within each region).
+  const [listStates, setListStates] = useState<ListState<any>[]>([]);
+  const registerListState = useCallback<SelectContextValue['registerListState']>(
+    (index, listState) => {
+      setListStates(current => [
+        ...current.slice(0, index),
+        listState,
+        ...current.slice(index + 1),
+      ]);
+    },
+    []
+  );
+
+  /**
+   * Search/filter value, used to filter out the list of displayed elements
+   */
+  const [search, setSearch] = useState('');
+  const updateSearch = useCallback(
+    (newValue: string) => {
+      setSearch(newValue);
+      onInputChange?.(newValue);
+    },
+    [onInputChange]
+  );
+  const filterOption = useCallback<SelectContextValue['filterOption']>(
+    opt =>
+      String(opt.label ?? '')
+        .toLowerCase()
+        .includes(search.toLowerCase()),
+    [search]
+  );
+
+  const {keyboardProps: searchKeyboardProps} = useKeyboard({
+    onKeyDown: e => {
+      // When the search input is focused, and the user presses Arrow Down,
+      // we should move the focus to the menu items list.
+      if (e.key === 'ArrowDown') {
+        e.preventDefault(); // Prevent scroll action
+        overlayRef.current?.querySelector<HTMLLIElement>('li[role="option"]')?.focus();
+      }
+
+      // Continue propagation, otherwise the overlay won't close on Esc key press
+      e.continuePropagation();
+    },
+  });
+
+  /**
+   * Clears selection values across all list box states
+   */
+  const clearSelection = useCallback(() => {
+    listStates.forEach(listState => listState.selectionManager.clearSelection());
+    onClear?.();
+  }, [onClear, listStates]);
+
+  // Get overlay props. We need to support both the `position` and `placement` props for
+  // backward compatibility. TODO: convert existing usages from `placement` to `position`
+  const overlayPosition = useMemo(
+    () =>
+      position ??
+      placement
+        ?.split(' ')
+        .map(key => {
+          switch (key) {
+            case 'right':
+              return 'end';
+            case 'left':
+              return 'start';
+            default:
+              return key;
+          }
+        })
+        .join('-'),
+    [position, placement]
+  );
+  const {
+    isOpen: overlayIsOpen,
+    state: overlayState,
+    triggerRef,
+    triggerProps: overlayTriggerProps,
+    overlayRef,
+    overlayProps,
+  } = useOverlay({
+    type: 'listbox',
+    position: overlayPosition,
+    offset,
+    isOpen,
+    onOpenChange: async open => {
+      // On open
+      if (open) {
+        // Wait for overlay to appear/disappear
+        await new Promise(resolve => resolve(null));
+
+        const firstSelectedOption = overlayRef.current?.querySelector<HTMLLIElement>(
+          'li[role="option"][aria-selected="true"]'
+        );
+
+        // Focus on first selected item
+        if (firstSelectedOption) {
+          firstSelectedOption.focus();
+          return;
+        }
+
+        // If no item is selected, focus on first item instead
+        overlayRef.current?.querySelector<HTMLLIElement>('li[role="option"]')?.focus();
+        return;
+      }
+
+      // On close
+      onClose?.();
+      setSearch(''); // Clear search string
+
+      // Wait for overlay to appear/disappear
+      await new Promise(resolve => resolve(null));
+      triggerRef.current?.focus();
+    },
+  });
+
+  /**
+   * A list of selected options across all select regions, to be used to generate the
+   * trigger label.
+   */
+  const [selectedOptions, setSelectedOptions] = useState<
+    Array<SelectOption<React.Key> | SelectOption<React.Key>[]>
+  >([]);
+  const saveSelectedOptions = useCallback<SelectContextValue['saveSelectedOptions']>(
+    (index, newSelectedOptions) => {
+      setSelectedOptions(current => [
+        ...current.slice(0, index),
+        newSelectedOptions,
+        ...current.slice(index + 1),
+      ]);
+    },
+    []
+  );
+
+  /**
+   * Trigger label, generated from current selection values. If more than one option is
+   * selected, then a count badge will appear.
+   */
+  const triggerLabel: React.ReactNode = useMemo(() => {
+    if (defined(triggerLabelProp)) {
+      return triggerLabelProp;
+    }
+
+    const options = selectedOptions.flat().filter(Boolean);
+
+    if (options.length === 0) {
+      return <TriggerLabel>{t('None')}</TriggerLabel>;
+    }
+
+    return (
+      <Fragment>
+        <TriggerLabel>{options[0]?.label}</TriggerLabel>
+        {options.length > 1 && <StyledBadge text={`+${options.length - 1}`} />}
+      </Fragment>
+    );
+  }, [triggerLabelProp, selectedOptions]);
+
+  const {keyboardProps: triggerKeyboardProps} = useKeyboard({
+    onKeyDown: e => {
+      // Open the select menu when user presses Arrow Up/Down.
+      if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+        e.preventDefault(); // Prevent scroll
+        overlayState.open();
+      }
+    },
+  });
+
+  const contextValue = useMemo(
+    () => ({
+      registerListState,
+      saveSelectedOptions,
+      overlayState,
+      overlayIsOpen,
+      filterOption,
+    }),
+    [registerListState, saveSelectedOptions, overlayState, overlayIsOpen, filterOption]
+  );
+
+  const theme = useTheme();
+  return (
+    <SelectContext.Provider value={contextValue}>
+      <ControlWrap {...wrapperProps}>
+        {trigger ? (
+          trigger(
+            mergeProps(triggerProps, triggerKeyboardProps, overlayTriggerProps, {
+              size,
+              disabled,
+              isOpen: overlayIsOpen,
+            })
+          )
+        ) : (
+          <DropdownButton
+            size={size}
+            {...mergeProps(triggerProps, triggerKeyboardProps, overlayTriggerProps)}
+            isOpen={overlayIsOpen}
+            disabled={disabled}
+          >
+            {triggerLabel}
+          </DropdownButton>
+        )}
+        <StyledPositionWrapper
+          zIndex={theme.zIndex.tooltip}
+          visible={overlayIsOpen}
+          {...overlayProps}
+        >
+          <StyledOverlay
+            width={menuWidth}
+            maxWidth={maxMenuWidth}
+            maxHeight={overlayProps.style.maxHeight}
+            maxHeightProp={maxMenuHeight}
+          >
+            <FocusScope contain={overlayIsOpen}>
+              {(menuTitle || isClearable) && (
+                <MenuHeader size={size} data-header>
+                  <MenuTitle>{menuTitle}</MenuTitle>
+                  <MenuHeaderTrailingItems>
+                    {isLoading && <StyledLoadingIndicator size={12} mini />}
+                    {isClearable && (
+                      <ClearButton onClick={clearSelection} size="zero" borderless>
+                        {t('Clear')}
+                      </ClearButton>
+                    )}
+                  </MenuHeaderTrailingItems>
+                </MenuHeader>
+              )}
+              {isSearchable && (
+                <SearchInput
+                  placeholder={placeholder}
+                  value={search}
+                  onChange={e => updateSearch(e.target.value)}
+                  visualSize={size}
+                  {...searchKeyboardProps}
+                />
+              )}
+              <OptionsWrap>{children}</OptionsWrap>
+            </FocusScope>
+          </StyledOverlay>
+        </StyledPositionWrapper>
+      </ControlWrap>
+    </SelectContext.Provider>
+  );
+}
+
+const ControlWrap = styled('div')`
+  position: relative;
+  width: max-content;
+`;
+
+const TriggerLabel = styled('span')`
+  ${p => p.theme.overflowEllipsis}
+  text-align: left;
+`;
+
+const StyledBadge = styled(Badge)`
+  flex-shrink: 0;
+  top: auto;
+`;
+
+const headerVerticalPadding: Record<FormSize, string> = {
+  xs: space(0.25),
+  sm: space(0.5),
+  md: space(0.75),
+};
+const MenuHeader = styled('div')<{size: FormSize}>`
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: ${p => headerVerticalPadding[p.size]} ${space(1)}
+    ${p => headerVerticalPadding[p.size]} ${space(1.5)};
+  box-shadow: 0 1px 0 ${p => p.theme.translucentInnerBorder};
+  line-height: ${p => p.theme.text.lineHeightBody};
+  z-index: 2;
+
+  font-size: ${p =>
+    p.size !== 'xs' ? p.theme.fontSizeSmall : p.theme.fontSizeExtraSmall};
+  color: ${p => p.theme.headingColor};
+`;
+
+const MenuHeaderTrailingItems = styled('div')`
+  display: grid;
+  grid-auto-flow: column;
+  gap: ${space(0.5)};
+`;
+
+const MenuTitle = styled('span')`
+  font-size: inherit; /* Inherit font size from MenuHeader */
+  font-weight: 600;
+  white-space: nowrap;
+  margin-right: ${space(2)};
+`;
+
+const StyledLoadingIndicator = styled(LoadingIndicator)`
+  && {
+    margin: ${space(0.5)} ${space(0.5)} ${space(0.5)} ${space(1)};
+    height: ${space(1)};
+    width: ${space(1)};
+  }
+`;
+
+const ClearButton = styled(Button)`
+  font-size: inherit; /* Inherit font size from MenuHeader */
+  color: ${p => p.theme.subText};
+  padding: 0 ${space(0.25)};
+  margin: 0 -${space(0.25)};
+`;
+
+const searchVerticalPadding: Record<FormSize, string> = {
+  xs: space(0.25),
+  sm: space(0.5),
+  md: space(0.5),
+};
+const SearchInput = styled('input')<{visualSize: FormSize}>`
+  appearance: none;
+  width: calc(100% - ${space(0.5)} * 2);
+  border: solid 1px ${p => p.theme.innerBorder};
+  border-radius: ${p => p.theme.borderRadius};
+  background: ${p => p.theme.backgroundSecondary};
+  font-size: ${p =>
+    p.visualSize !== 'xs' ? p.theme.fontSizeMedium : p.theme.fontSizeSmall};
+
+  /* Subtract 1px to account for border width */
+  padding: ${p => searchVerticalPadding[p.visualSize]} calc(${space(1)} - 1px);
+  margin: ${space(0.5)} ${space(0.5)};
+
+  /* Add 1px to top margin if immediately preceded by menu header, to account for the
+  header's shadow border */
+  div[data-header] + & {
+    margin-top: calc(${space(0.5)} + 1px);
+  }
+
+  &:focus,
+  &.focus-visible {
+    outline: none;
+    border-color: ${p => p.theme.focusBorder};
+    box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px;
+    background: transparent;
+  }
+`;
+
+const withUnits = value => (typeof value === 'string' ? value : `${value}px`);
+
+const StyledOverlay = styled(Overlay, {
+  shouldForwardProp: prop => isPropValid(prop),
+})<{
+  maxHeightProp: string | number;
+  maxHeight?: string | number;
+  maxWidth?: string | number;
+  width?: string | number;
+}>`
+  /* Should be a flex container so that when maxHeight is set (to avoid page overflow),
+  ListBoxWrap will also shrink to fit */
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+
+  max-height: ${p =>
+    p.maxHeight
+      ? `min(${withUnits(p.maxHeight)}, ${withUnits(p.maxHeightProp)})`
+      : withUnits(p.maxHeightProp)};
+  ${p => p.width && `width: ${withUnits(p.width)};`}
+  ${p => p.maxWidth && `max-width: ${withUnits(p.maxWidth)};`}
+`;
+
+const StyledPositionWrapper = styled(PositionWrapper, {
+  shouldForwardProp: prop => isPropValid(prop),
+})<{visible?: boolean}>`
+  min-width: 100%;
+  display: ${p => (p.visible ? 'block' : 'none')};
+`;
+
+const OptionsWrap = styled('div')`
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+`;

Some files were not shown because too many files changed in this diff