Browse Source

ref(compactSelect): Add optional grid structure (#45003)

## The Problem
Interactive elements inside select options (highlighted below) are not
reachable via keyboard. Only the options themselves can be toggled
on/off.
<img
src="https://user-images.githubusercontent.com/44172267/220724539-ed0f827e-9810-4ba0-a65b-56c48f2efe27.png"
width="400px" height="auto">

While generally we should lean toward simplicity and avoid such nested
interactive children, in some cases there is still a strong need for
them (e.g. in the page filters).

## The Solution
Luckily there is [a well-established, accessible pattern for such cases:
grid widgets.](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) Grids are
two-dimensional systems (think of spreadsheets) that allow for
navigation to constituent cells using the Arrow Up/Down/Left/Right keys.
Grid widgets need not be visually represented in tabular form. See the
linked documentation for a few examples of how grids can be used for a
variety of interactive elements, one of which can be a nested select
box.

`react-aria` supports grid widgets via the `useGridList()` hook, so we
don't need to re-implement it from scratch.

## Implementation
We can expose a boolean prop, `grid`, on `CompactSelect` to determine
whether it should render a grid list rather than a list box. The default
value should be `false` — we want to promote the simpler,
easier-to-navigate list box.

Structurally, the component tree will look like:
```jsx
<CompactSelect grid={…}>
  <List> {/* initializes `listState` and passes it to ListBox/GridList */}

    {/* if `grid` is false */}
    <ListBox>
      <ListBoxOption />
      <ListBoxOption />
    </ListBox>
    {/* endif */}

    {/* if `grid` is true */}
    <GridList>
      <GridListItem />
      <GridListItem />
    </GridList>
    {/* endif */}

  </List>
</CompactSelect>
```

## Result
The checkbox, settings button, and open button are reachable via the
Arrow Left/Right keys:

https://user-images.githubusercontent.com/44172267/220778283-27a9a84a-a075-421c-8852-c84d542eff31.mov

Accessibility tree:
<img width="558" alt="Screenshot 2023-02-22 at 2 53 16 PM"
src="https://user-images.githubusercontent.com/44172267/220779294-e480c33f-bf9f-46c0-8534-2c76ad8c32d7.png">
Vu Luong 2 years ago
parent
commit
534d08f9be

+ 1 - 0
package.json

@@ -25,6 +25,7 @@
     "@popperjs/core": "^2.11.5",
     "@react-aria/button": "^3.3.4",
     "@react-aria/focus": "^3.5.0",
+    "@react-aria/gridlist": "^3.1.2",
     "@react-aria/interactions": "^3.7.0",
     "@react-aria/listbox": "^3.5.1",
     "@react-aria/menu": "^3.3.0",

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

@@ -244,4 +244,68 @@ describe('CompactSelect', function () {
     // Region 2's label isn't rendered because the region is empty
     expect(screen.queryByRole('Region 2')).not.toBeInTheDocument();
   });
+
+  it('works with grid lists', async function () {
+    render(
+      <CompositeSelect grid>
+        <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"
+          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'));
+
+    // Region 1 is rendered & Choice One is selected
+    expect(screen.getByRole('grid', {name: 'Region 1'})).toBeInTheDocument();
+    expect(screen.getByRole('row', {name: 'Choice One'})).toBeInTheDocument();
+    await waitFor(() =>
+      expect(screen.getByRole('row', {name: 'Choice One'})).toHaveFocus()
+    );
+    expect(screen.getByRole('row', {name: 'Choice One'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByRole('row', {name: 'Choice Two'})).toBeInTheDocument();
+
+    // Region 2  is rendered
+    expect(screen.getByRole('grid', {name: 'Region 2'})).toBeInTheDocument();
+    expect(screen.getByRole('grid', {name: 'Region 2'})).toHaveAttribute(
+      'aria-multiselectable',
+      'true'
+    );
+    expect(screen.getByRole('row', {name: 'Choice Three'})).toBeInTheDocument();
+    expect(screen.getByRole('row', {name: 'Choice Four'})).toBeInTheDocument();
+
+    // Pressing Arrow Down twice moves focus to Choice Three
+    userEvent.keyboard('{ArrowDown>2}');
+    expect(screen.getByRole('row', {name: 'Choice Three'})).toHaveFocus();
+
+    // Pressing Enter selects Choice Three
+    userEvent.keyboard('{Enter}');
+    expect(screen.getByRole('row', {name: 'Choice Three'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+
+    // Pressing Arrow Down two more times loops focus back to Choice One
+    userEvent.keyboard('{ArrowDown>2}');
+    expect(screen.getByRole('row', {name: 'Choice One'})).toHaveFocus();
+  });
 });

+ 29 - 20
static/app/components/compactSelect/composite.tsx

@@ -6,7 +6,7 @@ import {Item} from '@react-stately/collections';
 import {space} from 'sentry/styles/space';
 
 import {Control, ControlProps} from './control';
-import {ListBox, MultipleListBoxProps, SingleListBoxProps} from './listBox';
+import {List, MultipleListProps, SingleListProps} from './list';
 import {SelectOption} from './types';
 
 interface BaseCompositeSelectRegion<Value extends React.Key> {
@@ -17,27 +17,33 @@ interface BaseCompositeSelectRegion<Value extends React.Key> {
 
 /**
  * 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
+ * composite select. Each "region" is a separated, self-contained selectable list (each
+ * renders as a `ul` 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'> {}
+    Omit<
+      SingleListProps<Value>,
+      'children' | 'items' | 'grid' | 'compositeIndex' | 'size'
+    > {}
 
 /**
  * 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
+ * inside a composite select. Each "region" is a separated, self-contained selectable
+ * list (each renders as a `ul` 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'> {}
+    Omit<
+      MultipleListProps<Value>,
+      'children' | 'items' | 'grid' | 'compositeIndex' | 'size'
+    > {}
 
 /**
  * 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
+ * selectable list (each renders as a `ul` with its own list state) whose selection
  * values don't interfere with one another.
  */
 export type CompositeSelectRegion<Value extends React.Key> =
@@ -57,7 +63,7 @@ type CompositeSelectChild =
 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)
+   * self-contained selectable list (each renders as a `ul` with its own list state)
    * whose values don't interfere with one another.
    */
   children: CompositeSelectChild | CompositeSelectChild[];
@@ -66,7 +72,7 @@ export interface CompositeSelectProps extends ControlProps {
    * 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'];
+  closeOnSelect?: SingleListProps<React.Key>['closeOnSelect'];
 }
 
 /**
@@ -75,13 +81,14 @@ export interface CompositeSelectProps extends ControlProps {
 function CompositeSelect({
   children,
   // Control props
+  grid,
   disabled,
   size = 'md',
   closeOnSelect,
   ...controlProps
 }: CompositeSelectProps) {
   return (
-    <Control {...controlProps} size={size} disabled={disabled}>
+    <Control {...controlProps} grid={grid} size={size} disabled={disabled}>
       <FocusScope>
         <RegionsWrap>
           {Children.map(children, (child, index) => {
@@ -92,6 +99,7 @@ function CompositeSelect({
             return (
               <Region
                 {...child.props}
+                grid={grid}
                 size={size}
                 compositeIndex={index}
                 closeOnSelect={child.props.closeOnSelect ?? closeOnSelect}
@@ -106,7 +114,7 @@ function CompositeSelect({
 
 /**
  * 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
+ * selectable list (each renders as a `ul` with its own list state) whose selection
  * values don't interfere with one another.
  */
 CompositeSelect.Region = function <Value extends React.Key>(
@@ -121,8 +129,9 @@ CompositeSelect.Region = function <Value extends React.Key>(
 export {CompositeSelect};
 
 type RegionProps<Value extends React.Key> = CompositeSelectRegion<Value> & {
-  compositeIndex: SingleListBoxProps<Value>['compositeIndex'];
-  size: SingleListBoxProps<Value>['size'];
+  compositeIndex: SingleListProps<Value>['compositeIndex'];
+  grid: SingleListProps<Value>['grid'];
+  size: SingleListProps<Value>['size'];
 };
 
 function Region<Value extends React.Key>({
@@ -139,9 +148,9 @@ function Region<Value extends React.Key>({
   label,
   ...props
 }: RegionProps<Value>) {
-  // Combine list box props into an object with two clearly separated types, one where
+  // Combine list 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(() => {
+  const listProps = useMemo(() => {
     if (multiple) {
       return {
         multiple,
@@ -166,9 +175,9 @@ function Region<Value extends React.Key>({
   );
 
   return (
-    <ListBox
+    <List
       {...props}
-      {...listBoxProps}
+      {...listProps}
       items={optionsWithKey}
       disallowEmptySelection={disallowEmptySelection}
       isOptionDisabled={isOptionDisabled}
@@ -182,7 +191,7 @@ function Region<Value extends React.Key>({
           {opt.label}
         </Item>
       )}
-    </ListBox>
+    </List>
   );
 }
 
@@ -191,7 +200,7 @@ const RegionsWrap = styled('div')`
   overflow: auto;
   padding: ${space(0.5)} 0;
 
-  /* Remove padding inside list boxes */
+  /* Remove padding inside lists */
   > ul {
     padding: 0;
   }

+ 30 - 15
static/app/components/compactSelect/control.tsx

@@ -23,24 +23,23 @@ 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
+   * Filter function to determine whether an option should be rendered in the select
+   * list. 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.
+   * Function to be called once when a list is initialized, to register its state in
+   * SelectContext. In composite selectors, where there can be multiple lists, the
+   * `index` parameter is the list's index number (the order in which it appears). In
+   * non-composite selectors, where there's only one list, that list'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
+   * Function to be called when a list'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`.
+   * indentifies the list, in the same way as in `registerListState`.
    */
   saveSelectedOptions: (
     index: number,
@@ -68,6 +67,17 @@ export interface ControlProps extends UseOverlayProps {
    */
   clearable?: boolean;
   disabled?: boolean;
+  /**
+   * Whether to render a grid list rather than a list box.
+   *
+   * Unlike list boxes, grid lists are two-dimensional. Users can press Arrow Up/Down to
+   * move between rows (options), and Arrow Left/Right to move between "columns". This
+   * is useful when the select options have smaller, interactive elements
+   * (buttons/links) inside. Grid lists allow users to focus on those child elements
+   * using the Arrow Left/Right keys and interact with them, which isn't possible with
+   * list boxes.
+   */
+  grid?: boolean;
   /**
    * If true, there will be a loading indicator in the menu header.
    */
@@ -145,6 +155,7 @@ export function Control({
   clearable = false,
   onClear,
   loading = false,
+  grid = false,
   children,
   ...wrapperProps
 }: ControlProps) {
@@ -187,7 +198,9 @@ export function Control({
       // 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();
+        overlayRef.current
+          ?.querySelector<HTMLLIElement>(`li[role="${grid ? 'row' : 'option'}"]`)
+          ?.focus();
       }
 
       // Continue propagation, otherwise the overlay won't close on Esc key press
@@ -196,7 +209,7 @@ export function Control({
   });
 
   /**
-   * Clears selection values across all list box states
+   * Clears selection values across all list states
    */
   const clearSelection = useCallback(() => {
     listStates.forEach(listState => listState.selectionManager.clearSelection());
@@ -212,7 +225,7 @@ export function Control({
     overlayRef,
     overlayProps,
   } = useOverlay({
-    type: 'listbox',
+    type: grid ? 'menu' : 'listbox',
     position,
     offset,
     isOpen,
@@ -223,7 +236,7 @@ export function Control({
         await new Promise(resolve => resolve(null));
 
         const firstSelectedOption = overlayRef.current?.querySelector<HTMLLIElement>(
-          'li[role="option"][aria-selected="true"]'
+          `li[role="${grid ? 'row' : 'option'}"][aria-selected="true"]`
         );
 
         // Focus on first selected item
@@ -233,7 +246,9 @@ export function Control({
         }
 
         // If no item is selected, focus on first item instead
-        overlayRef.current?.querySelector<HTMLLIElement>('li[role="option"]')?.focus();
+        overlayRef.current
+          ?.querySelector<HTMLLIElement>(`li[role="${grid ? 'row' : 'option'}"]`)
+          ?.focus();
         return;
       }
 
@@ -482,7 +497,7 @@ const StyledOverlay = styled(Overlay, {
   width?: string | number;
 }>`
   /* Should be a flex container so that when maxHeight is set (to avoid page overflow),
-  ListBoxWrap will also shrink to fit */
+  ListBoxWrap/GridListWrap will also shrink to fit */
   display: flex;
   flex-direction: column;
   overflow: hidden;

+ 113 - 0
static/app/components/compactSelect/gridList/index.tsx

@@ -0,0 +1,113 @@
+import {Fragment, useCallback, useContext, useRef} from 'react';
+import {AriaGridListOptions, useGridList} from '@react-aria/gridlist';
+import {mergeProps} from '@react-aria/utils';
+import {ListState} from '@react-stately/list';
+import {Node} from '@react-types/shared';
+
+import domId from 'sentry/utils/domId';
+import {FormSize} from 'sentry/utils/theme';
+
+import {SelectContext} from '../control';
+import {ListLabel, ListSeparator, ListWrap} from '../styles';
+
+import {GridListOption} from './option';
+import {GridListSection} from './section';
+
+interface GridListProps
+  extends React.HTMLAttributes<HTMLUListElement>,
+    Omit<
+      AriaGridListOptions<any>,
+      'disabledKeys' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange'
+    > {
+  /**
+   * Keyboard event handler, to be attached to the list (`ul`) element, to seamlessly
+   * move focus from one composite list to another when an arrow key is pressed. Returns
+   * a boolean indicating whether the keyboard event was intercepted. If yes, then no
+   * further callback function should be run.
+   */
+  keyDownHandler: (e: React.KeyboardEvent<HTMLUListElement>) => boolean;
+  /**
+   * Items to be rendered inside this grid list.
+   */
+  listItems: Node<any>[];
+  /**
+   * Object containing the selection state and focus position, needed for
+   * `useGridList()`.
+   */
+  listState: ListState<any>;
+  /**
+   * Text label to be rendered as heading on top of grid list.
+   */
+  label?: React.ReactNode;
+  size?: FormSize;
+}
+
+/**
+ * A grid list with accessibile behaviors & attributes.
+ * https://react-spectrum.adobe.com/react-aria/useGridList.html
+ *
+ * Unlike list boxes, grid lists are two-dimensional. Users can press Arrow Up/Down to
+ * move between rows (options), and Arrow Left/Right to move between "columns". This is
+ * useful when the select options have smaller, interactive elements (buttons/links)
+ * inside. Grid lists allow users to focus on those child elements (using the Arrow
+ * Left/Right keys) and interact with them, which isn't possible with list boxes.
+ */
+function GridList({
+  listItems,
+  listState,
+  size = 'md',
+  label,
+  keyDownHandler,
+  ...props
+}: GridListProps) {
+  const ref = useRef<HTMLUListElement>(null);
+  const labelId = domId('grid-label-');
+  const {gridProps} = useGridList(
+    {...props, 'aria-labelledby': label ? labelId : props['aria-labelledby']},
+    listState,
+    ref
+  );
+
+  const onKeyDown = useCallback<React.KeyboardEventHandler<HTMLUListElement>>(
+    e => {
+      const continueCallback = keyDownHandler?.(e);
+      // Prevent grid list from clearing value on Escape key press
+      continueCallback && e.key !== 'Escape' && gridProps.onKeyDown?.(e);
+    },
+    [keyDownHandler, gridProps]
+  );
+
+  const {overlayIsOpen} = useContext(SelectContext);
+  return (
+    <Fragment>
+      {listItems.length !== 0 && <ListSeparator role="separator" />}
+      {listItems.length !== 0 && label && <ListLabel id={labelId}>{label}</ListLabel>}
+      <ListWrap {...mergeProps(gridProps, props)} onKeyDown={onKeyDown} ref={ref}>
+        {overlayIsOpen &&
+          listItems.map(item => {
+            if (item.type === 'section') {
+              return (
+                <GridListSection
+                  key={item.key}
+                  node={item}
+                  listState={listState}
+                  size={size}
+                />
+              );
+            }
+
+            return (
+              <GridListOption
+                key={item.key}
+                node={item}
+                listState={listState}
+                size={size}
+              />
+            );
+          })}
+      </ListWrap>
+    </Fragment>
+  );
+}
+
+export {GridList};

+ 104 - 0
static/app/components/compactSelect/gridList/option.tsx

@@ -0,0 +1,104 @@
+import {Fragment, useRef, useState} from 'react';
+import {
+  AriaGridListItemOptions,
+  useGridListItem,
+  useGridListSelectionCheckbox,
+} from '@react-aria/gridlist';
+import {useFocusWithin, useHover} from '@react-aria/interactions';
+import {mergeProps} from '@react-aria/utils';
+import {ListState} from '@react-stately/list';
+import {Node} from '@react-types/shared';
+
+import Checkbox from 'sentry/components/checkbox';
+import MenuListItem from 'sentry/components/menuListItem';
+import {IconCheckmark} from 'sentry/icons';
+import {FormSize} from 'sentry/utils/theme';
+
+import {CheckWrap} from '../styles';
+
+interface GridListOptionProps extends AriaGridListItemOptions {
+  listState: ListState<any>;
+  node: Node<any>;
+  size: FormSize;
+}
+
+/**
+ * A <li /> element with accessibile behaviors & attributes.
+ * https://react-spectrum.adobe.com/react-aria/useGridList.html
+ */
+export function GridListOption({node, listState, size}: GridListOptionProps) {
+  const ref = useRef<HTMLLIElement>(null);
+  const {
+    label,
+    details,
+    leadingItems,
+    trailingItems,
+    priority,
+    tooltip,
+    tooltipOptions,
+    selectionMode,
+  } = node.props;
+  const multiple = selectionMode
+    ? selectionMode === 'multiple'
+    : listState.selectionManager.selectionMode === 'multiple';
+
+  const {rowProps, gridCellProps, isSelected, isDisabled} = useGridListItem(
+    {node, shouldSelectOnPressUp: true},
+    listState,
+    ref
+  );
+
+  const {
+    checkboxProps: {
+      isDisabled: _isDisabled,
+      isSelected: _isSelected,
+      onChange: _onChange,
+      ...checkboxProps
+    },
+  } = useGridListSelectionCheckbox({key: node.key}, listState);
+
+  // Move focus to this item on hover
+  const {hoverProps} = useHover({onHoverStart: () => ref.current?.focus()});
+
+  // Show focus effect when document focus is on or inside the item
+  const [isFocusWithin, setFocusWithin] = useState(false);
+  const {focusWithinProps} = useFocusWithin({onFocusWithinChange: setFocusWithin});
+
+  const checkboxSize = size === 'xs' ? 'xs' : 'sm';
+  return (
+    <MenuListItem
+      {...mergeProps(rowProps, focusWithinProps, hoverProps)}
+      ref={ref}
+      size={size}
+      label={label}
+      details={details}
+      disabled={isDisabled}
+      isFocused={isFocusWithin}
+      priority={priority ?? (isSelected && !multiple) ? 'primary' : 'default'}
+      innerWrapProps={gridCellProps}
+      labelProps={{as: typeof label === 'string' ? 'p' : 'div'}}
+      leadingItems={
+        <Fragment>
+          <CheckWrap multiple={multiple} isSelected={isSelected} role="presentation">
+            {multiple ? (
+              <Checkbox
+                {...checkboxProps}
+                size={checkboxSize}
+                checked={isSelected}
+                disabled={isDisabled}
+                readOnly
+              />
+            ) : (
+              isSelected && <IconCheckmark size={checkboxSize} {...checkboxProps} />
+            )}
+          </CheckWrap>
+          {leadingItems}
+        </Fragment>
+      }
+      trailingItems={trailingItems}
+      tooltip={tooltip}
+      tooltipOptions={tooltipOptions}
+      data-test-id={node.key}
+    />
+  );
+}

+ 62 - 0
static/app/components/compactSelect/gridList/section.tsx

@@ -0,0 +1,62 @@
+import {Fragment, useContext, useMemo} from 'react';
+import {useSeparator} from '@react-aria/separator';
+import {ListState} from '@react-stately/list';
+import {Node} from '@react-types/shared';
+
+import domId from 'sentry/utils/domId';
+import {FormSize} from 'sentry/utils/theme';
+
+import {SelectContext} from '../control';
+import {SectionGroup, SectionSeparator, SectionTitle, SectionWrap} from '../styles';
+
+import {GridListOption} from './option';
+
+interface GridListSectionProps {
+  listState: ListState<any>;
+  node: Node<any>;
+  size: FormSize;
+}
+
+/**
+ * A <li /> element that functions as a grid list section (renders a nested <ul />
+ * inside). https://react-spectrum.adobe.com/react-aria/useGridList.html
+ */
+export function GridListSection({node, listState, size}: GridListSectionProps) {
+  const titleId = domId('grid-section-title-');
+  const {separatorProps} = useSeparator({elementType: 'li'});
+
+  const {filterOption} = useContext(SelectContext);
+  const filteredOptions = useMemo(() => {
+    return [...node.childNodes].filter(child => {
+      return filterOption(child.props);
+    });
+  }, [node.childNodes, filterOption]);
+
+  return (
+    <Fragment>
+      <SectionSeparator {...separatorProps} />
+      <SectionWrap
+        role="rowgroup"
+        {...(node['aria-label']
+          ? {'aria-label': node['aria-label']}
+          : {'aria-labelledby': titleId})}
+      >
+        {node.rendered && (
+          <SectionTitle id={titleId} aria-hidden>
+            {node.rendered}
+          </SectionTitle>
+        )}
+        <SectionGroup role="presentation">
+          {filteredOptions.map(child => (
+            <GridListOption
+              key={child.key}
+              node={child}
+              listState={listState}
+              size={size}
+            />
+          ))}
+        </SectionGroup>
+      </SectionWrap>
+    </Fragment>
+  );
+}

+ 257 - 91
static/app/components/compactSelect/index.spec.tsx

@@ -1,4 +1,4 @@
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
 
 import {CompactSelect} from 'sentry/components/compactSelect';
 
@@ -45,111 +45,277 @@ describe('CompactSelect', function () {
     expect(screen.getByText('Menu title')).toBeInTheDocument();
   });
 
-  it('updates trigger label on selection', function () {
-    const mock = jest.fn();
-    render(
-      <CompactSelect
-        options={[
-          {value: 'opt_one', label: 'Option One'},
-          {value: 'opt_two', label: 'Option Two'},
-        ]}
-        onChange={mock}
-      />
-    );
+  describe('ListBox', function () {
+    it('updates trigger label on selection', function () {
+      const mock = jest.fn();
+      render(
+        <CompactSelect
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+          onChange={mock}
+        />
+      );
 
-    // click on the trigger button
-    userEvent.click(screen.getByRole('button'));
+      // click on the trigger button
+      userEvent.click(screen.getByRole('button'));
 
-    // select Option One
-    userEvent.click(screen.getByRole('option', {name: 'Option One'}));
+      // select Option One
+      userEvent.click(screen.getByRole('option', {name: 'Option One'}));
 
-    expect(mock).toHaveBeenCalledWith({value: 'opt_one', label: 'Option One'});
-    expect(screen.getByRole('button', {name: 'Option One'})).toBeInTheDocument();
-  });
+      expect(mock).toHaveBeenCalledWith({value: 'opt_one', label: 'Option One'});
+      expect(screen.getByRole('button', {name: 'Option One'})).toBeInTheDocument();
+    });
 
-  it('can select multiple options', function () {
-    const mock = jest.fn();
-    render(
-      <CompactSelect
-        multiple
-        options={[
-          {value: 'opt_one', label: 'Option One'},
-          {value: 'opt_two', label: 'Option Two'},
-        ]}
-        onChange={mock}
-      />
-    );
+    it('can select multiple options', function () {
+      const mock = jest.fn();
+      render(
+        <CompactSelect
+          multiple
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+          onChange={mock}
+        />
+      );
 
-    // click on the trigger button
-    userEvent.click(screen.getByRole('button'));
+      // click on the trigger button
+      userEvent.click(screen.getByRole('button'));
 
-    // select Option One & Option Two
-    userEvent.click(screen.getByRole('option', {name: 'Option One'}));
-    userEvent.click(screen.getByRole('option', {name: 'Option Two'}));
+      // select Option One & Option Two
+      userEvent.click(screen.getByRole('option', {name: 'Option One'}));
+      userEvent.click(screen.getByRole('option', {name: 'Option Two'}));
 
-    expect(mock).toHaveBeenCalledWith([
-      {value: 'opt_one', label: 'Option One'},
-      {value: 'opt_two', label: 'Option Two'},
-    ]);
-    expect(screen.getByRole('button', {name: 'Option One +1'})).toBeInTheDocument();
-  });
+      expect(mock).toHaveBeenCalledWith([
+        {value: 'opt_one', label: 'Option One'},
+        {value: 'opt_two', label: 'Option Two'},
+      ]);
+      expect(screen.getByRole('button', {name: 'Option One +1'})).toBeInTheDocument();
+    });
 
-  it('displays trigger button with prefix', function () {
-    render(
-      <CompactSelect
-        triggerProps={{prefix: 'Prefix'}}
-        value="opt_one"
-        options={[
-          {value: 'opt_one', label: 'Option One'},
-          {value: 'opt_two', label: 'Option Two'},
-        ]}
-      />
-    );
-    expect(screen.getByRole('button', {name: 'Prefix Option One'})).toBeInTheDocument();
-  });
+    it('displays trigger button with prefix', function () {
+      render(
+        <CompactSelect
+          triggerProps={{prefix: 'Prefix'}}
+          value="opt_one"
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+        />
+      );
+      expect(screen.getByRole('button', {name: 'Prefix Option One'})).toBeInTheDocument();
+    });
 
-  it('can search', function () {
-    render(
-      <CompactSelect
-        searchable
-        searchPlaceholder="Search here…"
-        options={[
-          {value: 'opt_one', label: 'Option One'},
-          {value: 'opt_two', label: 'Option Two'},
-        ]}
-      />
-    );
+    it('can search', function () {
+      render(
+        <CompactSelect
+          searchable
+          searchPlaceholder="Search here…"
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+        />
+      );
 
-    // click on the trigger button
-    userEvent.click(screen.getByRole('button'));
+      // click on the trigger button
+      userEvent.click(screen.getByRole('button'));
+
+      // type 'Two' into the search box
+      userEvent.click(screen.getByPlaceholderText('Search here…'));
+      userEvent.keyboard('Two');
 
-    // type 'Two' into the search box
-    userEvent.click(screen.getByPlaceholderText('Search here…'));
-    userEvent.keyboard('Two');
+      // only Option Two should be available, Option One should be filtered out
+      expect(screen.getByRole('option', {name: 'Option Two'})).toBeInTheDocument();
+      expect(screen.queryByRole('option', {name: 'Option One'})).not.toBeInTheDocument();
+    });
 
-    // only Option Two should be available, Option One should be filtered out
-    expect(screen.getByRole('option', {name: 'Option Two'})).toBeInTheDocument();
-    expect(screen.queryByRole('option', {name: 'Option One'})).not.toBeInTheDocument();
+    it('triggers onClose when the menu is closed if provided', function () {
+      const onCloseMock = jest.fn();
+      render(
+        <CompactSelect
+          onClose={onCloseMock}
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+        />
+      );
+
+      // click on the trigger button
+      userEvent.click(screen.getByRole('button'));
+      expect(onCloseMock).not.toHaveBeenCalled();
+
+      // close the menu
+      userEvent.click(document.body);
+      expect(onCloseMock).toHaveBeenCalled();
+    });
   });
 
-  it('triggers onClose when the menu is closed if provided', function () {
-    const onCloseMock = jest.fn();
-    render(
-      <CompactSelect
-        onClose={onCloseMock}
-        options={[
-          {value: 'opt_one', label: 'Option One'},
-          {value: 'opt_two', label: 'Option Two'},
-        ]}
-      />
-    );
+  describe('GridList', function () {
+    it('updates trigger label on selection', function () {
+      const mock = jest.fn();
+      render(
+        <CompactSelect
+          grid
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+          onChange={mock}
+        />
+      );
 
-    // click on the trigger button
-    userEvent.click(screen.getByRole('button'));
-    expect(onCloseMock).not.toHaveBeenCalled();
+      // click on the trigger button
+      userEvent.click(screen.getByRole('button'));
+
+      // select Option One
+      userEvent.click(screen.getByRole('row', {name: 'Option One'}));
+
+      expect(mock).toHaveBeenCalledWith({value: 'opt_one', label: 'Option One'});
+      expect(screen.getByRole('button', {name: 'Option One'})).toBeInTheDocument();
+    });
+
+    it('can select multiple options', function () {
+      const mock = jest.fn();
+      render(
+        <CompactSelect
+          grid
+          multiple
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+          onChange={mock}
+        />
+      );
+
+      // click on the trigger button
+      userEvent.click(screen.getByRole('button'));
+
+      // select Option One & Option Two
+      userEvent.click(screen.getByRole('row', {name: 'Option One'}));
+      userEvent.click(screen.getByRole('row', {name: 'Option Two'}));
+
+      expect(mock).toHaveBeenCalledWith([
+        {value: 'opt_one', label: 'Option One'},
+        {value: 'opt_two', label: 'Option Two'},
+      ]);
+      expect(screen.getByRole('button', {name: 'Option One +1'})).toBeInTheDocument();
+    });
+
+    it('displays trigger button with prefix', function () {
+      render(
+        <CompactSelect
+          grid
+          triggerProps={{prefix: 'Prefix'}}
+          value="opt_one"
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+        />
+      );
+      expect(screen.getByRole('button', {name: 'Prefix Option One'})).toBeInTheDocument();
+    });
+
+    it('can search', function () {
+      render(
+        <CompactSelect
+          grid
+          searchable
+          searchPlaceholder="Search here…"
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+        />
+      );
+
+      // click on the trigger button
+      userEvent.click(screen.getByRole('button'));
+
+      // type 'Two' into the search box
+      userEvent.click(screen.getByPlaceholderText('Search here…'));
+      userEvent.keyboard('Two');
+
+      // only Option Two should be available, Option One should be filtered out
+      expect(screen.getByRole('row', {name: 'Option Two'})).toBeInTheDocument();
+      expect(screen.queryByRole('row', {name: 'Option One'})).not.toBeInTheDocument();
+    });
+
+    it('triggers onClose when the menu is closed if provided', function () {
+      const onCloseMock = jest.fn();
+      render(
+        <CompactSelect
+          grid
+          onClose={onCloseMock}
+          options={[
+            {value: 'opt_one', label: 'Option One'},
+            {value: 'opt_two', label: 'Option Two'},
+          ]}
+        />
+      );
+
+      // click on the trigger button
+      userEvent.click(screen.getByRole('button'));
+      expect(onCloseMock).not.toHaveBeenCalled();
+
+      // close the menu
+      userEvent.click(document.body);
+      expect(onCloseMock).toHaveBeenCalled();
+    });
+
+    it('allows keyboard navigation to nested buttons', async function () {
+      const onPointerUpMock = jest.fn();
+      const onKeyUpMock = jest.fn();
+
+      render(
+        <CompactSelect
+          grid
+          options={[
+            {
+              value: 'opt_one',
+              label: 'Option One',
+              trailingItems: (
+                <button onPointerUp={onPointerUpMock} onKeyUp={onKeyUpMock}>
+                  Trailing Button One
+                </button>
+              ),
+            },
+            {
+              value: 'opt_two',
+              label: 'Option Two',
+              trailingItems: (
+                <button onPointerUp={onPointerUpMock} onKeyUp={onKeyUpMock}>
+                  Trailing Button Two
+                </button>
+              ),
+            },
+          ]}
+        />
+      );
+
+      // click on the trigger button
+      userEvent.click(screen.getByRole('button'));
+      await waitFor(() =>
+        expect(screen.getByRole('row', {name: 'Option One'})).toHaveFocus()
+      );
+
+      // press Arrow Right, focus should be moved to the trailing button
+      userEvent.keyboard('{ArrowRight}');
+      expect(screen.getByRole('button', {name: 'Trailing Button One'})).toHaveFocus();
+
+      // press Enter, onKeyUpMock is called
+      userEvent.keyboard('{ArrowRight}');
+      expect(onKeyUpMock).toHaveBeenCalled();
 
-    // close the menu
-    userEvent.click(document.body);
-    expect(onCloseMock).toHaveBeenCalled();
+      // click on Trailing Button Two, onPointerUpMock is called
+      userEvent.click(screen.getByRole('button', {name: 'Trailing Button Two'}));
+      expect(onPointerUpMock).toHaveBeenCalled();
+    });
   });
 });

+ 18 - 13
static/app/components/compactSelect/index.tsx

@@ -4,7 +4,7 @@ import {Item, Section} from '@react-stately/collections';
 import domId from 'sentry/utils/domId';
 
 import {Control, ControlProps} from './control';
-import {ListBox, MultipleListBoxProps, SingleListBoxProps} from './listBox';
+import {List, MultipleListProps, SingleListProps} from './list';
 import type {
   SelectOption,
   SelectOptionOrSection,
@@ -20,13 +20,16 @@ interface BaseSelectProps<Value extends React.Key> extends ControlProps {
 
 export interface SingleSelectProps<Value extends React.Key>
   extends BaseSelectProps<Value>,
-    Omit<SingleListBoxProps<Value>, 'children' | 'items' | 'compositeIndex' | 'label'> {}
+    Omit<
+      SingleListProps<Value>,
+      'children' | 'items' | 'grid' | 'compositeIndex' | 'label'
+    > {}
 
 export interface MultipleSelectProps<Value extends React.Key>
   extends BaseSelectProps<Value>,
     Omit<
-      MultipleListBoxProps<Value>,
-      'children' | 'items' | 'compositeIndex' | 'label'
+      MultipleListProps<Value>,
+      'children' | 'items' | 'grid' | 'compositeIndex' | 'label'
     > {}
 
 export type SelectProps<Value extends React.Key> =
@@ -43,7 +46,7 @@ function CompactSelect<Value extends React.Key>(props: SelectProps<Value>): JSX.
  * Flexible select component with a customizable trigger button
  */
 function CompactSelect<Value extends React.Key>({
-  // List box props
+  // List props
   options,
   value,
   defaultValue,
@@ -53,6 +56,7 @@ function CompactSelect<Value extends React.Key>({
   isOptionDisabled,
 
   // Control props
+  grid,
   disabled,
   size = 'md',
   closeOnSelect,
@@ -61,14 +65,14 @@ function CompactSelect<Value extends React.Key>({
 }: SelectProps<Value>) {
   const triggerId = useMemo(() => domId('select-trigger-'), []);
 
-  // Combine list box props into an object with two clearly separated types, one where
+  // Combine list 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(() => {
+  const listProps = useMemo(() => {
     if (multiple) {
-      return {multiple, value, defaultValue, onChange, closeOnSelect};
+      return {multiple, value, defaultValue, onChange, closeOnSelect, grid};
     }
-    return {multiple, value, defaultValue, onChange, closeOnSelect};
-  }, [multiple, value, defaultValue, onChange, closeOnSelect]);
+    return {multiple, value, defaultValue, onChange, closeOnSelect, grid};
+  }, [multiple, value, defaultValue, onChange, closeOnSelect, grid]);
 
   const optionsWithKey = useMemo<SelectOptionOrSectionWithKey<Value>[]>(
     () =>
@@ -89,10 +93,11 @@ function CompactSelect<Value extends React.Key>({
       {...controlProps}
       triggerProps={{...triggerProps, id: triggerId}}
       disabled={controlDisabled}
+      grid={grid}
       size={size}
     >
-      <ListBox
-        {...listBoxProps}
+      <List
+        {...listProps}
         items={optionsWithKey}
         disallowEmptySelection={disallowEmptySelection}
         isOptionDisabled={isOptionDisabled}
@@ -118,7 +123,7 @@ function CompactSelect<Value extends React.Key>({
             </Item>
           );
         }}
-      </ListBox>
+      </List>
     </Control>
   );
 }

+ 101 - 142
static/app/components/compactSelect/listBox.tsx → static/app/components/compactSelect/list.tsx

@@ -1,26 +1,27 @@
-import {Fragment, useContext, useEffect, useMemo, useRef, useState} from 'react';
-import styled from '@emotion/styled';
+import {useCallback, useContext, useEffect, useMemo} from 'react';
 import {useFocusManager} from '@react-aria/focus';
-import {useKeyboard} from '@react-aria/interactions';
-import {AriaListBoxOptions, useListBox} from '@react-aria/listbox';
-import {mergeProps} from '@react-aria/utils';
+import {AriaGridListOptions} from '@react-aria/gridlist';
+import {AriaListBoxOptions} from '@react-aria/listbox';
 import {ListProps, useListState} from '@react-stately/list';
 import {Selection} from '@react-types/shared';
 
-import {space} from 'sentry/styles/space';
 import {defined} from 'sentry/utils';
 import {FormSize} from 'sentry/utils/theme';
 
 import {SelectContext} from './control';
-import {Option} from './option';
-import {Section} from './section';
+import {GridList} from './gridList';
+import {ListBox} from './listBox';
 import {SelectOption, SelectOptionOrSection, SelectOptionOrSectionWithKey} from './types';
 
-interface BaseListBoxProps<Value extends React.Key>
+interface BaseListProps<Value extends React.Key>
   extends ListProps<any>,
     Omit<
       AriaListBoxOptions<any>,
       'disabledKeys' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange'
+    >,
+    Omit<
+      AriaGridListOptions<any>,
+      'disabledKeys' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange'
     > {
   items: SelectOptionOrSectionWithKey<Value>[];
   /**
@@ -29,29 +30,39 @@ interface BaseListBoxProps<Value extends React.Key>
    */
   closeOnSelect?: boolean;
   /**
-   * The index number of this list box inside composite select menus, which contain
-   * multiple list boxes (each corresponding to a select region).
+   * This list's index number inside composite select menus.
    */
   compositeIndex?: number;
+  /**
+   * Whether to render a grid list rather than a list box.
+   *
+   * Unlike list boxes, grid lists are two-dimensional. Users can press Arrow Up/Down to
+   * move between option rows, and Arrow Left/Right to move between columns. This is
+   * useful when the selector contains options with smaller, interactive elements
+   * (buttons/links) inside. Grid lists allow users to focus on those child elements and
+   * interact with them, which isn't possible with list boxes.
+   */
+  grid?: boolean;
   /**
    * Custom function to determine whether an option is disabled. By default, an option
    * is considered disabled when it has {disabled: true}.
    */
   isOptionDisabled?: (opt: SelectOption<Value>) => boolean;
+  /**
+   * Text label to be rendered as heading on top of grid list.
+   */
   label?: React.ReactNode;
   size?: FormSize;
 }
 
-export interface SingleListBoxProps<Value extends React.Key>
-  extends BaseListBoxProps<Value> {
+export interface SingleListProps<Value extends React.Key> extends BaseListProps<Value> {
   defaultValue?: Value;
   multiple?: false;
   onChange?: (selectedOption: SelectOption<Value>) => void;
   value?: Value;
 }
 
-export interface MultipleListBoxProps<Value extends React.Key>
-  extends BaseListBoxProps<Value> {
+export interface MultipleListProps<Value extends React.Key> extends BaseListProps<Value> {
   multiple: true;
   defaultValue?: Value[];
   onChange?: (selectedOptions: SelectOption<Value>[]) => void;
@@ -59,34 +70,29 @@ export interface MultipleListBoxProps<Value extends React.Key>
 }
 
 /**
- * A list box wrapper with accessibile behaviors & attributes. In composite selectors,
- * there may be multiple self-contained list boxes, each representing a select "region".
- * https://react-spectrum.adobe.com/react-aria/useListBox.html
+ * A list containing selectable options. Depending on the `grid` prop, this may be a
+ * grid list or list box.
+ *
+ * In composite selectors, there may be multiple self-contained lists, each
+ * representing a select "region".
  */
-function ListBox<Value extends React.Key>({
+function List<Value extends React.Key>({
   items,
   value,
   defaultValue,
   onChange,
+  grid,
   multiple,
   disallowEmptySelection,
   isOptionDisabled,
-  size = 'md',
   shouldFocusWrap = true,
   shouldFocusOnHover = true,
   compositeIndex = 0,
   closeOnSelect,
-  label,
   ...props
-}: SingleListBoxProps<Value> | MultipleListBoxProps<Value>) {
-  const ref = useRef<HTMLUListElement>(null);
-  const {
-    overlayState,
-    overlayIsOpen,
-    registerListState,
-    saveSelectedOptions,
-    filterOption,
-  } = useContext(SelectContext);
+}: SingleListProps<Value> | MultipleListProps<Value>) {
+  const {overlayState, registerListState, saveSelectedOptions, filterOption} =
+    useContext(SelectContext);
 
   /**
    * Props to be passed into useListState()
@@ -164,20 +170,6 @@ function ListBox<Value extends React.Key>({
     items,
   });
 
-  const [hasFocus, setHasFocus] = useState(false);
-  const {listBoxProps, labelProps} = useListBox(
-    {
-      ...props,
-      label,
-      onFocusChange: setHasFocus,
-      shouldFocusWrap,
-      shouldFocusOnHover,
-      shouldSelectOnPressUp: true,
-    },
-    listState,
-    ref
-  );
-
   // Register the initialized list state once on mount
   useEffect(() => {
     registerListState(compositeIndex, listState);
@@ -201,35 +193,50 @@ function ListBox<Value extends React.Key>({
     });
   }, [listState.collection, filterOption]);
 
-  // In composite selects, focus should seamlessly move from one region (listbox) to
+  // In composite selects, focus should seamlessly move from one region (list) to
   // another when the ArrowUp/Down key is pressed
   const focusManager = useFocusManager();
   const firstFocusableKey = useMemo(() => {
     let firstKey = listState.collection.getFirstKey();
-    while (firstKey && listState.selectionManager.isDisabled(firstKey)) {
+
+    while (
+      firstKey &&
+      (listState.collection.getItem(firstKey).type === 'section' ||
+        listState.selectionManager.isDisabled(firstKey))
+    ) {
       firstKey = listState.collection.getKeyAfter(firstKey);
     }
+
     return firstKey;
   }, [listState.collection, listState.selectionManager]);
   const lastFocusableKey = useMemo(() => {
     let lastKey = listState.collection.getLastKey();
-    while (lastKey && listState.selectionManager.isDisabled(lastKey)) {
+
+    while (
+      lastKey &&
+      (listState.collection.getItem(lastKey).type === 'section' ||
+        listState.selectionManager.isDisabled(lastKey))
+    ) {
       lastKey = listState.collection.getKeyBefore(lastKey);
     }
+
     return lastKey;
   }, [listState.collection, listState.selectionManager]);
-  const {keyboardProps} = useKeyboard({
-    onKeyDown: e => {
-      // Continue propagation, otherwise the overlay won't close on Esc key press
-      e.continuePropagation();
 
+  /**
+   * Keyboard event handler to seamlessly move focus from one composite list to another
+   * when an arrow key is pressed. Returns a boolean indicating whether the keyboard
+   * event was intercepted. If yes, then no further callback function should be run.
+   */
+  const keyDownHandler = useCallback(
+    (e: React.KeyboardEvent<HTMLUListElement>) => {
       // Don't handle ArrowDown/Up key presses if focus already wraps
-      if (shouldFocusWrap) {
-        return;
+      if (shouldFocusWrap && !grid) {
+        return true;
       }
 
       // Move focus to next region when ArrowDown is pressed and the last item in this
-      // list box is currently focused
+      // list is currently focused
       if (
         e.key === 'ArrowDown' &&
         listState.selectionManager.focusedKey === lastFocusableKey
@@ -237,13 +244,16 @@ function ListBox<Value extends React.Key>({
         focusManager.focusNext({
           wrap: true,
           accept: element =>
-            element.getAttribute('role') === 'option' &&
+            (element.getAttribute('role') === 'option' ||
+              element.getAttribute('role') === 'row') &&
             element.getAttribute('aria-disabled') !== 'true',
         });
+
+        return false; // event intercepted, don't run any further callbacks
       }
 
       // Move focus to previous region when ArrowUp is pressed and the first item in this
-      // list box is currently focused
+      // list is currently focused
       if (
         e.key === 'ArrowUp' &&
         listState.selectionManager.focusedKey === firstFocusableKey
@@ -251,48 +261,50 @@ function ListBox<Value extends React.Key>({
         focusManager.focusPrevious({
           wrap: true,
           accept: element =>
-            element.getAttribute('role') === 'option' &&
+            (element.getAttribute('role') === 'option' ||
+              element.getAttribute('role') === 'row') &&
             element.getAttribute('aria-disabled') !== 'true',
         });
+
+        return false; // event intercepted, don't run any further callbacks
       }
+
+      return true;
     },
-  });
+    [
+      focusManager,
+      firstFocusableKey,
+      lastFocusableKey,
+      listState.selectionManager.focusedKey,
+      shouldFocusWrap,
+      grid,
+    ]
+  );
+
+  if (grid) {
+    return (
+      <GridList
+        {...props}
+        listItems={filteredItems}
+        listState={listState}
+        keyDownHandler={keyDownHandler}
+      />
+    );
+  }
 
   return (
-    <Fragment>
-      {filteredItems.length !== 0 && <Separator role="separator" />}
-      {filteredItems.length !== 0 && label && <Label {...labelProps}>{label}</Label>}
-      <SelectListBoxWrap {...mergeProps(listBoxProps, keyboardProps)} ref={ref}>
-        {overlayIsOpen &&
-          filteredItems.map(item => {
-            if (item.type === 'section') {
-              return (
-                <Section
-                  key={item.key}
-                  item={item}
-                  listState={listState}
-                  listBoxHasFocus={hasFocus}
-                  size={size}
-                />
-              );
-            }
-
-            return (
-              <Option
-                key={item.key}
-                item={item}
-                listState={listState}
-                listBoxHasFocus={hasFocus}
-                size={size}
-              />
-            );
-          })}
-      </SelectListBoxWrap>
-    </Fragment>
+    <ListBox
+      {...props}
+      listItems={filteredItems}
+      listState={listState}
+      shouldFocusWrap={shouldFocusWrap}
+      shouldFocusOnHover={shouldFocusOnHover}
+      keyDownHandler={keyDownHandler}
+    />
   );
 }
 
-export {ListBox};
+export {List};
 
 /**
  * Recursively finds the selected option(s) from an options array. Useful for
@@ -342,56 +354,3 @@ function getDisabledOptions<Value extends React.Key>(
     return acc;
   }, []);
 }
-
-const SelectListBoxWrap = styled('ul')`
-  margin: 0;
-  padding: ${space(0.5)} 0;
-
-  /* Add 1px to top padding if preceded by menu header, to account for the header's
-  shadow border */
-  div[data-header] ~ &:first-of-type,
-  div[data-header] ~ div > &:first-of-type {
-    padding-top: calc(${space(0.5)} + 1px);
-  }
-
-  /* Remove top padding if preceded by search input, since search input already has
-  vertical padding */
-  input ~ &&:first-of-type,
-  input ~ div > &&:first-of-type {
-    padding-top: 0;
-  }
-
-  /* Should scroll if it's in a non-composite select */
-  :only-of-type {
-    min-height: 0;
-    overflow: auto;
-  }
-
-  :focus-visible {
-    outline: none;
-  }
-`;
-
-const Label = styled('p')`
-  display: inline-block;
-  font-weight: 600;
-  font-size: ${p => p.theme.fontSizeExtraSmall};
-  color: ${p => p.theme.subText};
-  text-transform: uppercase;
-  white-space: nowrap;
-  margin: ${space(0.5)} ${space(1.5)};
-  padding-right: ${space(1)};
-`;
-
-const Separator = styled('div')`
-  border-top: solid 1px ${p => p.theme.innerBorder};
-  margin: ${space(0.5)} ${space(1.5)};
-
-  :first-child {
-    display: none;
-  }
-
-  ul:empty + & {
-    display: none;
-  }
-`;

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