Browse Source

feat(ui): Add `SegmentedControl` component (#43867)

Add a new, composable component called `SegmentedControl`:
<img width="389" alt="Screenshot 2023-01-30 at 4 28 40 PM"
src="https://user-images.githubusercontent.com/44172267/215627532-2a9d56ae-7217-460a-866a-4163254fd592.png">

[Here's a Storybook
sample.](https://storybook-pwj6lia1s.sentry.dev/?path=/story/components-segmented-control--segmented-control)

It's a better alternative to `ButtonBar` (I plan to convert existing
usages of `ButtonBar` with an "active" button to `SegmentedControl`) in
a few ways:
- **Appearance**: the `default` priority uses a neutral color palette
that is much less prominent than `ButtonBar`'s. This helps us further
remove unnecessary chrome in the app. (There is still a `primary`
priority with a more prominent palette if needed).
     - `SegmentedControl`:
<img width="221" alt="Screenshot 2023-01-30 at 4 32 25 PM"
src="https://user-images.githubusercontent.com/44172267/215628033-38b242a5-8cfe-4389-aebd-eca67883ac92.png">
     - `ButtonBar`:
<img width="221" alt="Screenshot 2023-01-30 at 4 32 50 PM"
src="https://user-images.githubusercontent.com/44172267/215628091-979591cb-9a0a-4b12-b6a8-53e95e693d44.png">
- **Accessibility**: `SegmentedControl` is implemented as a radio group,
making it clear that constituent segments are selectable and that only
one segment can be selected at a time. The selected segment is indicated
with a `checked` attribute. `ButtonBar`, on the other hand, is just a
series of `button`s with no indication of a selection state.
<img width="236" alt="Screenshot 2023-01-30 at 4 42 51 PM"
src="https://user-images.githubusercontent.com/44172267/215629317-fd37c095-bd45-4f14-886f-7fca00d820f6.png">
- **Testing:** because `SegmentedControl` is implemented as a radio
group, it's easier to work with in `RTL` tests.
     - Testing with `SegmentedControl`:
<img width="830" alt="Screenshot 2023-01-30 at 4 45 51 PM"
src="https://user-images.githubusercontent.com/44172267/215629727-274189f2-acca-43f8-ad14-fa79269cc51b.png">
     - Testing with `ButtonBar`:
<img width="905" alt="Screenshot 2023-01-30 at 4 48 58 PM"
src="https://user-images.githubusercontent.com/44172267/215630113-e7c5f5c2-6442-4fe4-bccb-90bbb6aea067.png">
    
Part of https://github.com/getsentry/sentry/issues/43865
Vu Luong 2 years ago
parent
commit
66a9ea708d

+ 32 - 0
docs-ui/stories/components/segmentedControl.stories.js

@@ -0,0 +1,32 @@
+import {SegmentedControl} from 'sentry/components/segmentedControl';
+
+export default {
+  title: 'Components/Segmented Control',
+  parameters: {
+    controls: {
+      size: 'md',
+      priority: 'default',
+    },
+  },
+  argTypes: {
+    size: {
+      options: ['md', 'sm', 'xs'],
+      control: {type: 'inline-radio'},
+    },
+    priority: {
+      options: ['default', 'primary'],
+      control: {type: 'inline-radio'},
+    },
+  },
+};
+
+export const _SegmentedControl = args => (
+  <SegmentedControl {...args} aria-label="Story" defaultValue="1">
+    <SegmentedControl.Item key="1">All Issues</SegmentedControl.Item>
+    <SegmentedControl.Item key="2">New Issues</SegmentedControl.Item>
+    <SegmentedControl.Item key="3">Unhandled</SegmentedControl.Item>
+    <SegmentedControl.Item key="4" disabled>
+      Disabled
+    </SegmentedControl.Item>
+  </SegmentedControl>
+);

+ 1 - 0
docs-ui/storybook/preview.tsx

@@ -154,6 +154,7 @@ addParameters({
           'Buttons',
           'Tables',
           'Forms',
+          'Segmented Control',
           'Data Visualization',
           'Alerts',
           'Tags',

+ 3 - 0
package.json

@@ -29,16 +29,19 @@
     "@react-aria/menu": "^3.3.0",
     "@react-aria/numberfield": "3.1.0",
     "@react-aria/overlays": "^3.7.3",
+    "@react-aria/radio": "^3.4.1",
     "@react-aria/separator": "^3.1.3",
     "@react-aria/tabs": "^3.3.3",
     "@react-aria/utils": "^3.11.0",
     "@react-stately/collections": "^3.3.4",
     "@react-stately/menu": "^3.4.2",
     "@react-stately/numberfield": "^3.0.2",
+    "@react-stately/radio": "^3.6.1",
     "@react-stately/tabs": "^3.2.2",
     "@react-stately/tree": "^3.3.4",
     "@react-types/menu": "^3.3.0",
     "@react-types/numberfield": "^3.1.0",
+    "@react-types/radio": "^3.3.1",
     "@react-types/shared": "^3.8.0",
     "@sentry-internal/global-search": "^0.4.1",
     "@sentry/integrations": "7.34.0",

+ 118 - 0
static/app/components/segmentedControl.spec.tsx

@@ -0,0 +1,118 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {SegmentedControl} from 'sentry/components/segmentedControl';
+
+describe('SegmentedControl', function () {
+  it('renders with uncontrolled value', function () {
+    const onChange = jest.fn();
+    render(
+      <SegmentedControl aria-label="Test" defaultValue="1" onChange={onChange}>
+        <SegmentedControl.Item key="1">Option 1</SegmentedControl.Item>
+        <SegmentedControl.Item key="2">Option 2</SegmentedControl.Item>
+        <SegmentedControl.Item key="3">Option 3</SegmentedControl.Item>
+      </SegmentedControl>
+    );
+
+    // Radio group (wrapper) has the correct aria-label and aria-orientation
+    expect(screen.getByRole('radiogroup')).toHaveAccessibleName('Test');
+    expect(screen.getByRole('radiogroup')).toHaveAttribute(
+      'aria-orientation',
+      'horizontal'
+    );
+
+    // All 3 radio options are rendered
+    expect(screen.getByRole('radio', {name: 'Option 1'})).toBeInTheDocument();
+    expect(screen.getByRole('radio', {name: 'Option 2'})).toBeInTheDocument();
+    expect(screen.getByRole('radio', {name: 'Option 3'})).toBeInTheDocument();
+
+    // First option is selected by default
+    expect(screen.getByRole('radio', {name: 'Option 1'})).toBeChecked();
+
+    // Click on second option
+    userEvent.click(screen.getByRole('radio', {name: 'Option 2'}));
+
+    // onChange function is called with the new key
+    expect(onChange).toHaveBeenCalledWith('2');
+  });
+
+  it('renders with controlled value', function () {
+    const {rerender} = render(
+      <SegmentedControl aria-label="Test" value="2">
+        <SegmentedControl.Item key="1">Option 1</SegmentedControl.Item>
+        <SegmentedControl.Item key="2">Option 2</SegmentedControl.Item>
+        <SegmentedControl.Item key="3">Option 3</SegmentedControl.Item>
+      </SegmentedControl>
+    );
+    // Second option is selected
+    expect(screen.getByRole('radio', {name: 'Option 2'})).toBeChecked();
+
+    rerender(
+      <SegmentedControl aria-label="Test" value="3">
+        <SegmentedControl.Item key="1">Option 1</SegmentedControl.Item>
+        <SegmentedControl.Item key="2">Option 2</SegmentedControl.Item>
+        <SegmentedControl.Item key="3">Option 3</SegmentedControl.Item>
+      </SegmentedControl>
+    );
+    // Third option is selected upon rerender
+    expect(screen.getByRole('radio', {name: 'Option 3'})).toBeChecked();
+  });
+
+  it('responds to mouse and keyboard events', function () {
+    const onChange = jest.fn();
+    render(
+      <SegmentedControl aria-label="Test" defaultValue="1" onChange={onChange}>
+        <SegmentedControl.Item key="1">Option 1</SegmentedControl.Item>
+        <SegmentedControl.Item key="2">Option 2</SegmentedControl.Item>
+        <SegmentedControl.Item key="3">Option 3</SegmentedControl.Item>
+      </SegmentedControl>
+    );
+
+    // Clicking on Option 2 selects it
+    userEvent.click(screen.getByRole('radio', {name: 'Option 2'}));
+    expect(screen.getByRole('radio', {name: 'Option 2'})).toBeChecked();
+
+    // onChange function is called with the new key
+    expect(onChange).toHaveBeenCalledWith('2');
+
+    // Pressing arrow left/right selects the previous/next option
+    userEvent.keyboard('{ArrowLeft}');
+    expect(screen.getByRole('radio', {name: 'Option 1'})).toBeChecked();
+    userEvent.keyboard('{ArrowRight}');
+    expect(screen.getByRole('radio', {name: 'Option 2'})).toBeChecked();
+
+    // Pressing arrow up/down selects the previous/next option
+    userEvent.keyboard('{ArrowUp}');
+    expect(screen.getByRole('radio', {name: 'Option 1'})).toBeChecked();
+    userEvent.keyboard('{ArrowDown}');
+    expect(screen.getByRole('radio', {name: 'Option 2'})).toBeChecked();
+
+    // When the selection state reaches the end of the list, it circles back to the
+    // first option
+    userEvent.keyboard('{ArrowRight>2}');
+    expect(screen.getByRole('radio', {name: 'Option 1'})).toBeChecked();
+  });
+
+  it('works with disabled options', function () {
+    render(
+      <SegmentedControl aria-label="Test">
+        <SegmentedControl.Item key="1">Option 1</SegmentedControl.Item>
+        <SegmentedControl.Item key="2" disabled>
+          Option 2
+        </SegmentedControl.Item>
+        <SegmentedControl.Item key="3">Option 3</SegmentedControl.Item>
+      </SegmentedControl>
+    );
+
+    expect(screen.getByRole('radio', {name: 'Option 2'})).toBeDisabled();
+
+    // Clicking on the disabled option does not select it
+    userEvent.click(screen.getByRole('radio', {name: 'Option 2'}));
+    expect(screen.getByRole('radio', {name: 'Option 2'})).not.toBeChecked();
+
+    // The disabled option is skipped when using keyboard navigation
+    userEvent.click(screen.getByRole('radio', {name: 'Option 1'}));
+    expect(screen.getByRole('radio', {name: 'Option 1'})).toBeChecked();
+    userEvent.keyboard('{ArrowRight}');
+    expect(screen.getByRole('radio', {name: 'Option 3'})).toBeChecked();
+  });
+});

+ 328 - 0
static/app/components/segmentedControl.tsx

@@ -0,0 +1,328 @@
+import {useMemo, useRef} from 'react';
+import {Theme} from '@emotion/react';
+import styled from '@emotion/styled';
+import {useRadio, useRadioGroup} from '@react-aria/radio';
+import {Item, useCollection} from '@react-stately/collections';
+import {ListCollection} from '@react-stately/list';
+import {RadioGroupState, useRadioGroupState} from '@react-stately/radio';
+import {AriaRadioGroupProps, AriaRadioProps} from '@react-types/radio';
+import {CollectionBase, ItemProps, Node} from '@react-types/shared';
+import {LayoutGroup, motion} from 'framer-motion';
+
+import InteractionStateLayer from 'sentry/components/interactionStateLayer';
+import {FormSize} from 'sentry/utils/theme';
+
+export interface SegmentedControlItemProps extends ItemProps<any> {
+  key: React.Key;
+  disabled?: boolean;
+}
+
+type Priority = 'default' | 'primary';
+export interface SegmentedControlProps extends AriaRadioGroupProps, CollectionBase<any> {
+  disabled?: AriaRadioGroupProps['isDisabled'];
+  priority?: Priority;
+  size?: FormSize;
+}
+
+const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nodes);
+
+export function SegmentedControl({
+  size = 'md',
+  priority = 'default',
+  disabled,
+  ...props
+}: SegmentedControlProps) {
+  const ref = useRef<HTMLDivElement>(null);
+
+  const collection = useCollection(props, collectionFactory);
+  const ariaProps: AriaRadioGroupProps = {
+    ...props,
+    orientation: 'horizontal',
+    isDisabled: disabled,
+  };
+  const state = useRadioGroupState(ariaProps);
+  const {radioGroupProps} = useRadioGroup(ariaProps, state);
+
+  const collectionList = useMemo(() => [...collection], [collection]);
+
+  return (
+    <GroupWrap {...radioGroupProps} size={size} priority={priority} ref={ref}>
+      <LayoutGroup id={radioGroupProps.id}>
+        {[...collectionList].map(option => (
+          <Segment
+            {...option.props}
+            key={option.key}
+            nextKey={option.nextKey}
+            prevKey={option.prevKey}
+            value={String(option.key)}
+            isDisabled={option.props.disabled}
+            state={state}
+            size={size}
+            priority={priority}
+            layoutGroupId={radioGroupProps.id}
+          >
+            {option.rendered}
+          </Segment>
+        ))}
+      </LayoutGroup>
+    </GroupWrap>
+  );
+}
+
+SegmentedControl.Item = Item as (props: SegmentedControlItemProps) => JSX.Element;
+
+interface SegmentProps extends AriaRadioProps {
+  lastKey: string;
+  layoutGroupId: string;
+  priority: Priority;
+  size: FormSize;
+  state: RadioGroupState;
+  nextKey?: string;
+  prevKey?: string;
+}
+
+function Segment({
+  state,
+  nextKey,
+  prevKey,
+  size,
+  priority,
+  layoutGroupId,
+  ...props
+}: SegmentProps) {
+  const ref = useRef<HTMLInputElement>(null);
+
+  const {inputProps} = useRadio({...props}, state, ref);
+
+  const prevOptionIsSelected = state.selectedValue === prevKey;
+  const nextOptionIsSelected = state.selectedValue === nextKey;
+
+  const isSelected = state.selectedValue === props.value;
+  const showDivider = !isSelected && !nextOptionIsSelected;
+
+  const {isDisabled} = props;
+  return (
+    <SegmentWrap size={size} isSelected={isSelected} isDisabled={isDisabled}>
+      <SegmentInput {...inputProps} ref={ref} />
+      {!isDisabled && (
+        <SegmentInteractionStateLayer
+          nextOptionIsSelected={nextOptionIsSelected}
+          prevOptionIsSelected={prevOptionIsSelected}
+        />
+      )}
+      {isSelected && (
+        <SegmentSelectionIndicator
+          layoutId={layoutGroupId}
+          transition={{type: 'tween', ease: 'circOut', duration: 0.2}}
+          priority={priority}
+          aria-hidden
+        />
+      )}
+
+      <Divider visible={showDivider} role="separator" aria-hidden />
+
+      {/* Once an item is selected, it gets a heavier font weight and becomes slightly
+      wider. To prevent layout shifts, we need a hidden container (HiddenLabel) that will
+      always have normal weight to take up constant space; and a visible, absolutely
+      positioned container (VisibleLabel) that doesn't affect the layout. */}
+      <LabelWrap>
+        <HiddenLabel aria-hidden>{props.children}</HiddenLabel>
+        <VisibleLabel isSelected={isSelected} isDisabled={isDisabled} priority={priority}>
+          {props.children}
+        </VisibleLabel>
+      </LabelWrap>
+    </SegmentWrap>
+  );
+}
+
+const GroupWrap = styled('div')<{priority: Priority; size: FormSize}>`
+  position: relative;
+  display: inline-flex;
+  background: ${p =>
+    p.priority === 'primary' ? p.theme.background : p.theme.backgroundTertiary};
+  border: solid 1px ${p => p.theme.border};
+  border-radius: ${p => p.theme.borderRadius};
+  min-width: 0;
+
+  ${p => p.theme.form[p.size]}
+`;
+
+const SegmentWrap = styled('label')<{
+  isSelected: boolean;
+  size: FormSize;
+  isDisabled?: boolean;
+}>`
+  position: relative;
+  display: block;
+  margin: 0;
+  border-radius: calc(${p => p.theme.borderRadius} - 1px);
+  cursor: ${p => (p.isDisabled ? 'default' : 'pointer')};
+  min-width: 0;
+
+  ${p => p.theme.buttonPadding[p.size]}
+  font-weight: 400;
+
+  &:hover {
+    background-color: inherit;
+
+    [role='separator'] {
+      opacity: 0;
+    }
+  }
+
+  ${p => p.isSelected && `z-index: 1;`}
+  ${p => p.isDisabled && `pointer-events: none;`}
+`;
+
+const SegmentInput = styled('input')`
+  appearance: none;
+  position: absolute;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  right: 0;
+
+  border-radius: ${p => p.theme.borderRadius};
+  transition: box-shadow 0.125s ease-out;
+  z-index: -1;
+
+  /* Reset global styles */
+  && {
+    padding: 0;
+    margin: 0;
+  }
+
+  &:focus {
+    outline: none;
+  }
+`;
+
+const SegmentInteractionStateLayer = styled(InteractionStateLayer)<{
+  nextOptionIsSelected: boolean;
+  prevOptionIsSelected: boolean;
+}>`
+  top: 0;
+  left: 0;
+  bottom: 0;
+  right: 0;
+  width: auto;
+  height: auto;
+  transform: none;
+
+  /* Prevent small gaps between adjacent pairs of selected & hovered radios (due to their
+  border radius) by extending the hovered radio's interaction state layer into and
+  behind the selected radio. */
+  transition: left 0.2s, right 0.2s;
+  ${p => p.prevOptionIsSelected && `left: calc(-${p.theme.borderRadius} - 2px);`}
+  ${p => p.nextOptionIsSelected && `right: calc(-${p.theme.borderRadius} - 2px);`}
+`;
+
+const SegmentSelectionIndicator = styled(motion.div)<{priority: Priority}>`
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: ${p =>
+    p.priority === 'primary' ? p.theme.active : p.theme.backgroundElevated};
+  border-radius: ${p =>
+    p.priority === 'primary'
+      ? p.theme.borderRadius
+      : `calc(${p.theme.borderRadius} - 1px)`};
+  box-shadow: 0 0 2px rgba(43, 34, 51, 0.16);
+
+  input.focus-visible ~ & {
+    box-shadow: ${p =>
+      p.priority === 'primary'
+        ? `0 0 0 3px ${p.theme.focus}`
+        : `0 0 0 2px ${p.theme.focusBorder}`};
+  }
+
+  ${p =>
+    p.priority === 'primary' &&
+    `
+    top: -1px;
+    bottom: -1px;
+
+    label:first-child > & {
+      left: -1px;
+    }
+    label:last-child > & {
+      right: -1px;
+    }
+  `}
+`;
+
+const LabelWrap = styled('span')`
+  position: relative;
+  display: flex;
+  line-height: 1;
+`;
+
+const HiddenLabel = styled('span')`
+  display: inline-block;
+  margin: 0 2px;
+  visibility: hidden;
+  user-select: none;
+  ${p => p.theme.overflowEllipsis}
+`;
+
+function getTextColor({
+  isDisabled,
+  isSelected,
+  priority,
+  theme,
+}: {
+  isSelected: boolean;
+  priority: Priority;
+  theme: Theme;
+  isDisabled?: boolean;
+}) {
+  if (isDisabled) {
+    return `color: ${theme.subText};`;
+  }
+
+  if (isSelected) {
+    return priority === 'primary'
+      ? `color: ${theme.white};`
+      : `color: ${theme.headingColor};`;
+  }
+
+  return `color: ${theme.textColor};`;
+}
+
+const VisibleLabel = styled('span')<{
+  isSelected: boolean;
+  priority: Priority;
+  isDisabled?: boolean;
+}>`
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: max-content;
+  transform: translate(-50%, -50%);
+  transition: color 0.25s ease-out;
+
+  user-select: none;
+  font-weight: ${p => (p.isSelected ? 600 : 400)};
+  letter-spacing: ${p => (p.isSelected ? '-0.015em' : 'inherit')};
+  text-align: center;
+  ${getTextColor}
+  ${p => p.theme.overflowEllipsis}
+`;
+
+const Divider = styled('div')<{visible: boolean}>`
+  position: absolute;
+  top: 50%;
+  right: 0;
+  width: 0;
+  height: 50%;
+  transform: translate(1px, -50%);
+  border-right: solid 1px ${p => p.theme.innerBorder};
+
+  label:last-child > & {
+    display: none;
+  }
+
+  ${p => !p.visible && `opacity: 0;`}
+`;

+ 111 - 72
yarn.lock

@@ -1546,34 +1546,34 @@
   resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
   integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
 
-"@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==
+"@internationalized/date@^3.0.2":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.0.2.tgz#1566a0bcbd82dce4dd54a5b26456bb701068cb89"
+  integrity sha512-9V1IxesP6ASZj/hYyOXOC4yPJvidbbStyWQKLCQSqhhKACMOXoo+BddXZJy47ju9mqOMpWdrJ2rTx4yTxK9oag==
   dependencies:
-    "@babel/runtime" "^7.6.2"
+    "@swc/helpers" "^0.4.14"
 
-"@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==
+"@internationalized/message@^3.0.10":
+  version "3.0.10"
+  resolved "https://registry.yarnpkg.com/@internationalized/message/-/message-3.0.10.tgz#340dcfd14ace37234e09419427c991a0c466b96e"
+  integrity sha512-vfLqEop/NH68IgqMcXJNSDqZ5Leg3EEgCxhuuSefU7vvdbptD3pwpUWXaK9igYPa+aZfUU0eqv86yqm76obtsw==
   dependencies:
-    "@babel/runtime" "^7.6.2"
+    "@swc/helpers" "^0.4.14"
     intl-messageformat "^10.1.0"
 
-"@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==
+"@internationalized/number@^3.1.1", "@internationalized/number@^3.1.2":
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/@internationalized/number/-/number-3.1.2.tgz#4482a6ac573acfb18efd354a42008af20da6c89c"
+  integrity sha512-Mbys8SGsn0ApXz3hJLNU+d95B8luoUbwnmCpBwl7d63UmYAlcT6TRDyvaS/vwdbElXLcsQJjQCu0gox2cv/Tig==
   dependencies:
-    "@babel/runtime" "^7.6.2"
+    "@swc/helpers" "^0.4.14"
 
-"@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==
+"@internationalized/string@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@internationalized/string/-/string-3.0.1.tgz#2c70a81ae5eb84f156f40330369c2469bad6d504"
+  integrity sha512-2+rHfXZ56YgsC6i3fKvBue/xatnSm0Jv+C/x4+n3wg5xAcLh4LPW3GvZ/9ifxNAz9+IWplgZHa1FRIbSuUvNWg==
   dependencies:
-    "@babel/runtime" "^7.6.2"
+    "@swc/helpers" "^0.4.14"
 
 "@istanbuljs/load-nyc-config@^1.0.0":
   version "1.1.0"
@@ -2005,49 +2005,49 @@
     "@react-stately/toggle" "^3.2.3"
     "@react-types/button" "^3.4.1"
 
-"@react-aria/focus@^3.10.0", "@react-aria/focus@^3.5.0":
-  version "3.10.0"
-  resolved "https://registry.yarnpkg.com/@react-aria/focus/-/focus-3.10.0.tgz#12d85d46f58590a915009e57bddb2d90b56f5836"
-  integrity sha512-idI7Etgh6y2BYi3X4d+EuUpzR7gPZ94Lf/0UNnVyMkDM9fzcdz/8DCBt0qKOff24HlaLE1rmREt0+iTR/qRgbA==
+"@react-aria/focus@^3.10.0", "@react-aria/focus@^3.10.1", "@react-aria/focus@^3.5.0":
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/@react-aria/focus/-/focus-3.10.1.tgz#624d02d2565151030a4156aeb17685d87f18ad58"
+  integrity sha512-HjgFUC1CznuYC7CxtBIFML6bOBxW3M3cSNtvmXU9QWlrPSwwOLkXCnfY6+UkjCc5huP4v7co4PoRDX8Vbe/cVQ==
   dependencies:
-    "@babel/runtime" "^7.6.2"
-    "@react-aria/interactions" "^3.13.0"
-    "@react-aria/utils" "^3.14.1"
+    "@react-aria/interactions" "^3.13.1"
+    "@react-aria/utils" "^3.14.2"
     "@react-types/shared" "^3.16.0"
+    "@swc/helpers" "^0.4.14"
     clsx "^1.1.1"
 
-"@react-aria/i18n@^3.3.2", "@react-aria/i18n@^3.3.3", "@react-aria/i18n@^3.6.2":
-  version "3.6.2"
-  resolved "https://registry.yarnpkg.com/@react-aria/i18n/-/i18n-3.6.2.tgz#74fa50f4b13ca7efe7738fd1960732a076ed049d"
-  integrity sha512-/G22mZQcISX6DcKLBn4j/X53y2SOnFfiD4wOEuY7sIZZDryktd+3I/QHukCnNlf0tKK3PdixQLvWa9Q1RqTSaw==
-  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.4.0"
-    "@react-aria/utils" "^3.14.1"
+"@react-aria/i18n@^3.3.2", "@react-aria/i18n@^3.3.3", "@react-aria/i18n@^3.6.2", "@react-aria/i18n@^3.6.3":
+  version "3.6.3"
+  resolved "https://registry.yarnpkg.com/@react-aria/i18n/-/i18n-3.6.3.tgz#2b4d72d0baf07b514d2c35eb6ac356d0247ea84a"
+  integrity sha512-cDWl8FXJIXsw/raWcThywBueCJ5ncoogq81wYVS6hfZVmSyncONIB3bwUL12cojmjX1VEP31sN0ujT/83QP95Q==
+  dependencies:
+    "@internationalized/date" "^3.0.2"
+    "@internationalized/message" "^3.0.10"
+    "@internationalized/number" "^3.1.2"
+    "@internationalized/string" "^3.0.1"
+    "@react-aria/ssr" "^3.4.1"
+    "@react-aria/utils" "^3.14.2"
     "@react-types/shared" "^3.16.0"
+    "@swc/helpers" "^0.4.14"
 
-"@react-aria/interactions@^3.13.0", "@react-aria/interactions@^3.5.1", "@react-aria/interactions@^3.6.0", "@react-aria/interactions@^3.7.0":
-  version "3.13.0"
-  resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.13.0.tgz#897ee2b4a7751bcf22c716ceccfc1321f427a8f2"
-  integrity sha512-gbZL+qs+6FPitR/abAramth4lqz/drEzXwzIDF6p6WyajF805mjyAgZin1/3mQygSE5BwJNDU7jMUSGRvgFyTw==
+"@react-aria/interactions@^3.13.0", "@react-aria/interactions@^3.13.1", "@react-aria/interactions@^3.5.1", "@react-aria/interactions@^3.6.0", "@react-aria/interactions@^3.7.0":
+  version "3.13.1"
+  resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.13.1.tgz#75e102c50a5c1d002cad4ee8d59677aee226186b"
+  integrity sha512-WCvfZOi1hhussVTHxVq76OR48ry13Zvp9U5hmuQufyxIUlf4hOvDk4/cbK4o4JiCs8X7C7SRzcwFM34M4NHzmg==
   dependencies:
-    "@babel/runtime" "^7.6.2"
-    "@react-aria/utils" "^3.14.1"
+    "@react-aria/utils" "^3.14.2"
     "@react-types/shared" "^3.16.0"
+    "@swc/helpers" "^0.4.14"
 
-"@react-aria/label@^3.4.3":
-  version "3.4.3"
-  resolved "https://registry.yarnpkg.com/@react-aria/label/-/label-3.4.3.tgz#7cc1821cffb0dba6c9a82c2c0fb5655ddca52ba6"
-  integrity sha512-g8NSHQKha6xOpR0cUQ6cmH/HwGJdebEbyy+c1I6VeW6me8lSF47xLnybnA6LBV4x9hJqkST6rfL/oPaBMCEKNA==
+"@react-aria/label@^3.4.3", "@react-aria/label@^3.4.4":
+  version "3.4.4"
+  resolved "https://registry.yarnpkg.com/@react-aria/label/-/label-3.4.4.tgz#b891d3cebeeffc7a1413a492d8a694083dc3253e"
+  integrity sha512-1fuYf2UctNhBy31uYN7OhdcrwzlB5GS0+C49gDkwWzccB7yr+CoOJ5UQUoVB7WBmzrc+CuzwWxSDd4OupSYIZQ==
   dependencies:
-    "@babel/runtime" "^7.6.2"
-    "@react-aria/utils" "^3.14.1"
+    "@react-aria/utils" "^3.14.2"
     "@react-types/label" "^3.7.1"
     "@react-types/shared" "^3.16.0"
+    "@swc/helpers" "^0.4.14"
 
 "@react-aria/live-announcer@^3.0.1", "@react-aria/live-announcer@^3.1.1":
   version "3.1.1"
@@ -2106,6 +2106,21 @@
     "@react-types/overlays" "^3.5.1"
     dom-helpers "^3.3.1"
 
+"@react-aria/radio@^3.4.1":
+  version "3.4.2"
+  resolved "https://registry.yarnpkg.com/@react-aria/radio/-/radio-3.4.2.tgz#bcd2deb8326f934046545fee9b2568f9d3b0655b"
+  integrity sha512-PpEsQjwkYOkSfKfnqXpBzf0FM/V2GSC0g/NG2ZAI5atDIACeic+kHCcs8fm2QzXtUDaRltNurvYdDJ+XzZ8g1g==
+  dependencies:
+    "@react-aria/focus" "^3.10.1"
+    "@react-aria/i18n" "^3.6.3"
+    "@react-aria/interactions" "^3.13.1"
+    "@react-aria/label" "^3.4.4"
+    "@react-aria/utils" "^3.14.2"
+    "@react-stately/radio" "^3.6.2"
+    "@react-types/radio" "^3.3.1"
+    "@react-types/shared" "^3.16.0"
+    "@swc/helpers" "^0.4.14"
+
 "@react-aria/selection@^3.12.0", "@react-aria/selection@^3.7.0":
   version "3.12.0"
   resolved "https://registry.yarnpkg.com/@react-aria/selection/-/selection-3.12.0.tgz#895ced39795180094ca79882c54b71441f4466e7"
@@ -2141,12 +2156,12 @@
     "@react-types/button" "^3.7.0"
     "@react-types/shared" "^3.16.0"
 
-"@react-aria/ssr@^3.4.0":
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.4.0.tgz#a2b9a170214f56e41d3c4c933d0d8fcffa07a12a"
-  integrity sha512-qzuGk14/fUyUAoW/EBwgFcuMkVNXJVGlezTgZ1HovpCZ+p9844E7MUFHE7CuzFzPEIkVeqhBNIoIu+VJJ8YCOA==
+"@react-aria/ssr@^3.4.1":
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.4.1.tgz#79e8bb621487e8f52890c917d3c734f448ba95e7"
+  integrity sha512-NmhoilMDyIfQiOSdQgxpVH2tC2u85Y0mVijtBNbI9kcDYLEiW/r6vKYVKtkyU+C4qobXhGMPfZ70PTc0lysSVA==
   dependencies:
-    "@babel/runtime" "^7.6.2"
+    "@swc/helpers" "^0.4.14"
 
 "@react-aria/tabs@^3.3.3":
   version "3.3.3"
@@ -2176,15 +2191,15 @@
     "@react-types/shared" "^3.16.0"
     "@react-types/textfield" "^3.6.1"
 
-"@react-aria/utils@^3.10.0", "@react-aria/utils@^3.11.0", "@react-aria/utils@^3.14.1", "@react-aria/utils@^3.8.2", "@react-aria/utils@^3.9.0":
-  version "3.14.1"
-  resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.14.1.tgz#36aeb077f758f1f325951b1e3376a905217edd84"
-  integrity sha512-+ynP0YlxN02MHVEBaeuTrIhBsfBYpfJn36pZm2t7ZEFbafH8DPaMGZ70ffYZXAESkWzRULXL3e79DheWOFI1qA==
+"@react-aria/utils@^3.10.0", "@react-aria/utils@^3.11.0", "@react-aria/utils@^3.14.1", "@react-aria/utils@^3.14.2", "@react-aria/utils@^3.8.2", "@react-aria/utils@^3.9.0":
+  version "3.14.2"
+  resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.14.2.tgz#3a8d0d14abab4bb1095e101ee44dc5e3e43e6217"
+  integrity sha512-3nr5gsAf/J/W+6Tu4NF3Q7m+1mXjfpXESh7TPa6UR6v3tVDTsJVMrITg2BkHN1jM8xELcl2ZxyUffOWqOXzWuA==
   dependencies:
-    "@babel/runtime" "^7.6.2"
-    "@react-aria/ssr" "^3.4.0"
-    "@react-stately/utils" "^3.5.1"
+    "@react-aria/ssr" "^3.4.1"
+    "@react-stately/utils" "^3.5.2"
     "@react-types/shared" "^3.16.0"
+    "@swc/helpers" "^0.4.14"
     clsx "^1.1.1"
 
 "@react-aria/visually-hidden@^3.2.3":
@@ -2247,6 +2262,16 @@
     "@react-stately/utils" "^3.5.1"
     "@react-types/overlays" "^3.6.4"
 
+"@react-stately/radio@^3.6.1", "@react-stately/radio@^3.6.2":
+  version "3.6.2"
+  resolved "https://registry.yarnpkg.com/@react-stately/radio/-/radio-3.6.2.tgz#6a13e3f97d130fccc1b404673cbe1414ac018621"
+  integrity sha512-qjbebR0YSkdEocLsPSzNnCsUYllWY938/5Z8mETxk4+74PJLxC3z0qjqVRq+aDO8hOgIfqSgrRRp3cJz9vIsBg==
+  dependencies:
+    "@react-stately/utils" "^3.5.2"
+    "@react-types/radio" "^3.3.1"
+    "@react-types/shared" "^3.16.0"
+    "@swc/helpers" "^0.4.14"
+
 "@react-stately/selection@^3.11.0", "@react-stately/selection@^3.11.1":
   version "3.11.1"
   resolved "https://registry.yarnpkg.com/@react-stately/selection/-/selection-3.11.1.tgz#580145bade9aebb8395ebc2edabed422d84fde0a"
@@ -2288,12 +2313,12 @@
     "@react-stately/utils" "^3.5.1"
     "@react-types/shared" "^3.15.0"
 
-"@react-stately/utils@^3.2.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==
+"@react-stately/utils@^3.2.2", "@react-stately/utils@^3.5.1", "@react-stately/utils@^3.5.2":
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/@react-stately/utils/-/utils-3.5.2.tgz#9b5f3bb9ad500bf9c5b636a42988dba60a221669"
+  integrity sha512-639gSKqamPHIEPaApb9ahVJS0HgAqNdVF3tQRoh+Ky6759Mbk6i3HqG4zk4IGQ1tVlYSYZvCckwehF7b2zndMg==
   dependencies:
-    "@babel/runtime" "^7.6.2"
+    "@swc/helpers" "^0.4.14"
 
 "@react-types/button@^3.4.1", "@react-types/button@^3.7.0":
   version "3.7.0"
@@ -2338,6 +2363,13 @@
   dependencies:
     "@react-types/shared" "^3.15.0"
 
+"@react-types/radio@^3.3.1":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@react-types/radio/-/radio-3.3.1.tgz#688570ba9901d21850a16c2aaafed5dd83e09966"
+  integrity sha512-q/x0kMvBsu6mH4bIkp/Jjrm9ff5y/p3UR0V4CmQFI7604gQd2Dt1dZMU/2HV9x70r1JfWRrDeRrVjUHVfFL5Vg==
+  dependencies:
+    "@react-types/shared" "^3.16.0"
+
 "@react-types/shared@^3.10.0", "@react-types/shared@^3.15.0", "@react-types/shared@^3.16.0", "@react-types/shared@^3.8.0", "@react-types/shared@^3.9.0":
   version "3.16.0"
   resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.16.0.tgz#cab7bf0376969d1773480ecb2d6da5aa91391db5"
@@ -3538,6 +3570,13 @@
     regenerator-runtime "^0.13.7"
     resolve-from "^5.0.0"
 
+"@swc/helpers@^0.4.14":
+  version "0.4.14"
+  resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74"
+  integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==
+  dependencies:
+    tslib "^2.4.0"
+
 "@tanstack/query-core@4.22.4":
   version "4.22.4"
   resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.22.4.tgz#aca622d2f8800a147ece5520d956a076ab92f0ea"
@@ -14550,10 +14589,10 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.3:
   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.4.1:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
-  integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
+tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.4.1:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
+  integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==
 
 tsutils@^3.21.0:
   version "3.21.0"