Browse Source

feat(tabs): Add new Tabs component (#38886)

Add a new `Tabs` component as a replacement for `NavTabs`, with a
composable implementation and full accessibility support (via
`react-aria`).

Sample usage:

```jsx
import {Item, TabList, TabPanels, Tabs} from 'sentry/components/tabs';

<Tabs>
  <TabList>
    <Item key="details">Details</Item>
    <Item key="activity">Activity</Item>
  </TabList>
  <TabPanels>
    <Item key="details">So by colonel hearted ferrars.</Item>
    <Item key="activity">Draw from upon here gone add one.</Item>
  </TabPanels>
</Tabs>
```

๐Ÿ‘‰๐Ÿ‘‰๐Ÿ‘‰ [Storybook
prototype](https://storybook-ilh3qd00a.sentry.dev/?path=/story/views-tabs--default)

One notable feature is the overflow menu:

https://user-images.githubusercontent.com/44172267/190467998-b6baf2a6-85ac-4713-a9cd-137a8f3bed34.mp4

Co-authored-by: Evan Purkhiser <evanpurkhiser@gmail.com>
Vu Luong 2 years ago
parent
commit
5f2a4039e1

+ 1 - 1
docs-ui/stories/views/navTabs.stories.js

@@ -1,7 +1,7 @@
 import NavTabs from 'sentry/components/navTabs';
 
 export default {
-  title: 'Views/Tabs',
+  title: 'Views/Nav Tabs',
   component: NavTabs,
 };
 

+ 91 - 0
docs-ui/stories/views/tabs.stories.js

@@ -0,0 +1,91 @@
+import styled from '@emotion/styled';
+
+import {Item, TabList, TabPanels, Tabs} from 'sentry/components/tabs';
+import space from 'sentry/styles/space';
+
+export default {
+  title: 'Views/Tabs',
+  component: Tabs,
+};
+
+const TABS = [
+  {key: 'details', label: 'Details', content: 'So by colonel hearted ferrars.'},
+  {
+    key: 'activity',
+    label: 'Activity',
+    content: 'Draw from upon here gone add one.',
+  },
+  {
+    key: 'user-feedback',
+    label: 'User Feedback',
+    content: 'He in sportsman household otherwise it perceived instantly.',
+  },
+  {
+    key: 'attachments',
+    label: 'Attachments',
+    content: 'Do play they miss give so up.',
+  },
+  {
+    key: 'tags',
+    label: 'Tags',
+    content: 'Words to up style of since world.',
+  },
+  {
+    key: 'disabled',
+    label: 'Disabled',
+    content: 'Unreachable content.',
+    disabled: true,
+  },
+];
+
+export const Default = args => {
+  return (
+    <Tabs {...args}>
+      <TabList>
+        {TABS.map(tab => (
+          <Item key={tab.key} disabled={tab.disabled}>
+            {tab.label}
+          </Item>
+        ))}
+      </TabList>
+      <StyledTabPanels orientation={args.orientation}>
+        {TABS.map(tab => (
+          <Item key={tab.key}>{tab.content}</Item>
+        ))}
+      </StyledTabPanels>
+    </Tabs>
+  );
+};
+
+Default.storyName = 'Default';
+Default.args = {
+  orientation: 'horizontal',
+  disabled: false,
+  value: undefined,
+  defaultValue: undefined,
+};
+Default.argTypes = {
+  orientation: {
+    options: ['horizontal', 'vertical'],
+    control: {type: 'radio'},
+  },
+  value: {
+    options: TABS.map(tab => tab.key),
+    control: {type: 'select'},
+  },
+  defaultValue: {
+    options: TABS.map(tab => tab.key),
+    control: {type: 'select'},
+  },
+  className: {control: {type: 'disabed'}},
+};
+
+// To add styles to tab panels, wrap styled() around `TabPanels`, not `Item`
+const StyledTabPanels = styled(TabPanels)`
+  ${p =>
+    p.orientation === 'horizontal'
+      ? `padding: ${space(2)} 0;`
+      : `padding: 0 ${space(2)};`};
+
+  color: ${p => p.theme.subText};
+`;

+ 2 - 0
package.json

@@ -28,9 +28,11 @@
     "@react-aria/menu": "^3.3.0",
     "@react-aria/overlays": "^3.7.3",
     "@react-aria/separator": "^3.1.3",
+    "@react-aria/tabs": "^3.3.1",
     "@react-aria/utils": "^3.11.0",
     "@react-stately/collections": "^3.3.3",
     "@react-stately/menu": "^3.2.3",
+    "@react-stately/tabs": "^3.2.1",
     "@react-stately/tree": "^3.2.0",
     "@react-types/menu": "^3.3.0",
     "@react-types/shared": "^3.8.0",

+ 203 - 0
static/app/components/tabs/index.spec.tsx

@@ -0,0 +1,203 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {Item, TabList, TabPanels, Tabs} from 'sentry/components/tabs';
+
+const TABS = [
+  {key: 'details', label: 'Details', content: 'So by colonel hearted ferrars.'},
+  {
+    key: 'activity',
+    label: 'Activity',
+    content:
+      'Draw from upon here gone add one. He in sportsman household otherwise it perceived instantly.',
+  },
+  {
+    key: 'user-feedback',
+    label: 'User Feedback',
+    content: 'Is inquiry no he several excited am.',
+  },
+  {
+    key: 'attachments',
+    label: 'Attachments',
+    content: 'Called though excuse length ye needed it he having.',
+  },
+];
+
+describe('Tabs', () => {
+  it('renders tabs list', () => {
+    render(
+      <Tabs>
+        <TabList>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.label}</Item>
+          ))}
+        </TabList>
+        <TabPanels>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.content}</Item>
+          ))}
+        </TabPanels>
+      </Tabs>
+    );
+
+    // The full tabs list is rendered
+    expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal');
+    expect(screen.getAllByRole('tab')).toHaveLength(TABS.length);
+    TABS.forEach(tab => {
+      expect(screen.getByRole('tab', {name: tab.label})).toBeInTheDocument();
+    });
+
+    // The first tab item is selected and its content visible
+    expect(screen.getByRole('tab', {name: TABS[0].label})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByText(TABS[0].content)).toBeInTheDocument();
+  });
+
+  it('renders tabs list when disabled', () => {
+    render(
+      <Tabs disabled>
+        <TabList>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.label}</Item>
+          ))}
+        </TabList>
+        <TabPanels>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.content}</Item>
+          ))}
+        </TabPanels>
+      </Tabs>
+    );
+
+    // The first tab item is selected and its content visible
+    expect(screen.getByRole('tab', {name: TABS[0].label})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByText(TABS[0].content)).toBeInTheDocument();
+
+    // All tabs are marked as disabled
+    TABS.forEach(tab => {
+      expect(screen.getByRole('tab', {name: tab.label})).toHaveAttribute(
+        'aria-disabled',
+        'true'
+      );
+    });
+  });
+
+  it('changes tabs on click', () => {
+    render(
+      <Tabs>
+        <TabList>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.label}</Item>
+          ))}
+        </TabList>
+        <TabPanels>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.content}</Item>
+          ))}
+        </TabPanels>
+      </Tabs>
+    );
+
+    // Click on the Activity tab
+    userEvent.click(screen.getByRole('tab', {name: 'Activity'}));
+
+    // The Activity tab is selected and its content rendered
+    expect(screen.getByRole('tab', {name: 'Activity'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByText(TABS[1].content)).toBeInTheDocument();
+  });
+
+  it('changes tabs on key press', () => {
+    render(
+      <Tabs>
+        <TabList>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.label}</Item>
+          ))}
+        </TabList>
+        <TabPanels>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.content}</Item>
+          ))}
+        </TabPanels>
+      </Tabs>
+    );
+
+    // Focus on tab list
+    userEvent.tab();
+    expect(screen.getByRole('tab', {name: 'Details'})).toHaveFocus();
+
+    // Press Arrow Right to select the next tab to the right (Activity)
+    userEvent.keyboard('{arrowRight}');
+
+    // The Activity tab is selected and its contents rendered
+    expect(screen.getByRole('tab', {name: 'Activity'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByText(TABS[1].content)).toBeInTheDocument();
+  });
+
+  it('changes tabs on key press in vertical orientation', () => {
+    render(
+      <Tabs orientation="vertical">
+        <TabList>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.label}</Item>
+          ))}
+        </TabList>
+        <TabPanels>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.content}</Item>
+          ))}
+        </TabPanels>
+      </Tabs>
+    );
+
+    // Focus on tab list
+    userEvent.tab();
+    expect(screen.getByRole('tab', {name: 'Details'})).toHaveFocus();
+
+    // Press Arrow Right to select the next tab below (Activity)
+    userEvent.keyboard('{arrowDown}');
+
+    // The Activity tab should now be selected and its contents rendered
+    expect(screen.getByRole('tab', {name: 'Activity'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    expect(screen.getByText(TABS[1].content)).toBeInTheDocument();
+  });
+
+  it('renders disabled tabs', () => {
+    render(
+      <Tabs>
+        <TabList>
+          {TABS.map(tab => (
+            <Item key={tab.key} disabled>
+              {tab.label}
+            </Item>
+          ))}
+        </TabList>
+        <TabPanels>
+          {TABS.map(tab => (
+            <Item key={tab.key}>{tab.content}</Item>
+          ))}
+        </TabPanels>
+      </Tabs>
+    );
+
+    TABS.forEach(tab => {
+      expect(screen.getByRole('tab', {name: tab.label})).toHaveAttribute(
+        'aria-disabled',
+        'true'
+      );
+    });
+  });
+});

+ 80 - 0
static/app/components/tabs/index.tsx

@@ -0,0 +1,80 @@
+import 'intersection-observer'; // polyfill
+
+import {createContext, useState} from 'react';
+import styled from '@emotion/styled';
+import {AriaTabListProps} from '@react-aria/tabs';
+import {Item} from '@react-stately/collections';
+import {TabListProps, TabListState} from '@react-stately/tabs';
+import {ItemProps, Orientation} from '@react-types/shared';
+
+import {TabList} from './tabList';
+import {TabPanels} from './tabPanels';
+
+const _Item = Item as (
+  props: ItemProps<any> & {disabled?: boolean; hidden?: boolean}
+) => JSX.Element;
+export {_Item as Item, TabList, TabPanels};
+
+export interface TabsProps
+  extends Omit<TabListProps<any>, 'children'>,
+    Omit<AriaTabListProps<any>, 'children'> {
+  children?: React.ReactNode;
+  className?: string;
+  /**
+   * [Uncontrolled] Default selected tab. Must match the `key` prop on the
+   * selected tab item.
+   */
+  defaultValue?: TabListProps<any>['defaultSelectedKey'];
+  disabled?: boolean;
+  /**
+   * Callback when the selected tab changes.
+   */
+  onChange?: TabListProps<any>['onSelectionChange'];
+  /**
+   * [Controlled] Selected tab . Must match the `key` prop on the selected tab
+   * item.
+   */
+  value?: TabListProps<any>['selectedKey'];
+}
+
+interface TabContext {
+  rootProps: TabsProps & {orientation: Orientation};
+  setTabListState: (state: TabListState<any>) => void;
+  tabListState?: TabListState<any>;
+}
+
+export const TabsContext = createContext<TabContext>({
+  rootProps: {orientation: 'horizontal', children: []},
+  setTabListState: () => {},
+});
+
+/**
+ * Root tabs component. Provides the necessary data (via React context) for
+ * child components (TabList and TabPanels) to work together. See example
+ * usage in tabs.stories.js
+ */
+export function Tabs({orientation = 'horizontal', className, ...props}: TabsProps) {
+  const [tabListState, setTabListState] = useState<TabListState<any>>();
+
+  return (
+    <TabsContext.Provider
+      value={{rootProps: {...props, orientation}, tabListState, setTabListState}}
+    >
+      <TabsWrap orientation={orientation} className={className}>
+        {props.children}
+      </TabsWrap>
+    </TabsContext.Provider>
+  );
+}
+
+const TabsWrap = styled('div')<{orientation: Orientation}>`
+  display: flex;
+  flex-direction: ${p => (p.orientation === 'horizontal' ? 'column' : 'row')};
+
+  ${p =>
+    p.orientation === 'vertical' &&
+    `
+      height: 100%;
+      align-items: stretch;
+    `};
+`;

+ 188 - 0
static/app/components/tabs/tab.tsx

@@ -0,0 +1,188 @@
+import {forwardRef} from 'react';
+import styled from '@emotion/styled';
+import {useTab} from '@react-aria/tabs';
+import {mergeProps, useObjectRef} from '@react-aria/utils';
+import {TabListState} from '@react-stately/tabs';
+import {Node, Orientation} from '@react-types/shared';
+
+import space from 'sentry/styles/space';
+
+interface TabProps {
+  item: Node<any>;
+  orientation: Orientation;
+  /**
+   * Whether this tab is overflowing the TabList container. If so, the tab
+   * needs to be visually hidden. Users can instead select it via an overflow
+   * menu.
+   */
+  overflowing: boolean;
+  state: TabListState<any>;
+}
+
+/**
+ * Renders a single tab item. This should not be imported directly into any
+ * page/view โ€“ it's only meant to be used by <TabsList />. See the correct
+ * usage in tabs.stories.js
+ */
+function BaseTab(
+  {item, state, orientation, overflowing}: TabProps,
+  forwardedRef: React.ForwardedRef<HTMLLIElement>
+) {
+  const ref = useObjectRef(forwardedRef);
+
+  const {key, rendered} = item;
+  const {tabProps, isSelected, isDisabled} = useTab({key}, state, ref);
+
+  return (
+    <TabWrap
+      {...mergeProps(tabProps)}
+      disabled={isDisabled}
+      selected={isSelected}
+      overflowing={overflowing}
+      orientation={orientation}
+      ref={ref}
+    >
+      <HoverLayer orientation={orientation} />
+      <FocusLayer orientation={orientation} />
+      {rendered}
+      <TabSelectionIndicator orientation={orientation} selected={isSelected} />
+    </TabWrap>
+  );
+}
+
+export const Tab = forwardRef(BaseTab);
+
+const TabWrap = styled('li')<{
+  disabled: boolean;
+  orientation: Orientation;
+  overflowing: boolean;
+  selected: boolean;
+}>`
+  display: flex;
+  align-items: center;
+  position: relative;
+  height: calc(
+    ${p => p.theme.form.sm.height}px +
+      ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)}
+  );
+  border-radius: ${p => p.theme.borderRadius};
+  transform: translateY(1px);
+
+  ${p =>
+    p.orientation === 'horizontal'
+      ? `
+        /* Extra padding + negative margin trick, to expand click area */
+        padding: ${space(0.75)} ${space(1)} ${space(1.5)};
+        margin-left: -${space(1)};
+        margin-right: -${space(1)};
+      `
+      : `padding: ${space(0.75)} ${space(2)};`};
+
+  color: ${p => (p.selected ? p.theme.activeText : p.theme.textColor)};
+  white-space: nowrap;
+  cursor: pointer;
+
+  &:hover {
+    color: ${p => (p.selected ? p.theme.activeText : p.theme.headingColor)};
+  }
+
+  &:focus {
+    outline: none;
+  }
+
+  ${p =>
+    p.disabled &&
+    `
+      &, &:hover {
+        color: ${p.theme.subText};
+        pointer-events: none;
+      }
+    `}
+
+  ${p =>
+    p.overflowing &&
+    `
+      opacity: 0;
+      pointer-events: none;
+    `}
+`;
+
+const HoverLayer = styled('div')<{orientation: Orientation}>`
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)};
+
+  pointer-events: none;
+  background-color: currentcolor;
+  border-radius: inherit;
+  z-index: 0;
+
+  opacity: 0;
+  transition: opacity 0.1s ease-out;
+
+  li:hover:not(.focus-visible) > & {
+    opacity: 0.06;
+  }
+
+  ${p =>
+    p.orientation === 'vertical' &&
+    `
+      li[aria-selected='true']:not(.focus-visible) > & {
+        opacity: 0.06;
+      }
+    `}
+`;
+
+const FocusLayer = styled('div')<{orientation: Orientation}>`
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)};
+
+  pointer-events: none;
+  border-radius: inherit;
+  z-index: 0;
+  transition: box-shadow 0.1s ease-out;
+
+  li.focus-visible > & {
+    box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px,
+      inset ${p => p.theme.focusBorder} 0 0 0 1px;
+  }
+`;
+
+const TabSelectionIndicator = styled('div')<{
+  orientation: Orientation;
+  selected: boolean;
+}>`
+  position: absolute;
+  border-radius: 2px;
+  pointer-events: none;
+  background: ${p => (p.selected ? p.theme.active : 'transparent')};
+  transition: background 0.1s ease-out;
+
+  li[aria-disabled='true'] & {
+    background: ${p => (p.selected ? p.theme.subText : 'transparent')};
+  }
+
+  ${p =>
+    p.orientation === 'horizontal'
+      ? `
+        width: calc(100% - ${space(2)});
+        height: 3px;
+
+        bottom: 0;
+        left: 50%;
+        transform: translateX(-50%);
+      `
+      : `
+        width: 3px;
+        height: 50%;
+
+        left: 0;
+        top: 50%;
+        transform: translateY(-50%);
+      `};
+`;

+ 235 - 0
static/app/components/tabs/tabList.tsx

@@ -0,0 +1,235 @@
+import {useContext, useEffect, useMemo, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+import {AriaTabListProps, useTabList} from '@react-aria/tabs';
+import {Item, useCollection} from '@react-stately/collections';
+import {ListCollection} from '@react-stately/list';
+import {TabListProps as TabListStateProps, useTabListState} from '@react-stately/tabs';
+import {Node, Orientation} from '@react-types/shared';
+
+import DropdownButton from 'sentry/components/dropdownButton';
+import CompactSelect from 'sentry/components/forms/compactSelect';
+import {IconEllipsis} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+
+import {TabsContext} from './index';
+import {Tab} from './tab';
+
+/**
+ * Uses IntersectionObserver API to detect overflowing tabs. Returns an array
+ * containing of keys of overflowing tabs.
+ */
+function useOverflowTabs({
+  tabListRef,
+  tabItemsRef,
+}: {
+  tabItemsRef: React.RefObject<Record<React.Key, HTMLLIElement | null>>;
+  tabListRef: React.RefObject<HTMLUListElement>;
+}) {
+  const [overflowTabs, setOverflowTabs] = useState<React.Key[]>([]);
+
+  useEffect(() => {
+    const options = {
+      root: tabListRef.current,
+      // Nagative right margin to account for overflow menu's trigger button
+      rootMargin: `0px -42px 1px ${space(1)}`,
+      threshold: 1,
+    };
+
+    const callback: IntersectionObserverCallback = entries => {
+      entries.forEach(entry => {
+        const {target} = entry;
+        const {key} = (target as HTMLElement).dataset;
+        if (!key) {
+          return;
+        }
+
+        if (!entry.isIntersecting) {
+          setOverflowTabs(prev => prev.concat([key]));
+          return;
+        }
+
+        setOverflowTabs(prev => prev.filter(k => k !== key));
+      });
+    };
+
+    const observer = new IntersectionObserver(callback, options);
+    Object.values(tabItemsRef.current ?? {}).forEach(
+      element => element && observer.observe(element)
+    );
+
+    return () => observer.disconnect();
+  }, [tabListRef, tabItemsRef]);
+
+  return overflowTabs;
+}
+
+interface TabListProps extends TabListStateProps<any>, AriaTabListProps<any> {
+  className?: string;
+}
+
+function BaseTabList({className, ...props}: TabListProps) {
+  const tabListRef = useRef<HTMLUListElement>(null);
+  const {rootProps, setTabListState} = useContext(TabsContext);
+  const {value, defaultValue, onChange, orientation, disabled, ...otherRootProps} =
+    rootProps;
+
+  // Load up list state
+  const ariaProps = {
+    selectedKey: value,
+    defaultSelectedKey: defaultValue,
+    onSelectionChange: onChange,
+    isDisabled: disabled,
+    ...otherRootProps,
+    ...props,
+  };
+  const state = useTabListState(ariaProps);
+  const {tabListProps} = useTabList({orientation, ...ariaProps}, state, tabListRef);
+  useEffect(() => {
+    setTabListState(state);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [state.disabledKeys, state.selectedItem, state.selectedKey, props.children]);
+
+  // Detect tabs that overflow from the wrapper and put them in an overflow menu
+  const tabItemsRef = useRef<Record<React.Key, HTMLLIElement | null>>({});
+  const overflowTabs = useOverflowTabs({tabListRef, tabItemsRef});
+  const overflowMenuItems = useMemo(() => {
+    // Sort overflow items in the order that they appear in TabList
+    const sortedKeys = [...state.collection].map(item => item.key);
+    const sortedOverflowTabs = overflowTabs.sort(
+      (a, b) => sortedKeys.indexOf(a) - sortedKeys.indexOf(b)
+    );
+
+    return sortedOverflowTabs.map(key => {
+      const item = state.collection.getItem(key);
+      return {
+        value: key,
+        label: item.props.children,
+        disabled: item.props.disabled,
+      };
+    });
+  }, [state.collection, overflowTabs]);
+
+  return (
+    <TabListOuterWrap>
+      <TabListWrap
+        {...tabListProps}
+        orientation={orientation}
+        className={className}
+        ref={tabListRef}
+      >
+        {[...state.collection].map(item => (
+          <Tab
+            key={item.key}
+            item={item}
+            state={state}
+            orientation={orientation}
+            overflowing={orientation === 'horizontal' && overflowTabs.includes(item.key)}
+            ref={element => (tabItemsRef.current[item.key] = element)}
+          />
+        ))}
+      </TabListWrap>
+
+      {orientation === 'horizontal' && overflowMenuItems.length > 0 && (
+        <CompactSelect
+          options={overflowMenuItems}
+          value={[...state.selectionManager.selectedKeys][0]}
+          onChange={opt => state.setSelectedKey(opt.value)}
+          isDisabled={disabled}
+          placement="bottom end"
+          size="sm"
+          offset={4}
+          trigger={({props: triggerProps, ref: triggerRef}) => (
+            <OverflowMenuTrigger
+              ref={triggerRef}
+              {...triggerProps}
+              borderless
+              showChevron={false}
+              icon={<IconEllipsis />}
+              aria-label={t('More tabs')}
+            />
+          )}
+        />
+      )}
+    </TabListOuterWrap>
+  );
+}
+
+const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nodes);
+
+/**
+ * To be used as a direct child of the <Tabs /> component. See example usage
+ * in tabs.stories.js
+ */
+export function TabList({items, ...props}: TabListProps) {
+  /**
+   * Initial, unfiltered list of tab items.
+   */
+  const collection = useCollection({items, ...props}, collectionFactory);
+
+  /**
+   * Filtered list of items with hidden items (those with a `disbled` prop)
+   * removed. The `hidden` prop is useful for hiding tabs based on some
+   * conditions.
+   */
+  const parsedItems = useMemo(
+    () =>
+      [...collection]
+        .filter(item => !item.props.hidden)
+        .map(({key, props: itemProps}) => ({key, ...itemProps})),
+    [collection]
+  );
+
+  /**
+   * List of keys of disabled items (those with a `disbled` prop) to be passed
+   * into `BaseTabList`.
+   */
+  const disabledKeys = useMemo(
+    () => parsedItems.filter(item => item.disabled).map(item => item.key),
+    [parsedItems]
+  );
+
+  return (
+    <BaseTabList items={parsedItems} disabledKeys={disabledKeys} {...props}>
+      {item => <Item {...item} />}
+    </BaseTabList>
+  );
+}
+
+const TabListOuterWrap = styled('div')`
+  position: relative;
+`;
+
+const TabListWrap = styled('ul')<{orientation: Orientation}>`
+  position: relative;
+  display: grid;
+  padding: 0;
+  margin: 0;
+  list-style-type: none;
+  flex-shrink: 0;
+
+  ${p =>
+    p.orientation === 'horizontal'
+      ? `
+        grid-auto-flow: column;
+        justify-content: start;
+        gap: ${space(2)};
+        border-bottom: solid 1px ${p.theme.border};
+      `
+      : `
+        grid-auto-flow: row;
+        align-content: start;
+        gap: 1px;
+        padding-right: ${space(2)};
+        border-right: solid 1px ${p.theme.border};
+        height: 100%;
+      `};
+`;
+
+const OverflowMenuTrigger = styled(DropdownButton)`
+  position: absolute;
+  right: 0;
+  bottom: ${space(0.75)};
+  padding-left: ${space(1)};
+  padding-right: ${space(1)};
+`;

+ 85 - 0
static/app/components/tabs/tabPanels.tsx

@@ -0,0 +1,85 @@
+import {useContext, useRef} from 'react';
+import styled from '@emotion/styled';
+import {AriaTabPanelProps, useTabPanel} from '@react-aria/tabs';
+import {useCollection} from '@react-stately/collections';
+import {ListCollection} from '@react-stately/list';
+import {TabListState} from '@react-stately/tabs';
+import {CollectionBase, Node, Orientation} from '@react-types/shared';
+
+import {TabsContext} from './index';
+
+const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nodes);
+
+interface TabPanelsProps extends AriaTabPanelProps, CollectionBase<any> {
+  className?: string;
+}
+
+/**
+ * To be used as a direct child of the <Tabs /> component. See example usage
+ * in tabs.stories.js
+ */
+export function TabPanels(props: TabPanelsProps) {
+  const {
+    rootProps: {orientation, items},
+    tabListState,
+  } = useContext(TabsContext);
+
+  // Parse child tab panels from props and identify the selected panel
+  const collection = useCollection({items, ...props}, collectionFactory, {
+    suppressTextValueWarning: true,
+  });
+  const selectedPanel = tabListState
+    ? collection.getItem(tabListState.selectedKey)
+    : null;
+
+  if (!tabListState) {
+    return null;
+  }
+
+  return (
+    <TabPanel
+      {...props}
+      state={tabListState}
+      orientation={orientation}
+      key={tabListState?.selectedKey}
+    >
+      {selectedPanel?.props.children}
+    </TabPanel>
+  );
+}
+
+interface TabPanelProps extends AriaTabPanelProps {
+  orientation: Orientation;
+  state: TabListState<any>;
+  children?: React.ReactNode;
+  className?: string;
+}
+
+function TabPanel({state, orientation, className, children, ...props}: TabPanelProps) {
+  const ref = useRef<HTMLDivElement>(null);
+  const {tabPanelProps} = useTabPanel(props, state, ref);
+
+  return (
+    <TabPanelWrap
+      {...tabPanelProps}
+      orientation={orientation}
+      className={className}
+      ref={ref}
+    >
+      {children}
+    </TabPanelWrap>
+  );
+}
+
+const TabPanelWrap = styled('div')<{orientation: Orientation}>`
+  border-radius: ${p => p.theme.borderRadius};
+
+  ${p => (p.orientation === 'horizontal' ? `height: 100%;` : `width: 100%;`)};
+
+  &.focus-visible {
+    outline: none;
+    box-shadow: inset ${p => p.theme.focusBorder} 0 0 0 1px,
+      ${p => p.theme.focusBorder} 0 0 0 1px;
+    z-index: 1;
+  }
+`;

+ 223 - 5
yarn.lock

@@ -1530,6 +1530,14 @@
     "@formatjs/intl-localematcher" "0.2.21"
     tslib "^2.1.0"
 
+"@formatjs/ecma402-abstract@1.12.0":
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.12.0.tgz#2fb5e8983d5fae2fad9ec6c77aec1803c2b88d8e"
+  integrity sha512-0/wm9b7brUD40kx7KSE0S532T8EfH06Zc41rGlinoNyYXnuusR6ull2x63iFJgVXgwahm42hAW7dcYdZ+llZzA==
+  dependencies:
+    "@formatjs/intl-localematcher" "0.2.31"
+    tslib "2.4.0"
+
 "@formatjs/fast-memoize@1.2.1":
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz#e6f5aee2e4fd0ca5edba6eba7668e2d855e0fc21"
@@ -1537,6 +1545,13 @@
   dependencies:
     tslib "^2.1.0"
 
+"@formatjs/fast-memoize@1.2.6":
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.6.tgz#a442970db7e9634af556919343261a7bbe5e88c3"
+  integrity sha512-9CWZ3+wCkClKHX+i5j+NyoBVqGf0pIskTo6Xl6ihGokYM2yqSSS68JIgeo+99UIHc+7vi9L3/SDSz/dWI9SNlA==
+  dependencies:
+    tslib "2.4.0"
+
 "@formatjs/icu-messageformat-parser@2.0.15":
   version "2.0.15"
   resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.0.15.tgz#9e3ccadc582dbf076481bb95f98a689cfb10e7d5"
@@ -1546,6 +1561,23 @@
     "@formatjs/icu-skeleton-parser" "1.3.2"
     tslib "^2.1.0"
 
+"@formatjs/icu-messageformat-parser@2.1.7":
+  version "2.1.7"
+  resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.7.tgz#35dc556c13a0544cc730300c8ddb730ba7f44bd4"
+  integrity sha512-KM4ikG5MloXMulqn39Js3ypuVzpPKq/DDplvl01PE2qD9rAzFO8YtaUCC9vr9j3sRXwdHPeTe8r3J/8IJgvYEQ==
+  dependencies:
+    "@formatjs/ecma402-abstract" "1.12.0"
+    "@formatjs/icu-skeleton-parser" "1.3.13"
+    tslib "2.4.0"
+
+"@formatjs/icu-skeleton-parser@1.3.13":
+  version "1.3.13"
+  resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.13.tgz#f7e186e72ed73c3272d22a3aacb646e77368b099"
+  integrity sha512-qb1kxnA4ep76rV+d9JICvZBThBpK5X+nh1dLmmIReX72QyglicsaOmKEcdcbp7/giCWfhVs6CXPVA2JJ5/ZvAw==
+  dependencies:
+    "@formatjs/ecma402-abstract" "1.12.0"
+    tslib "2.4.0"
+
 "@formatjs/icu-skeleton-parser@1.3.2":
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.2.tgz#a8ab9c668ea7f044ceba2043ac1d872d71307e22"
@@ -1561,6 +1593,13 @@
   dependencies:
     tslib "^2.1.0"
 
+"@formatjs/intl-localematcher@0.2.31":
+  version "0.2.31"
+  resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.31.tgz#aada2b1e58211460cedba56889e3c489117eb6eb"
+  integrity sha512-9QTjdSBpQ7wHShZgsNzNig5qT3rCPvmZogS/wXZzKotns5skbXgs0I7J8cuN0PPqXyynvNVuN+iOKhNS2eb+ZA==
+  dependencies:
+    tslib "2.4.0"
+
 "@humanwhocodes/config-array@^0.10.4":
   version "0.10.4"
   resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c"
@@ -1592,6 +1631,13 @@
   dependencies:
     "@babel/runtime" "^7.6.2"
 
+"@internationalized/date@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.0.1.tgz#66332e9ca8f59b7be010ca65d946bca430ba4b66"
+  integrity sha512-E/3lASs4mAeJ2Z2ye6ab7eUD0bPUfTeNVTAv6IS+ne9UtMu9Uepb9A1U2Ae0hDr6WAlBuvUtrakaxEdYB9TV6Q==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+
 "@internationalized/message@^3.0.2":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@internationalized/message/-/message-3.0.3.tgz#bdedde42d02f935e06a1cb2f3b0dacc5228e782a"
@@ -1600,6 +1646,14 @@
     "@babel/runtime" "^7.6.2"
     intl-messageformat "^9.6.12"
 
+"@internationalized/message@^3.0.9":
+  version "3.0.9"
+  resolved "https://registry.yarnpkg.com/@internationalized/message/-/message-3.0.9.tgz#52bc20debe5296375d66ffcf56c3df5d8118a37d"
+  integrity sha512-yHQggKWUuSvj1GznVtie4tcYq+xMrkd/lTKCFHp6gG18KbIliDw+UI7sL9+yJPGuWiR083xuLyyhzqiPbNOEww==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    intl-messageformat "^10.1.0"
+
 "@internationalized/number@^3.0.2":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@internationalized/number/-/number-3.0.3.tgz#d29003dffdff54ca6f2287ec0cb77ff3d045478f"
@@ -1607,6 +1661,20 @@
   dependencies:
     "@babel/runtime" "^7.6.2"
 
+"@internationalized/number@^3.1.1":
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/@internationalized/number/-/number-3.1.1.tgz#160584316741de4381689ab759001603ee17b595"
+  integrity sha512-dBxCQKIxvsZvW2IBt3KsqrCfaw2nV6o6a8xsloJn/hjW0ayeyhKuiiMtTwW3/WGNPP7ZRyDbtuiUEjMwif1ENQ==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+
+"@internationalized/string@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@internationalized/string/-/string-3.0.0.tgz#de563871e1b19e4d0ce3246ec18d25da1a73db73"
+  integrity sha512-NUSr4u+mNu5BysXFeVWZW4kvjXylPkU/YYqaWzdNuz1eABfehFiZTEYhWAAMzI3U8DTxfqF9PM3zyhk5gcfz6w==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+
 "@istanbuljs/load-nyc-config@^1.0.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
@@ -2033,6 +2101,17 @@
     "@react-types/shared" "^3.9.0"
     clsx "^1.1.1"
 
+"@react-aria/focus@^3.8.0":
+  version "3.8.0"
+  resolved "https://registry.yarnpkg.com/@react-aria/focus/-/focus-3.8.0.tgz#b292df7e35ed1b57af43f98df8135b00c4667d17"
+  integrity sha512-XuaLFdqf/6OyILifkVJo++5k2O+wlpNvXgsJkRWn/wSmB77pZKURm2MMGiSg2u911NqY+829UrSlpmhCZrc8RA==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@react-aria/interactions" "^3.11.0"
+    "@react-aria/utils" "^3.13.3"
+    "@react-types/shared" "^3.14.1"
+    clsx "^1.1.1"
+
 "@react-aria/i18n@^3.3.3":
   version "3.3.4"
   resolved "https://registry.yarnpkg.com/@react-aria/i18n/-/i18n-3.3.4.tgz#172b8bcff0273410e67af31f7d84e49dd3ada463"
@@ -2046,6 +2125,29 @@
     "@react-aria/utils" "^3.10.0"
     "@react-types/shared" "^3.10.0"
 
+"@react-aria/i18n@^3.6.0":
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/@react-aria/i18n/-/i18n-3.6.0.tgz#0caf4d2173de411839ee55c1d4591aaf3919d6dc"
+  integrity sha512-FbdoBpMPgO0uldrpn43vCm8Xcveb46AklvUmh+zIUYRSIyIl2TKs5URTnwl9Sb1aloawoHQm2A5kASj5+TCxuA==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@internationalized/date" "^3.0.1"
+    "@internationalized/message" "^3.0.9"
+    "@internationalized/number" "^3.1.1"
+    "@internationalized/string" "^3.0.0"
+    "@react-aria/ssr" "^3.3.0"
+    "@react-aria/utils" "^3.13.3"
+    "@react-types/shared" "^3.14.1"
+
+"@react-aria/interactions@^3.11.0":
+  version "3.11.0"
+  resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.11.0.tgz#aa6118af58ff443670152393edab97e403d6d359"
+  integrity sha512-ZjK4m8u6FlV7Q9/1h9P2Ii6j/NwKR3BmTeGeBQssS2i4dV2pJeOPePnGzVQQGG8FzGQ+TcNRvZPXKaU4AlnBjw==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@react-aria/utils" "^3.13.3"
+    "@react-types/shared" "^3.14.1"
+
 "@react-aria/interactions@^3.5.1", "@react-aria/interactions@^3.6.0", "@react-aria/interactions@^3.7.0":
   version "3.7.0"
   resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.7.0.tgz#eb19c1068b557a6b6df1e1c4abef07de719e9f25"
@@ -2087,6 +2189,20 @@
     "@react-types/overlays" "^3.5.1"
     dom-helpers "^3.3.1"
 
+"@react-aria/selection@^3.10.1":
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/@react-aria/selection/-/selection-3.10.1.tgz#16368f68463923d51ee3ee7b393a2b85534dc277"
+  integrity sha512-f4T6HVp6MP0A8EHZd/gTc8irgZW8KbjZYa6sP6u4+2N0Uxwm67mlG41/IJGt1KSSk0EOulRqdAdF+Kd78hIOWg==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@react-aria/focus" "^3.8.0"
+    "@react-aria/i18n" "^3.6.0"
+    "@react-aria/interactions" "^3.11.0"
+    "@react-aria/utils" "^3.13.3"
+    "@react-stately/collections" "^3.4.3"
+    "@react-stately/selection" "^3.10.3"
+    "@react-types/shared" "^3.14.1"
+
 "@react-aria/selection@^3.7.0":
   version "3.7.1"
   resolved "https://registry.yarnpkg.com/@react-aria/selection/-/selection-3.7.1.tgz#885e02f8d424b11f1f8ca7840228c18fd26dd783"
@@ -2117,6 +2233,29 @@
   dependencies:
     "@babel/runtime" "^7.6.2"
 
+"@react-aria/ssr@^3.3.0":
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.3.0.tgz#25e81daf0c7a270a4a891159d8d984578e4512d8"
+  integrity sha512-yNqUDuOVZIUGP81R87BJVi/ZUZp/nYOBXbPsRe7oltJOfErQZD+UezMpw4vM2KRz18cURffvmC8tJ6JTeyDtaQ==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+
+"@react-aria/tabs@^3.3.1":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@react-aria/tabs/-/tabs-3.3.1.tgz#c85c2256b1ae429c7069c205d8111529f5ad4f6a"
+  integrity sha512-olKBDlh21+0TZHhO2r2wETdbkcW+9MEuiEz/pLi6PGb3b1BR/WjF8s/iCG/aLyvVed8rLmxP6ONuaXqIF8thRQ==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@react-aria/focus" "^3.8.0"
+    "@react-aria/i18n" "^3.6.0"
+    "@react-aria/interactions" "^3.11.0"
+    "@react-aria/selection" "^3.10.1"
+    "@react-aria/utils" "^3.13.3"
+    "@react-stately/list" "^3.5.3"
+    "@react-stately/tabs" "^3.2.1"
+    "@react-types/shared" "^3.14.1"
+    "@react-types/tabs" "^3.1.3"
+
 "@react-aria/utils@^3.10.0", "@react-aria/utils@^3.11.0", "@react-aria/utils@^3.8.2", "@react-aria/utils@^3.9.0":
   version "3.11.0"
   resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.11.0.tgz#215ea23a5435672a822cd713bdb8217972c5c80b"
@@ -2128,6 +2267,17 @@
     "@react-types/shared" "^3.10.1"
     clsx "^1.1.1"
 
+"@react-aria/utils@^3.13.3":
+  version "3.13.3"
+  resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.13.3.tgz#1b27912e4630f0db6a7b39eb1013f6c4f710075c"
+  integrity sha512-wqjGNFX4TrXriUU1gvCaoqRhuckdoYogUWN0iyQRkTmzvb7H/NNzQzHou5ggWAdts/NzJUInwKarBWM9hCZZbg==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@react-aria/ssr" "^3.3.0"
+    "@react-stately/utils" "^3.5.1"
+    "@react-types/shared" "^3.14.1"
+    clsx "^1.1.1"
+
 "@react-aria/visually-hidden@^3.2.3":
   version "3.2.3"
   resolved "https://registry.yarnpkg.com/@react-aria/visually-hidden/-/visually-hidden-3.2.3.tgz#4779df0a468873550afb42a7f5fcb2411d82db8d"
@@ -2146,6 +2296,25 @@
     "@babel/runtime" "^7.6.2"
     "@react-types/shared" "^3.8.0"
 
+"@react-stately/collections@^3.4.3":
+  version "3.4.3"
+  resolved "https://registry.yarnpkg.com/@react-stately/collections/-/collections-3.4.3.tgz#aaff67e697006a7c38dfb639180b79df4b202b46"
+  integrity sha512-xK3KPBCFcptpbTH/gsBT2bqVdGFruYvznBvUwzwgjb5x+vF2hXuIfaClD3/g6NckIo11MWpYGKO6iiPb1ytKeg==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@react-types/shared" "^3.14.1"
+
+"@react-stately/list@^3.5.3":
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/@react-stately/list/-/list-3.5.3.tgz#4a9473194f2a9465ec8bfe6b201036b90d0d52bf"
+  integrity sha512-qO8RhtXKdXKWqoJiwB+iw18SwY4NlMoDGX08wnesIz10blWyBotx81uR6C53Z7pAlbm4jUSO8KlJ9ACvhy/6Mg==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@react-stately/collections" "^3.4.3"
+    "@react-stately/selection" "^3.10.3"
+    "@react-stately/utils" "^3.5.1"
+    "@react-types/shared" "^3.14.1"
+
 "@react-stately/menu@^3.2.3":
   version "3.2.3"
   resolved "https://registry.yarnpkg.com/@react-stately/menu/-/menu-3.2.3.tgz#eb58e3cfc941d49637bac04aa474935f08bc7215"
@@ -2166,6 +2335,16 @@
     "@react-stately/utils" "^3.2.2"
     "@react-types/overlays" "^3.5.1"
 
+"@react-stately/selection@^3.10.3":
+  version "3.10.3"
+  resolved "https://registry.yarnpkg.com/@react-stately/selection/-/selection-3.10.3.tgz#26722b4a5986626661f25a4e636385396c6f216a"
+  integrity sha512-gOEZ3bikv5zE3mFhv1etzk3WRy8/wBtXrZ1656L6fUNwYwl3lgW8fi5KrK8QEpdy5rHYeiMy/swn5SXK9GfnMA==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@react-stately/collections" "^3.4.3"
+    "@react-stately/utils" "^3.5.1"
+    "@react-types/shared" "^3.14.1"
+
 "@react-stately/selection@^3.7.0", "@react-stately/selection@^3.9.0":
   version "3.9.0"
   resolved "https://registry.yarnpkg.com/@react-stately/selection/-/selection-3.9.0.tgz#1aead3d1a34ccc1013bc8131e93dc04af07c3ee8"
@@ -2176,6 +2355,16 @@
     "@react-stately/utils" "^3.3.0"
     "@react-types/shared" "^3.10.1"
 
+"@react-stately/tabs@^3.2.1":
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/@react-stately/tabs/-/tabs-3.2.1.tgz#bc32bd13e1816d536000848e133d6ebc44c75bc7"
+  integrity sha512-3Z5MrJrx7Ozkp5kjhYgDs8p0kNmLocsHgq1IWgBRTRdTyQB01ixEuhR1g6A+BHFLojyDB6EKBX8TrbZPsnHRdQ==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+    "@react-stately/list" "^3.5.3"
+    "@react-stately/utils" "^3.5.1"
+    "@react-types/tabs" "^3.1.3"
+
 "@react-stately/toggle@^3.2.3":
   version "3.2.3"
   resolved "https://registry.yarnpkg.com/@react-stately/toggle/-/toggle-3.2.3.tgz#a4de6edc16982990492c6c557e5194f46dacc809"
@@ -2204,6 +2393,13 @@
   dependencies:
     "@babel/runtime" "^7.6.2"
 
+"@react-stately/utils@^3.5.1":
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/@react-stately/utils/-/utils-3.5.1.tgz#502de762e5d33e892347c5f58053674e06d3bc92"
+  integrity sha512-INeQ5Er2Jm+db8Py4upKBtgfzp3UYgwXYmbU/XJn49Xw27ktuimH9e37qP3bgHaReb5L3g8IrGs38tJUpnGPHA==
+  dependencies:
+    "@babel/runtime" "^7.6.2"
+
 "@react-types/button@^3.4.1":
   version "3.4.1"
   resolved "https://registry.yarnpkg.com/@react-types/button/-/button-3.4.1.tgz#715ac9d4997c79233be4d9020b58f85936b8252b"
@@ -2238,6 +2434,18 @@
   resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.10.1.tgz#16cd3038361dee63f351fa4d0fd25d90480a149b"
   integrity sha512-U3dLJtstvOiZ8XLrWdNv9WXuruoDyfIfSXguTs9N0naDdO+M0MIbt/1Hg7Toe43ueAe56GM14IFL+S0/jhv8ow==
 
+"@react-types/shared@^3.14.1":
+  version "3.14.1"
+  resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.14.1.tgz#8fe25f729426e8043054e442eb5392364200e028"
+  integrity sha512-yPPgVRWWanXqbdxFTgJmVwx0JlcnEK3dqkKDIbVk6mxAHvEESI9+oDnHvO8IMHqF+GbrTCzVtAs0zwhYI/uHJA==
+
+"@react-types/tabs@^3.1.3":
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/@react-types/tabs/-/tabs-3.1.3.tgz#a9de35aa9a97997b5b2d3e94ec91e46920ded90d"
+  integrity sha512-RfHVSsbQiiIaJxf1qBdTt+mWj1GGC7AK/sXAQGhf3p3bi8fXBcXv2hZyPQF8uWZfb8sANtEXP8V3Xdg5SlWFGA==
+  dependencies:
+    "@react-types/shared" "^3.14.1"
+
 "@sentry-internal/global-search@^0.3.0":
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/@sentry-internal/global-search/-/global-search-0.3.0.tgz#f0fda5ae9b19cf96a979dddbd0573dc894950c63"
@@ -8784,6 +8992,16 @@ intersection-observer@^0.12.2:
   resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.12.2.tgz#4a45349cc0cd91916682b1f44c28d7ec737dc375"
   integrity sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==
 
+intl-messageformat@^10.1.0:
+  version "10.1.4"
+  resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.1.4.tgz#bf5ad48e357e3f3ab6559599296f54c175b22a92"
+  integrity sha512-tXCmWCXhbeHOF28aIf5b9ce3kwdwGyIiiSXVZsyDwksMiGn5Tp0MrMvyeuHuz4uN1UL+NfGOztHmE+6aLFp1wQ==
+  dependencies:
+    "@formatjs/ecma402-abstract" "1.12.0"
+    "@formatjs/fast-memoize" "1.2.6"
+    "@formatjs/icu-messageformat-parser" "2.1.7"
+    tslib "2.4.0"
+
 intl-messageformat@^9.6.12:
   version "9.11.0"
   resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.11.0.tgz#7b454f8385df6ffbb7ae054fc22cfee67e6f4572"
@@ -14194,16 +14412,16 @@ tslib@2.3.0:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
   integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
 
+tslib@2.4.0, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
+  integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
+
 tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.3:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
-tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
-  integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
-
 tsutils@^3.21.0:
   version "3.21.0"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"