diff --git a/documentation-site/components/yard/config/radio-v2.ts b/documentation-site/components/yard/config/radio-v2.ts
new file mode 100644
index 0000000000..ff16929b26
--- /dev/null
+++ b/documentation-site/components/yard/config/radio-v2.ts
@@ -0,0 +1,190 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import pick from "just-pick";
+
+import { Radio, RadioGroup, ALIGN, LABEL_PLACEMENT } from "baseui/radio-v2";
+import { PropTypes } from "react-view";
+import type { TConfig } from "../types";
+
+import { changeHandlers } from "./common/common";
+
+const RadioGroupConfig: TConfig = {
+ componentName: "RadioGroup",
+ imports: {
+ "baseui/radio-v2": { named: ["RadioGroup"] },
+ },
+ scope: {
+ Radio,
+ RadioGroup,
+ ALIGN,
+ LABEL_PLACEMENT,
+ },
+ theme: [
+ "tickFill",
+ "tickFillHover",
+ "tickFillActive",
+ "tickFillSelected",
+ "tickFillSelectedHover",
+ "tickFillSelectedHoverActive",
+ "tickFillError",
+ "tickFillErrorHover",
+ "tickFillErrorHoverActive",
+ "tickFillErrorSelected",
+ "tickFillErrorSelectedHover",
+ "tickFillErrorSelectedHoverActive",
+ "tickFillDisabled",
+ "tickBorder",
+ "tickBorderError",
+ "tickMarkFill",
+ "tickMarkFillError",
+ "tickMarkFillDisabled",
+ ],
+ props: {
+ value: {
+ value: "2",
+ type: PropTypes.String,
+ description: "Passed to the input element value attribute",
+ stateful: true,
+ },
+ onChange: {
+ value: "e => setValue(e.currentTarget.value)",
+ type: PropTypes.Function,
+ description: "Handler for change events on trigger element.",
+ propHook: {
+ what: "e.target.value",
+ into: "value",
+ },
+ },
+ children: {
+ value: `One
+
+ Two
+
+
+ Three
+`,
+ type: PropTypes.ReactNode,
+ description: "Radios within the RadioGroup",
+ imports: {
+ "baseui/radio-v2": { named: ["Radio"] },
+ },
+ },
+ name: {
+ value: "number",
+ type: PropTypes.String,
+ description:
+ "String value for the name of RadioGroup, it is used to group buttons. If missed default is random ID string.",
+ hidden: false,
+ },
+ align: {
+ value: "ALIGN.vertical",
+ type: PropTypes.Enum,
+ options: ALIGN,
+ description: "How to position radio-v2 buttons in the group.",
+ imports: {
+ "baseui/radio-v2": {
+ named: ["ALIGN"],
+ },
+ },
+ },
+ labelPlacement: {
+ value: "LABEL_PLACEMENT.right",
+ type: PropTypes.Enum,
+ options: LABEL_PLACEMENT,
+ enumName: "LABEL_PLACEMENT",
+ description:
+ "How to position radio-v2 label relative to the radio itself.",
+ imports: {
+ "baseui/radio-v2": {
+ named: ["LABEL_PLACEMENT"],
+ },
+ },
+ },
+ disabled: {
+ value: false,
+ type: PropTypes.Boolean,
+ description:
+ "Disabled all radio-v2 group from being changed. To disable some of radios provide disabled flag in each of them.",
+ },
+ error: {
+ value: false,
+ type: PropTypes.Boolean,
+ description: "Sets radio-v2 group into error state.",
+ },
+ required: {
+ value: false,
+ type: PropTypes.Boolean,
+ description: "Set if the control is required to be checked.",
+ hidden: true,
+ },
+ autoFocus: {
+ value: false,
+ type: PropTypes.Boolean,
+ description: "Set to be focused (active) on selectedchecked radio-v2.",
+ hidden: true,
+ },
+ containsInteractiveElement: {
+ value: false,
+ type: PropTypes.Boolean,
+ description:
+ "Indicates the radio-v2 contains an interactive element, and the default label behavior should be prevented for child elements.",
+ hidden: true,
+ },
+ "aria-label": {
+ value: undefined,
+ type: PropTypes.String,
+ description: `Sets aria-label attribute.`,
+ hidden: true,
+ },
+ "aria-labelledby": {
+ value: undefined,
+ type: PropTypes.String,
+ description: `Sets aria-labelledby attribute.`,
+ hidden: true,
+ },
+ ...pick(changeHandlers, [
+ "onBlur",
+ "onFocus",
+ "onMouseLeave",
+ "onMouseEnter",
+ ]),
+ overrides: {
+ value: undefined,
+ type: PropTypes.Custom,
+ description: "Lets you customize all aspects of the component.",
+ custom: {
+ names: ["Root"],
+ sharedProps: {
+ $isFocused: {
+ type: PropTypes.Boolean,
+ description: "True when the component is focused.",
+ },
+ $isHovered: {
+ type: PropTypes.Boolean,
+ description: "True when the component is hovered.",
+ },
+ $isActive: {
+ type: PropTypes.Boolean,
+ description: "True when the component is active.",
+ },
+ $error: "error",
+ $checked: {
+ type: PropTypes.Boolean,
+ description: "True when the component is active.",
+ },
+ $required: "required",
+ $disabled: "disabled",
+ },
+ },
+ },
+ },
+};
+
+export default RadioGroupConfig;
diff --git a/documentation-site/examples/radio-v2/basic.tsx b/documentation-site/examples/radio-v2/basic.tsx
new file mode 100644
index 0000000000..11a3185f61
--- /dev/null
+++ b/documentation-site/examples/radio-v2/basic.tsx
@@ -0,0 +1,22 @@
+import * as React from "react";
+import { Radio, RadioGroup } from "baseui/radio-v2";
+
+export default function Example() {
+ const [value, setValue] = React.useState("1");
+ return (
+ setValue(e.target.value)}
+ value={value}
+ >
+ First
+
+ Second
+
+ Third
+
+ );
+}
diff --git a/documentation-site/examples/radio-v2/disabled.tsx b/documentation-site/examples/radio-v2/disabled.tsx
new file mode 100644
index 0000000000..194a728950
--- /dev/null
+++ b/documentation-site/examples/radio-v2/disabled.tsx
@@ -0,0 +1,11 @@
+import * as React from "react";
+import { Radio, RadioGroup } from "baseui/radio-v2";
+
+export default function Example() {
+ return (
+
+ Checked
+ Unchecked
+
+ );
+}
diff --git a/documentation-site/examples/radio-v2/error.tsx b/documentation-site/examples/radio-v2/error.tsx
new file mode 100644
index 0000000000..b0d8ab73da
--- /dev/null
+++ b/documentation-site/examples/radio-v2/error.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+import { Radio, RadioGroup } from "baseui/radio-v2";
+
+export default function Example() {
+ const [value, setValue] = React.useState("1");
+ return (
+ setValue(e.target.value)}
+ value={value}
+ >
+ First
+ Second
+ Third
+
+ );
+}
diff --git a/documentation-site/examples/radio-v2/horizontal-align.tsx b/documentation-site/examples/radio-v2/horizontal-align.tsx
new file mode 100644
index 0000000000..4ee9fa2bc7
--- /dev/null
+++ b/documentation-site/examples/radio-v2/horizontal-align.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+import { Radio, RadioGroup } from "baseui/radio-v2";
+
+export default function Example() {
+ const [value, setValue] = React.useState("1");
+ return (
+ setValue(e.target.value)}
+ value={value}
+ >
+ First
+ Second
+ Third
+
+ );
+}
diff --git a/documentation-site/examples/radio-v2/overrides.tsx b/documentation-site/examples/radio-v2/overrides.tsx
new file mode 100644
index 0000000000..95acd82046
--- /dev/null
+++ b/documentation-site/examples/radio-v2/overrides.tsx
@@ -0,0 +1,31 @@
+import * as React from "react";
+import { Radio, RadioGroup } from "baseui/radio-v2";
+import { RadioOverrides } from "baseui/radio-v2";
+
+export default function Example() {
+ const [value, setValue] = React.useState("1");
+ const radioOverrides: RadioOverrides = {
+ RadioMarkOuter: {
+ style: ({ $theme }) => ({
+ backgroundColor: $theme.colors.positive,
+ }),
+ },
+ };
+ return (
+ setValue(e.target.value)}
+ value={value}
+ >
+
+ Custom label for value 1
+
+
+ Custom label for value 2
+
+
+ Custom label for value 3
+
+
+ );
+}
diff --git a/documentation-site/examples/radio-v2/stateful.tsx b/documentation-site/examples/radio-v2/stateful.tsx
new file mode 100644
index 0000000000..32763ce629
--- /dev/null
+++ b/documentation-site/examples/radio-v2/stateful.tsx
@@ -0,0 +1,12 @@
+import * as React from "react";
+import { Radio, StatefulRadioGroup } from "baseui/radio-v2";
+
+export default function Example() {
+ return (
+
+ First
+ Second
+ Third
+
+ );
+}
diff --git a/documentation-site/pages/components/radio-v2.mdx b/documentation-site/pages/components/radio-v2.mdx
new file mode 100644
index 0000000000..01eee72945
--- /dev/null
+++ b/documentation-site/pages/components/radio-v2.mdx
@@ -0,0 +1,62 @@
+import Example from "../../components/example";
+import Layout from "../../components/layout";
+import Exports from "../../components/exports";
+
+import Basic from "examples/radio-v2/basic.tsx";
+import Stateful from "examples/radio-v2/stateful.tsx";
+import HorizontalAlign from "examples/radio-v2/horizontal-align.tsx";
+import Error from "examples/radio-v2/error.tsx";
+import Customization from "examples/radio-v2/overrides.tsx";
+import Disabled from "examples/radio-v2/disabled.tsx";
+
+import { Radio, StatefulRadioGroup } from "baseui/radio-v2";
+import * as RadioExports from "baseui/radio-v2";
+
+import Yard from "../../components/yard/index";
+import radioYardConfig from "../../components/yard/config/radio-v2";
+
+export default Layout;
+
+# Radio
+
+
+
+## Notes
+
+- Radios are used when only one choice may be selected in a series of options.
+- Label placement is default to right to the control. Description label is optional.
+- We support both stateful and stateless radiogroup components to group radios in addition to some built in features: keyboard navigation, a11y support.
+
+## Examples
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+As with many of our components, there is also an [uncontrolled](https://reactjs.org/docs/uncontrolled-components.html) version, `StatefulRadioGroup`, which manages its own state.
+
+
diff --git a/documentation-site/pages/components/radio.mdx b/documentation-site/pages/components/radio.mdx
index f13e4d37a8..54bed5948e 100644
--- a/documentation-site/pages/components/radio.mdx
+++ b/documentation-site/pages/components/radio.mdx
@@ -15,8 +15,22 @@ import * as RadioExports from "baseui/radio";
import Yard from "../../components/yard/index";
import radioYardConfig from "../../components/yard/config/radio";
+import { Notification, KIND as NOTIFICATION_KIND } from "baseui/notification";
+
export default Layout;
+
+ This Radio component is about to be deprecated in favor of the new Radio-v2
+ component. The supported apis keep almost same, so the migration just requires
+ minimal efforts(most likely only change import path) but gains much more
+ benefits. We encouraged you to start new implementations with Radio-v2
+ depending your use case and design. Any migrations from Radio to Radio-v2 or
+ Switch are recommended.
+
+
# Radio
diff --git a/documentation-site/routes.jsx b/documentation-site/routes.jsx
index 50c375396e..7b493a9642 100644
--- a/documentation-site/routes.jsx
+++ b/documentation-site/routes.jsx
@@ -123,6 +123,10 @@ const routes = [
title: 'Radio',
itemId: '/components/radio',
},
+ {
+ title: 'Radio-v2',
+ itemId: '/components/radio-v2',
+ },
{
title: 'Slider',
itemId: '/components/slider',
diff --git a/package-lock.json b/package-lock.json
index 9cbe76bc31..438f8ceae0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "baseui",
- "version": "16.1.0",
+ "version": "17.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "baseui",
- "version": "16.1.0",
+ "version": "17.1.0",
"license": "MIT",
"dependencies": {
"@date-io/date-fns": "^2.13.1",
diff --git a/package.json b/package.json
index 31373a7a58..2407b53a2b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "baseui",
- "version": "17.0.0",
+ "version": "17.1.0",
"description": "A React Component library implementing the Base design language",
"keywords": [
"react",
diff --git a/src/map-marker/__tests__/floating-route-marker-map.scenario.tsx b/src/map-marker/__tests__/floating-route-marker-map.scenario.tsx
index 5e604777aa..a09951a250 100644
--- a/src/map-marker/__tests__/floating-route-marker-map.scenario.tsx
+++ b/src/map-marker/__tests__/floating-route-marker-map.scenario.tsx
@@ -20,7 +20,7 @@ import { Button } from '../../button';
import { useStyletron } from '../../styles';
import { getMapStyle } from './map-style';
import ReactMapGL, { Marker } from 'react-map-gl';
-import { Slider } from 'src/slider';
+import { Slider } from '../../slider';
const uberHq = {
latitude: 37.768495131168336,
diff --git a/src/radio-v2/__tests__/radio-align.scenario.tsx b/src/radio-v2/__tests__/radio-align.scenario.tsx
new file mode 100644
index 0000000000..0d1f4819cc
--- /dev/null
+++ b/src/radio-v2/__tests__/radio-align.scenario.tsx
@@ -0,0 +1,52 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+
+import { StatefulRadioGroup, Radio, ALIGN } from '../../radio-v2';
+import { HeadingSmall, LabelMedium } from '../../typography';
+
+export function Scenario() {
+ return (
+
+ Vertical Alignment (Default)
+
+
+ First
+
+
+ Second
+
+
+ Third
+
+
+
+ Horizontal Alignment
+
+
+ First
+
+
+ Second
+
+
+ Third
+
+
+
+ );
+}
diff --git a/src/radio-v2/__tests__/radio-interactive-label.scenario.tsx b/src/radio-v2/__tests__/radio-interactive-label.scenario.tsx
new file mode 100644
index 0000000000..237e911918
--- /dev/null
+++ b/src/radio-v2/__tests__/radio-interactive-label.scenario.tsx
@@ -0,0 +1,33 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+
+import { StatefulRadioGroup, Radio, ALIGN } from '../../radio-v2';
+
+import { FormControl } from '../../form-control';
+import { Select } from '../../select';
+
+export function Scenario() {
+ return (
+
+
+ One
+
+ Two
+
+ Three
+
+
+
+
+
+ );
+}
diff --git a/src/radio-v2/__tests__/radio-label-placement.scenario.tsx b/src/radio-v2/__tests__/radio-label-placement.scenario.tsx
new file mode 100644
index 0000000000..169bdcf8e0
--- /dev/null
+++ b/src/radio-v2/__tests__/radio-label-placement.scenario.tsx
@@ -0,0 +1,133 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+
+import {
+ StatefulRadioGroup,
+ Radio,
+ LABEL_PLACEMENT,
+ ALIGN,
+} from '../../radio-v2';
+import { HeadingSmall, LabelMedium } from '../../typography';
+
+export function Scenario() {
+ return (
+
+ Label Placement: Top
+
+
+ First
+
+
+ Second
+
+
+ Third
+
+
+
+
+ Label Placement: Right (Default)
+
+
+
+ First
+
+
+ Second
+
+
+ Third
+
+
+
+ Label Placement: Bottom
+
+
+ First
+
+
+ Second
+
+
+ Third
+
+
+
+ Label Placement: Left
+
+
+ First
+
+
+ Second
+
+
+ Third
+
+
+
+ );
+}
diff --git a/src/radio-v2/__tests__/radio-states.scenario.tsx b/src/radio-v2/__tests__/radio-states.scenario.tsx
new file mode 100644
index 0000000000..10cde192a2
--- /dev/null
+++ b/src/radio-v2/__tests__/radio-states.scenario.tsx
@@ -0,0 +1,178 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+
+import { Radio, ALIGN } from '../../radio-v2';
+import { StyledTable, StyledHeadCell, StyledBodyCell } from '../../table-grid';
+import { HeadingMedium, LabelMedium } from '../../typography';
+
+export function Scenario() {
+ return (
+
+ Radio illustrations - States
+
+ Note: This story is not suitable for accessibility testing. The
+ standalone radios have to be handled by a baseui RadioGroup or product
+ team with their own logic on isFocused, isFocusVisible, checked,
+ tabIndex, etc.
+
+
+ {/* Header Row */}
+
+ State
+
+
+ Unchecked
+
+
+ Checked
+
+
+ {/* Standalone radio Row */}
+
+ Standalone
+
+
+
+
+
+
+
+
+ {/* Default Row */}
+
+ Default
+
+
+ Option
+
+
+ Option
+
+
+ {/* Disabled Row */}
+
+ Disabled
+
+
+
+ Option
+
+
+
+
+ Option
+
+
+
+ {/* Error Row */}
+
+ Error
+
+
+
+ Option
+
+
+
+
+ Option
+
+
+
+ {/* Disabled + Error Row */}
+
+ Disabled + Error
+
+
+
+ Option
+
+
+
+
+ Option
+
+
+
+ {/* With Label Row */}
+
+ With Label
+
+
+ Radio Label
+
+
+ Radio Label
+
+
+ {/* With Label + Description Row */}
+
+ With Label + Description
+
+
+
+ Radio Label
+
+
+
+
+ Radio Label
+
+
+
+ {/* With Long Label + Description Row */}
+
+ With Long Label + Description
+
+
+
+ Radio Label with a very long text to illustrate wrapping and layout
+ behavior
+
+
+
+
+ Radio Label with a very long text to illustrate wrapping and layout
+ behavior
+
+
+
+ {/* With Long Label + Long Description Row */}
+
+
+ With Long Label + Long Description(set as horizontal alignment)
+
+
+
+
+ Radio Label with a very long text to illustrate wrapping and layout
+ behavior and it is designed to span more than 3 lines in order to
+ test the truncation style applied to the label in this specific
+ scenario
+
+
+
+
+ Radio Label with a very long text to illustrate wrapping and layout
+ behavior and it is designed to span more than 3 lines in order to
+ test the truncation style applied to the label in this specific
+ scenario
+
+
+
+
+ );
+}
diff --git a/src/radio-v2/__tests__/radio-v2.stories.tsx b/src/radio-v2/__tests__/radio-v2.stories.tsx
new file mode 100644
index 0000000000..09f30655a3
--- /dev/null
+++ b/src/radio-v2/__tests__/radio-v2.stories.tsx
@@ -0,0 +1,18 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import React from 'react';
+import { Scenario as RadioAlign } from './radio-align.scenario';
+import { Scenario as RadioContainsInteractiveLabel } from './radio-interactive-label.scenario';
+import { Scenario as RadioLabelPlacement } from './radio-label-placement.scenario';
+import { Scenario as RadioStates } from './radio-states.scenario';
+import { Scenario as RadioDefault } from './radio.scenario';
+
+export const Align = () => ;
+export const ContainsInteractiveLabel = () => ;
+export const LabelPlacement = () => ;
+export const Radio = () => ;
+export const States = () => ;
diff --git a/src/radio-v2/__tests__/radio.scenario.tsx b/src/radio-v2/__tests__/radio.scenario.tsx
new file mode 100644
index 0000000000..57beaa8302
--- /dev/null
+++ b/src/radio-v2/__tests__/radio.scenario.tsx
@@ -0,0 +1,53 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+
+import { StatefulRadioGroup, RadioGroup, Radio } from '../../radio-v2';
+
+import { FormControl } from '../../form-control';
+
+export function Scenario() {
+ const [value, setValue] = React.useState('1');
+
+ return (
+
+
+
+ First
+
+ Second
+
+ Third
+
+
+
+
+ setValue(e.target.value)}
+ value={value}
+ >
+ First
+
+ Second
+
+ Third
+
+
+
+ );
+}
diff --git a/src/radio-v2/__tests__/radio.test.tsx b/src/radio-v2/__tests__/radio.test.tsx
new file mode 100644
index 0000000000..931a0d3de6
--- /dev/null
+++ b/src/radio-v2/__tests__/radio.test.tsx
@@ -0,0 +1,127 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+/* eslint-env node */
+import * as React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+
+import { ALIGN, Radio, StatefulRadioGroup } from "..";
+import { Select } from "../../select";
+
+describe("Radio", () => {
+ it("calls provided handlers", async () => {
+ const user = userEvent.setup();
+ const mockOnBlur = jest.fn();
+ const mockOnChange = jest.fn();
+ const mockOnFocus = jest.fn();
+ const mockOnMouseEnter = jest.fn();
+ const mockOnMouseLeave = jest.fn();
+ const mockOnMouseDown = jest.fn();
+ const mockOnMouseUp = jest.fn();
+
+ const clearMocks = () => {
+ mockOnBlur.mockClear();
+ mockOnChange.mockClear();
+ mockOnFocus.mockClear();
+ mockOnMouseEnter.mockClear();
+ mockOnMouseLeave.mockClear();
+ mockOnMouseDown.mockClear();
+ mockOnMouseUp.mockClear();
+ };
+
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector("input");
+ if (input) {
+ await user.click(input);
+ input.blur();
+ }
+ expect(mockOnFocus).toHaveBeenCalledTimes(1);
+ expect(mockOnBlur).toHaveBeenCalledTimes(1);
+
+ clearMocks();
+ const root = screen.getByTestId("root");
+ await user.hover(root);
+ expect(mockOnMouseEnter).toHaveBeenCalledTimes(1);
+
+ clearMocks();
+ await user.unhover(root);
+ expect(mockOnMouseLeave).toHaveBeenCalledTimes(1);
+
+ clearMocks();
+ await user.pointer({ keys: "[MouseLeft>]", target: root });
+ expect(mockOnMouseDown).toHaveBeenCalledTimes(1);
+
+ clearMocks();
+ await user.pointer({ keys: "[/MouseLeft]", target: root });
+ expect(mockOnMouseUp).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not select radio when interactive element is present", async () => {
+ const user = userEvent.setup();
+ const { container } = render(
+
+
+
+
+ Two
+ ,
+ );
+
+ const select = container.querySelector('[data-baseweb="select"]');
+ const radio = screen.getByDisplayValue("one");
+ expect(radio).not.toBeChecked();
+ if (select) await user.click(select);
+ expect(radio).not.toBeChecked();
+ });
+
+ it("displays description if provided", () => {
+ const description = "foo";
+ render(bar);
+ expect(screen.getByText(description)).toBeInTheDocument();
+ });
+
+ it("only fires one click event", async () => {
+ const user = userEvent.setup();
+ const onAncestorClick = jest.fn();
+ render(
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
+
+ label
+
,
+ );
+ const label = screen.getByText("label").closest("label");
+ if (label) await user.click(label);
+ expect(onAncestorClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders RadioBackplate component", () => {
+ render(
+
+ label
+ ,
+ );
+ const backplate = screen.getByTestId("radio-backplate");
+ expect(backplate).toBeInTheDocument();
+ });
+});
diff --git a/src/radio-v2/__tests__/radiogroup.test.tsx b/src/radio-v2/__tests__/radiogroup.test.tsx
new file mode 100644
index 0000000000..13f11e42bb
--- /dev/null
+++ b/src/radio-v2/__tests__/radiogroup.test.tsx
@@ -0,0 +1,68 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from "react";
+import { render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+
+import { RadioGroup, Radio } from "..";
+
+describe("radio-group", () => {
+ it("sets expected child radio checked", () => {
+ render(
+
+ one
+ two
+ three
+ ,
+ );
+
+ const radios = screen.getAllByRole("radio") as HTMLInputElement[];
+ radios.forEach((radio, index) => {
+ if (index === 2) {
+ expect(radio).toBeChecked();
+ } else {
+ expect(radio).not.toBeChecked();
+ }
+ });
+ });
+
+ it("disables children if disabled", () => {
+ render(
+
+ one
+ two
+ three
+ ,
+ );
+
+ const radios = screen.getAllByRole("radio") as HTMLInputElement[];
+ radios.forEach((radio) => {
+ expect(radio).toBeDisabled();
+ });
+ });
+
+ it("disabled prop on children take priority", () => {
+ render(
+
+
+ one
+
+ two
+ three
+ ,
+ );
+
+ const radios = screen.getAllByRole("radio") as HTMLInputElement[];
+ radios.forEach((radio, index) => {
+ if (index === 0) {
+ expect(radio).toBeDisabled();
+ } else {
+ expect(radio).not.toBeDisabled();
+ }
+ });
+ });
+});
diff --git a/src/radio-v2/__tests__/stateful-radiogroup.test.tsx b/src/radio-v2/__tests__/stateful-radiogroup.test.tsx
new file mode 100644
index 0000000000..932b3b6c98
--- /dev/null
+++ b/src/radio-v2/__tests__/stateful-radiogroup.test.tsx
@@ -0,0 +1,110 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+
+import { StatefulRadioGroup, Radio } from "..";
+
+describe("radio-group", () => {
+ it("sets clicked child checked", async () => {
+ const user = userEvent.setup();
+ render(
+
+ one
+ two
+ three
+ ,
+ );
+
+ const radiogroup = screen.getByRole("radiogroup");
+ expect(radiogroup).toBeInTheDocument();
+
+ const radios = screen.getAllByRole("radio") as HTMLInputElement[];
+ radios.forEach((radio) => {
+ expect(radio).not.toBeChecked();
+ });
+
+ await user.click(radios[0]);
+ expect(radios[0]).toBeChecked();
+ });
+});
+
+describe("radio-group focus and a11y management", () => {
+ it("sets the initial state", () => {
+ render(
+
+ one
+ two
+ three
+ ,
+ );
+
+ const one = screen.getByDisplayValue("1");
+ const two = screen.getByDisplayValue("2");
+ const three = screen.getByDisplayValue("3");
+
+ expect(one).not.toBeChecked();
+ expect(two).not.toBeChecked();
+ expect(three).toBeChecked();
+
+ expect(one).toHaveAttribute("tabindex", "-1");
+ expect(two).toHaveAttribute("tabindex", "-1");
+ expect(three).toHaveAttribute("tabindex", "0");
+
+ expect(one).not.toHaveFocus();
+ expect(two).not.toHaveFocus();
+ expect(three).not.toHaveFocus();
+ });
+
+ it("focus selected radio", async () => {
+ render(
+
+ one
+ two
+ three
+ ,
+ );
+
+ const one = screen.getByDisplayValue("1");
+ const two = screen.getByDisplayValue("2");
+ const three = screen.getByDisplayValue("3");
+ await userEvent.tab();
+
+ expect(one).not.toHaveFocus();
+ expect(two).not.toHaveFocus();
+ expect(three).toHaveFocus();
+ });
+
+ it("focus first radio if no value is selected", async () => {
+ render(
+
+ one
+ two
+ three
+ ,
+ );
+
+ const one = screen.getByDisplayValue("1");
+ const two = screen.getByDisplayValue("2");
+ const three = screen.getByDisplayValue("3");
+
+ expect(one).not.toBeChecked();
+ expect(two).not.toBeChecked();
+ expect(three).not.toBeChecked();
+
+ expect(one).toHaveAttribute("tabindex", "0");
+ expect(two).toHaveAttribute("tabindex", "-1");
+ expect(three).toHaveAttribute("tabindex", "-1");
+ await userEvent.tab();
+
+ expect(one).toHaveFocus();
+ expect(two).not.toHaveFocus();
+ expect(three).not.toHaveFocus();
+ });
+});
diff --git a/src/radio-v2/constants.ts b/src/radio-v2/constants.ts
new file mode 100644
index 0000000000..cfa9886713
--- /dev/null
+++ b/src/radio-v2/constants.ts
@@ -0,0 +1,21 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+export const STATE_TYPE = {
+ change: 'CHANGE',
+} as const;
+
+export const ALIGN = {
+ vertical: 'vertical',
+ horizontal: 'horizontal',
+} as const;
+
+export const LABEL_PLACEMENT = {
+ top: 'top',
+ right: 'right',
+ bottom: 'bottom',
+ left: 'left',
+} as const;
diff --git a/src/radio-v2/index.ts b/src/radio-v2/index.ts
new file mode 100644
index 0000000000..e125f3774c
--- /dev/null
+++ b/src/radio-v2/index.ts
@@ -0,0 +1,24 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+export { default as StatefulRadioGroup } from './stateful-radiogroup';
+export { default as StatefulContainer } from './stateful-radiogroup-container';
+export { default as RadioGroup } from './radiogroup';
+// Styled elements
+export {
+ Root as StyledRoot,
+ Label as StyledLabel,
+ Input as StyledInput,
+ Description as StyledDescription,
+ RadioBackplate as StyledRadioBackplate,
+ RadioMarkInner as StyledRadioMarkInner,
+ RadioMarkOuter as StyledRadioMarkOuter,
+ RadioGroupRoot as StyledRadioGroupRoot,
+} from './styled-components';
+export { default as Radio } from './radio';
+export * from './types';
+export { ALIGN, LABEL_PLACEMENT } from './constants';
+export { RadioGroupContext, useRadioGroupContext } from './radio-context';
diff --git a/src/radio-v2/radio-context.tsx b/src/radio-v2/radio-context.tsx
new file mode 100644
index 0000000000..60d9d2933c
--- /dev/null
+++ b/src/radio-v2/radio-context.tsx
@@ -0,0 +1,47 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+import type { ChangeEvent } from 'react';
+import type { Align, LabelPlacement } from './types';
+
+export type RadioGroupContextValue = {
+ // Registration (required - needed for auto-indexing)
+ // Register with the group to get a unique index for this radio.
+ // Each radio needs its own index so the RadioGroup can track which radio triggered
+ // events (focus, blur, keyboard navigation). The index is used to identify the source
+ // of events when communicating with the parent RadioGroup.
+ // Also tabIndex may need this information if no radio is selected in the group. (First radio gets tabIndex 0)
+ registerRadio: () => number;
+
+ // Shared props (optional - have sensible defaults)
+ name?: string;
+ selectedValue?: string;
+ disabled?: boolean;
+ autoFocus?: boolean;
+ error?: boolean;
+ required?: boolean;
+ align?: Align;
+ labelPlacement?: LabelPlacement;
+
+ // Focus state (optional)
+ focusedIndex?: number;
+ isFocusVisible?: boolean;
+
+ // Callbacks (optional)
+ onChange?: (e: ChangeEvent) => void;
+ onMouseEnter?: (e: ChangeEvent) => void;
+ onMouseLeave?: (e: ChangeEvent) => void;
+ onFocus?: (e: ChangeEvent, index: number) => void;
+ onBlur?: (e: ChangeEvent, index: number) => void;
+};
+
+export const RadioGroupContext =
+ React.createContext(null);
+
+export function useRadioGroupContext() {
+ return React.useContext(RadioGroupContext);
+}
diff --git a/src/radio-v2/radio.tsx b/src/radio-v2/radio.tsx
new file mode 100644
index 0000000000..8f0c1c3518
--- /dev/null
+++ b/src/radio-v2/radio.tsx
@@ -0,0 +1,282 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+import { getOverrides } from '../helpers/overrides';
+import {
+ Root as StyledRoot,
+ Label as StyledLabel,
+ LabelWrapper as StyledLabelWrapper,
+ Input as StyledInput,
+ RadioBackplate as StyledRadioBackplate,
+ RadioMarkInner as StyledRadioMarkInner,
+ RadioMarkOuter as StyledRadioMarkOuter,
+ Description as StyledDescription,
+} from './styled-components';
+import type { RadioProps, LabelPlacement } from './types';
+import type { ChangeEvent } from 'react';
+import { ALIGN, LABEL_PLACEMENT } from './constants';
+import { useRadioGroupContext } from './radio-context';
+
+function isLabelTopLeft(labelPlacement: LabelPlacement) {
+ return (
+ labelPlacement === LABEL_PLACEMENT.top ||
+ labelPlacement === LABEL_PLACEMENT.left
+ );
+}
+
+function isLabelBottomRight(labelPlacement: LabelPlacement) {
+ return (
+ labelPlacement === LABEL_PLACEMENT.bottom ||
+ labelPlacement === LABEL_PLACEMENT.right
+ );
+}
+
+const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
+
+const Radio: React.FC = (props) => {
+ const groupContext = useRadioGroupContext();
+
+ // Register with the group to get a unique index for this radio.
+ // Each radio needs its own index so the RadioGroup can track which radio triggered
+ // events (focus, blur, keyboard navigation). The index is used to identify the source
+ // of events when communicating with the parent RadioGroup.
+ // Also tabIndex may need this information if no radio is selected in the group. (First radio gets tabIndex 0)
+ const [radioIndex] = React.useState(
+ () => groupContext?.registerRadio() ?? -1,
+ );
+
+ const {
+ overrides = {},
+ containsInteractiveElement = false,
+ checked: checkedProp = false,
+ disabled: disabledProp = false,
+ autoFocus: autoFocusProp = false,
+ inputRef = React.createRef(),
+ align: alignProp = ALIGN.vertical,
+ labelPlacement: labelPlacementProp = LABEL_PLACEMENT.right,
+ error: errorProp = false,
+ onChange: onChangeProp = () => {},
+ onMouseEnter: onMouseEnterProp = () => {},
+ onMouseLeave: onMouseLeaveProp = () => {},
+ onMouseDown = () => {},
+ onMouseUp = () => {},
+ onFocus: onFocusProp = () => {},
+ onBlur: onBlurProp = () => {},
+ children,
+ description,
+ isFocused: isFocusedProp,
+ isFocusVisible: isFocusVisibleProp,
+ name: nameProp,
+ required: requiredProp,
+ tabIndex: tabIndexProp,
+ value,
+ } = props;
+
+ // Group context takes precedence to ensure consistency
+ // For disabled, allow local override (individual radio can be disabled even if group isn't)
+ const name = groupContext?.name ?? nameProp;
+ const disabled = disabledProp || (groupContext?.disabled ?? false);
+ const autoFocus = groupContext?.autoFocus ?? autoFocusProp;
+ const error = groupContext?.error ?? errorProp;
+ const required = groupContext?.required ?? requiredProp;
+ const align = groupContext?.align ?? alignProp;
+ const labelPlacement = groupContext?.labelPlacement ?? labelPlacementProp;
+
+ // Compute derived values
+ const checked = groupContext
+ ? groupContext.selectedValue === value
+ : checkedProp;
+ const isFocused = groupContext
+ ? groupContext.focusedIndex === radioIndex
+ : isFocusedProp;
+ const isFocusVisible = groupContext?.isFocusVisible ?? isFocusVisibleProp;
+
+ // Compute tabIndex based on context or prop
+ let tabIndex = tabIndexProp;
+ if (groupContext) {
+ // First radio (index 0) gets tabIndex 0 if no value selected, otherwise checked radio gets 0
+ tabIndex =
+ (radioIndex === 0 && !groupContext.selectedValue) || checked ? '0' : '-1';
+ }
+
+ // Event handlers - use group handlers if in a group
+ const onChange = groupContext?.onChange ?? onChangeProp;
+ const onMouseEnter = groupContext?.onMouseEnter ?? onMouseEnterProp;
+ const onMouseLeave = groupContext?.onMouseLeave ?? onMouseLeaveProp;
+
+ const onFocus = React.useCallback(
+ (e: ChangeEvent) => {
+ if (groupContext?.onFocus) {
+ groupContext.onFocus(e, radioIndex);
+ } else {
+ onFocusProp(e);
+ }
+ },
+ [groupContext, radioIndex, onFocusProp],
+ );
+
+ const onBlur = React.useCallback(
+ (e: ChangeEvent) => {
+ if (groupContext?.onBlur) {
+ groupContext.onBlur(e, radioIndex);
+ } else {
+ onBlurProp(e);
+ }
+ },
+ [groupContext, radioIndex, onBlurProp],
+ );
+
+ const [isActive, setIsActive] = React.useState(false);
+ const [isHovered, setIsHovered] = React.useState(false);
+
+ React.useEffect(() => {
+ if (autoFocus && inputRef?.current) {
+ inputRef.current.focus();
+ }
+ }, [autoFocus, inputRef]);
+
+ const handleMouseEnter = React.useCallback(
+ (e: ChangeEvent) => {
+ setIsHovered(true);
+ onMouseEnter && onMouseEnter(e);
+ },
+ [onMouseEnter],
+ );
+
+ const handleMouseLeave = React.useCallback(
+ (e: ChangeEvent) => {
+ setIsHovered(false);
+ onMouseLeave && onMouseLeave(e);
+ },
+ [onMouseLeave],
+ );
+
+ const handleMouseDown = React.useCallback(
+ (e: ChangeEvent) => {
+ setIsActive(true);
+ onMouseDown && onMouseDown(e);
+ },
+ [onMouseDown],
+ );
+
+ const handleMouseUp = React.useCallback(
+ (e: ChangeEvent) => {
+ setIsActive(false);
+ onMouseUp && onMouseUp(e);
+ },
+ [onMouseUp],
+ );
+
+ const [Root, rootProps] = getOverrides(overrides.Root, StyledRoot);
+ const [Label, labelProps] = getOverrides(overrides.Label, StyledLabel);
+ const [LabelWrapper, labelWrapperProps] = getOverrides(
+ overrides.LabelWrapper,
+ StyledLabelWrapper,
+ );
+ const [Input, inputProps] = getOverrides(overrides.Input, StyledInput);
+ const [Description, descriptionProps] = getOverrides(
+ overrides.Description,
+ StyledDescription,
+ );
+ const [RadioBackplate, radioBackplateProps] = getOverrides(
+ overrides.RadioBackplate,
+ StyledRadioBackplate,
+ );
+ const [RadioMarkInner, radioMarkInnerProps] = getOverrides(
+ overrides.RadioMarkInner,
+ StyledRadioMarkInner,
+ );
+ const [RadioMarkOuter, radioMarkOuterProps] = getOverrides(
+ overrides.RadioMarkOuter,
+ StyledRadioMarkOuter,
+ );
+
+ const sharedProps = {
+ $align: align,
+ $checked: checked,
+ $disabled: disabled,
+ $hasDescription: !!description,
+ $isActive: isActive,
+ $error: error,
+ $isFocused: isFocused,
+ $isFocusVisible: isFocused && isFocusVisible,
+ $isHovered: isHovered,
+ $labelPlacement: labelPlacement,
+ $required: required,
+ $value: value,
+ };
+
+ const label = (
+
+ );
+
+ const labelWithDescription = (
+
+ {label}
+ {!!description && (
+
+ {description}
+
+ )}
+
+ );
+
+ return (
+
+ {!!children && isLabelTopLeft(labelPlacement) && labelWithDescription}
+
+
+
+
+
+
+
+
+ {!!children && isLabelBottomRight(labelPlacement) && labelWithDescription}
+
+ );
+};
+
+export default Radio;
diff --git a/src/radio-v2/radiogroup.tsx b/src/radio-v2/radiogroup.tsx
new file mode 100644
index 0000000000..ce6ed71301
--- /dev/null
+++ b/src/radio-v2/radiogroup.tsx
@@ -0,0 +1,148 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+
+import { getOverrides } from '../helpers/overrides';
+
+import { RadioGroupRoot as StyledRadioGroupRoot } from './styled-components';
+import type { RadioGroupProps } from './types';
+import { isFocusVisible } from '../utils/focusVisible';
+import { ALIGN, LABEL_PLACEMENT } from './constants';
+import { RadioGroupContext } from './radio-context';
+
+import type { ChangeEvent } from 'react';
+
+const StatelessRadioGroup: React.FC = (props) => {
+ const {
+ overrides = {},
+ name = '',
+ value = '',
+ disabled = false,
+ autoFocus = false,
+ labelPlacement = LABEL_PLACEMENT.right,
+ align = ALIGN.vertical,
+ error = false,
+ required = false,
+ onChange = () => {},
+ onMouseEnter = () => {},
+ onMouseLeave = () => {},
+ onFocus = () => {},
+ onBlur = () => {},
+ children,
+ id,
+ } = props;
+
+ const [isFocusVisibleState, setIsFocusVisibleState] = React.useState(false);
+ const [focusedRadioIndex, setFocusedRadioIndex] = React.useState(-1);
+
+ // Registration counter for child radios
+ const radioIndexRef = React.useRef(0);
+
+ // Reset the registration counter before each render
+ React.useLayoutEffect(() => {
+ radioIndexRef.current = 0;
+ });
+
+ const registerRadio = React.useCallback(() => {
+ const index = radioIndexRef.current;
+ radioIndexRef.current += 1;
+ return index;
+ }, []);
+
+ const handleFocus = React.useCallback(
+ (event: ChangeEvent, index: number) => {
+ if (isFocusVisible(event)) {
+ setIsFocusVisibleState(true);
+ }
+ setFocusedRadioIndex(index);
+ onFocus && onFocus(event);
+ },
+ [onFocus],
+ );
+
+ const handleBlur = React.useCallback(
+ (event: ChangeEvent, index: number) => {
+ if (isFocusVisibleState !== false) {
+ setIsFocusVisibleState(false);
+ }
+ setFocusedRadioIndex(-1);
+ onBlur && onBlur(event);
+ },
+ [isFocusVisibleState, onBlur],
+ );
+
+ const [RadioGroupRoot, radioGroupRootProps] = getOverrides(
+ overrides.RadioGroupRoot,
+ StyledRadioGroupRoot,
+ );
+
+ const contextValue = React.useMemo(
+ () => ({
+ name,
+ selectedValue: value,
+ disabled,
+ autoFocus,
+ error,
+ required,
+ align,
+ labelPlacement,
+ focusedIndex: focusedRadioIndex,
+ isFocusVisible: isFocusVisibleState,
+ onChange,
+ onMouseEnter,
+ onMouseLeave,
+ onFocus: handleFocus,
+ onBlur: handleBlur,
+ registerRadio,
+ }),
+ [
+ name,
+ value,
+ disabled,
+ autoFocus,
+ error,
+ required,
+ align,
+ labelPlacement,
+ focusedRadioIndex,
+ isFocusVisibleState,
+ onChange,
+ onMouseEnter,
+ onMouseLeave,
+ handleFocus,
+ handleBlur,
+ registerRadio,
+ ],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+StatelessRadioGroup.displayName = 'StatelessRadioGroup';
+
+export default StatelessRadioGroup;
diff --git a/src/radio-v2/stateful-radiogroup-container.ts b/src/radio-v2/stateful-radiogroup-container.ts
new file mode 100644
index 0000000000..0d9fb5a6ed
--- /dev/null
+++ b/src/radio-v2/stateful-radiogroup-container.ts
@@ -0,0 +1,63 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+import { STATE_TYPE } from './constants';
+import type { StatefulContainerProps, StateReducer, State } from './types';
+import type { ChangeEvent } from 'react';
+
+const defaultStateReducer: StateReducer = (type, nextState) => nextState;
+
+type Action = {
+ type: string;
+ event: ChangeEvent;
+};
+
+const StatefulRadioGroupContainer: React.FC = (
+ props,
+) => {
+ const {
+ children = (childProps: {}) => null,
+ initialState = { value: '' },
+ stateReducer = defaultStateReducer,
+ onChange = () => {},
+ ...restProps
+ } = props;
+
+ const reducer = React.useCallback(
+ (currentState: State, action: Action): State => {
+ const { type, event } = action;
+
+ // Calculate next state based on action type
+ let nextState = currentState;
+ if (type === STATE_TYPE.change) {
+ nextState = { value: event.target.value };
+ }
+
+ // Allow user's stateReducer to intercept and modify the state change
+ return stateReducer(type, nextState, currentState, event);
+ },
+ [stateReducer],
+ );
+
+ const [state, dispatch] = React.useReducer(reducer, initialState);
+
+ const handleChange = React.useCallback(
+ (e: ChangeEvent) => {
+ dispatch({ type: STATE_TYPE.change, event: e });
+ onChange(e);
+ },
+ [onChange],
+ );
+
+ return children({
+ ...restProps,
+ value: state.value,
+ onChange: handleChange,
+ });
+};
+
+export default StatefulRadioGroupContainer;
diff --git a/src/radio-v2/stateful-radiogroup.tsx b/src/radio-v2/stateful-radiogroup.tsx
new file mode 100644
index 0000000000..4edcb73e14
--- /dev/null
+++ b/src/radio-v2/stateful-radiogroup.tsx
@@ -0,0 +1,26 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import * as React from 'react';
+import StatefulContainer from './stateful-radiogroup-container';
+import RadioGroup from './radiogroup';
+import type { RadioGroupProps, StatefulRadioGroupProps } from './types';
+
+const StatefulRadioGroup = function (
+ props: StatefulRadioGroupProps &
+ Omit,
+) {
+ const { children, ...restProps } = props;
+ return (
+
+ {(childrenProps: RadioGroupProps) => (
+ {children}
+ )}
+
+ );
+};
+
+export default StatefulRadioGroup;
diff --git a/src/radio-v2/styled-components.ts b/src/radio-v2/styled-components.ts
new file mode 100644
index 0000000000..c676788f3c
--- /dev/null
+++ b/src/radio-v2/styled-components.ts
@@ -0,0 +1,247 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import { styled, type Theme } from '../styles';
+import type { StyleProps } from './types';
+import { ALIGN, LABEL_PLACEMENT } from './constants';
+import {
+ getOverlayColor,
+ getFocusOutlineStyle,
+} from '../utils/get-shared-styles';
+
+type StylePropsWithTheme = StyleProps & {
+ $theme: Theme;
+};
+
+function getOuterColor(props: StylePropsWithTheme) {
+ const {
+ $theme: { colors },
+ $disabled,
+ $error,
+ } = props;
+ return $disabled
+ ? colors.contentStateDisabled
+ : $error
+ ? colors.tagRedBorderSecondarySelected
+ : colors.contentPrimary;
+}
+
+function getLabelColor(props: StylePropsWithTheme) {
+ const { $disabled, $theme } = props;
+ const { colors } = $theme;
+ return $disabled ? colors.contentSecondary : colors.contentPrimary;
+}
+
+export const RadioGroupRoot = styled<'div', StyleProps>('div', (props) => {
+ const { $align, $labelPlacement } = props;
+
+ return {
+ display: 'flex',
+ flexWrap: 'wrap',
+ flexDirection: $align === ALIGN.horizontal ? 'row' : 'column',
+ alignItems:
+ $align === ALIGN.horizontal && $labelPlacement === LABEL_PLACEMENT.top
+ ? 'flex-end'
+ : 'flex-start',
+ columnGap: props.$theme.sizing.scale600,
+ rowGap: props.$theme.sizing.scale300,
+ };
+});
+
+RadioGroupRoot.displayName = 'RadioGroupRoot';
+
+export const Root = styled<'label', StyleProps>('label', (props) => {
+ const { $disabled, $labelPlacement, $theme } = props;
+ const { sizing } = $theme;
+ const isHorizontalLabelPlacement =
+ $labelPlacement === LABEL_PLACEMENT.left ||
+ $labelPlacement === LABEL_PLACEMENT.right;
+ const isVerticalLabelPlacement =
+ $labelPlacement === LABEL_PLACEMENT.top ||
+ $labelPlacement === LABEL_PLACEMENT.bottom;
+
+ return {
+ flexDirection: isVerticalLabelPlacement ? 'column' : 'row',
+ display: 'inline-flex',
+ alignItems: isHorizontalLabelPlacement ? 'flex-start' : 'center',
+ cursor: $disabled ? 'not-allowed' : 'pointer',
+ ...(isVerticalLabelPlacement
+ ? { rowGap: sizing.scale100 }
+ : isHorizontalLabelPlacement
+ ? { columnGap: sizing.scale100 }
+ : {}),
+ '@media (pointer: coarse)': {
+ // Increase target size for touch devices to meet the minimum touch target size of 48x48dp
+ padding: sizing.scale300,
+ },
+ };
+});
+
+Root.displayName = 'Root';
+
+export const RadioBackplate = styled<'div', StyleProps>('div', (props) => {
+ const { sizing } = props.$theme;
+ const { hoveredColor, pressedColor } = getOverlayColor(props);
+
+ return {
+ position: 'relative',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ boxSizing: 'border-box',
+ borderRadius: sizing.scale300,
+ paddingTop: sizing.scale300,
+ paddingBottom: sizing.scale300,
+ paddingLeft: sizing.scale300,
+ paddingRight: sizing.scale300,
+ '@media (hover: hover)': {
+ ':hover': {
+ backgroundColor: hoveredColor,
+ },
+ },
+ ':active': {
+ backgroundColor: pressedColor,
+ },
+
+ minHeight: sizing.scale900,
+ minWidth: sizing.scale900,
+ };
+});
+
+RadioBackplate.displayName = 'RadioBackplate';
+
+export const RadioMarkInner = styled<'div', StyleProps>('div', (props) => {
+ const { $theme, $checked, $isActive, $isHovered, $disabled } = props;
+ const { animation, colors } = $theme;
+ const { hoveredColor, pressedColor } = getOverlayColor(props);
+
+ return {
+ borderTopLeftRadius: '50%',
+ borderTopRightRadius: '50%',
+ borderBottomRightRadius: '50%',
+ borderBottomLeftRadius: '50%',
+ height: $checked ? '5px' : '100%',
+ width: $checked ? '5px' : '100%',
+ backgroundColor: colors.contentInversePrimary,
+ // Add overlay for hover and pressed states with box-shadow (instead of background-color) to prevent RadioMarkOuter's background color bleeding through
+ // We also want this visual effect when hovering/pressing on backplate so using $isActive, $isHovered instead of css selectors.
+ boxShadow: !$disabled
+ ? $isHovered
+ ? `inset 999px 999px 0px ${hoveredColor}`
+ : $isActive
+ ? `inset 999px 999px 0px ${pressedColor}`
+ : 'none'
+ : 'none',
+ // Animations for height and width changes - transition when checking/unchecking radio
+ transitionProperty: 'width, height',
+ transitionDuration: animation.timing200,
+ transitionTimingFunction: animation.easeOutCurve,
+ };
+});
+
+RadioMarkInner.displayName = 'RadioMarkInner';
+
+export const RadioMarkOuter = styled<'div', StyleProps>('div', (props) => {
+ const { $theme, $checked, $isFocusVisible } = props;
+ const { sizing } = $theme;
+ const focusRingStyle = getFocusOutlineStyle($theme);
+
+ return {
+ display: 'flex',
+ height: '17px',
+ width: '17px',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ boxSizing: 'border-box',
+ borderWidth: sizing.scale0,
+ borderStyle: 'solid',
+ borderColor: getOuterColor(props),
+ backgroundColor: getOuterColor(props),
+ borderTopLeftRadius: '50%',
+ borderTopRightRadius: '50%',
+ borderBottomRightRadius: '50%',
+ borderBottomLeftRadius: '50%',
+ verticalAlign: 'middle',
+ ...($checked && $isFocusVisible ? focusRingStyle : {}),
+ };
+});
+
+RadioMarkOuter.displayName = 'RadioMarkOuter';
+
+export const LabelWrapper = styled<'div', StyleProps>('div', (props) => {
+ const { $labelPlacement, $theme, $align } = props;
+ const { sizing } = $theme;
+
+ return {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start', // don't stretch label and description(for example, when label is a select component)
+ ...($labelPlacement === LABEL_PLACEMENT.left ||
+ $labelPlacement === LABEL_PLACEMENT.right
+ ? { paddingTop: sizing.scale300 }
+ : {}), // add top padding when label is on left/right to align with radio center
+ rowGap: sizing.scale0,
+ //Horizontal: Not recommended when the text needs to wrap. If necessary, let the text wrap to 3 lines maximum and truncate.
+ ...($align === ALIGN.horizontal ? { maxWidth: '240px' } : {}),
+ };
+});
+
+LabelWrapper.displayName = 'LabelWrapper';
+
+// This style is design from horizontal alignment
+// Not recommended when the text needs to wrap. If necessary, let the text wrap to 3 lines maximum and truncate.
+const max3LinesStyle = {
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ display: '-webkit-box',
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: 'vertical' as const,
+};
+
+export const Label = styled<'div', StyleProps>('div', (props) => {
+ const {
+ $theme: { typography },
+ $align,
+ } = props;
+ return {
+ verticalAlign: 'middle',
+ color: getLabelColor(props),
+ ...typography.LabelSmall,
+ ...($align === ALIGN.horizontal ? max3LinesStyle : {}),
+ };
+});
+
+Label.displayName = 'Label';
+
+// tricky style for focus event cause display: none doesn't work
+export const Input = styled('input', {
+ width: 0,
+ height: 0,
+ marginTop: 0,
+ marginRight: 0,
+ marginBottom: 0,
+ marginLeft: 0,
+ paddingTop: 0,
+ paddingRight: 0,
+ paddingBottom: 0,
+ paddingLeft: 0,
+ clip: 'rect(0 0 0 0)',
+ position: 'absolute',
+});
+
+Input.displayName = 'Input';
+
+export const Description = styled<'div', StyleProps>('div', (props) => {
+ const { $theme, $align } = props;
+ return {
+ ...$theme.typography.ParagraphSmall,
+ color: $theme.colors.contentSecondary,
+ cursor: 'auto',
+ ...($align === ALIGN.horizontal ? max3LinesStyle : {}),
+ };
+});
+Description.displayName = 'Description';
diff --git a/src/radio-v2/types.ts b/src/radio-v2/types.ts
new file mode 100644
index 0000000000..6fd07471ff
--- /dev/null
+++ b/src/radio-v2/types.ts
@@ -0,0 +1,211 @@
+/*
+Copyright (c) Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+import type * as React from 'react';
+import type { Override } from '../helpers/overrides';
+import type { ALIGN, LABEL_PLACEMENT } from './constants';
+
+export type LabelPlacement = keyof typeof LABEL_PLACEMENT;
+export type Align = keyof typeof ALIGN;
+
+export type RadioOverrides = {
+ RadioBackplate?: Override;
+ RadioMarkInner?: Override;
+ RadioMarkOuter?: Override;
+ Label?: Override;
+ LabelWrapper?: Override;
+ Root?: Override;
+ Input?: Override;
+ Description?: Override;
+};
+
+export type RadioGroupOverrides = {
+ RadioGroupRoot?: Override;
+};
+
+export type DefaultProps = Partial;
+
+export type RadioGroupProps = {
+ /** Id of element which contains a related caption */
+ 'aria-describedby'?: string;
+ /** Id of element which contains a related error message */
+ 'aria-errormessage'?: string;
+ /**
+ * Used to define a string that labels the radio group. Use this prop if the label is not
+ * visible on screen. If the label is visible, use the 'aria-labeledby' prop instead.
+ */
+ 'aria-label'?: string;
+ /**
+ * Establishes a relationship between the radio group and its label. Screen readers use this
+ * attribute to catalog the object on a page so that users can navigate between them.
+ */
+ 'aria-labelledby'?: string;
+ // This prop will be deprecated in the next major update. Pass overrides to the 'Radio' component instead.
+ overrides?: RadioGroupOverrides;
+ /** As `children` in React native approach represents radio buttons inside of Radio Group. Can use `Radio` from this package. */
+ children?: Array;
+ /** The value of radio button, which is preselected. */
+ value?: string;
+ /** Disabled all radio group from being changed. To disable some of radios provide disabled flag in each of them. */
+ disabled?: boolean;
+ /** Set if the control is required to be checked. */
+ required?: boolean;
+ /** Sets radio group into error state. */
+ error?: boolean;
+ /** Set to be focused (active) on selected\checked radio. */
+ autoFocus?: boolean;
+ /** How to position radio buttons in the group. */
+ align?: Align;
+ /** String value for the name of RadioGroup, it is used to group buttons. If missed default is random ID string. */
+ name?: string;
+ /** How to position the label relative to the radio itself. */
+ labelPlacement?: LabelPlacement;
+ /** Unique id for RadioGroup, help ARIA to identify element */
+ id?: string;
+ /** Handler for change events on trigger element. */
+ onChange?: (e: React.ChangeEvent) => unknown;
+ /** Handler for mouseenter events on trigger element. */
+ onMouseEnter?: (e: React.ChangeEvent) => unknown;
+ /** Handler for mouseleave events on trigger element. */
+ onMouseLeave?: (e: React.ChangeEvent) => unknown;
+ /** Handler for focus events on trigger element. */
+ onFocus?: (e: React.ChangeEvent) => unknown;
+ /** Handler for blur events on trigger element. */
+ onBlur?: (e: React.ChangeEvent) => unknown;
+};
+
+export type State = {
+ value?: string;
+};
+
+export type RadioProps = {
+ /** Id of element which contains a related caption */
+ 'aria-describedby'?: string;
+ /** Id of element which contains a related error message */
+ 'aria-errormessage'?: string;
+ /**
+ * Used to define a string that labels the radio. Use this prop if the label is not
+ * visible on screen. If the label is visible, use the 'aria-labeledby' prop instead.
+ */
+ 'aria-label'?: string;
+ /**
+ * Establishes a relationship between the radio and its label. Screen readers use this
+ * attribute to catalog the object on a page so that users can navigate between them.
+ */
+ 'aria-labelledby'?: string;
+ /** Focus the radio on initial render. */
+ autoFocus?: boolean;
+ /** How the radio will be displayed along with its description. Controls spacing */
+ align?: Align;
+ /** Check or uncheck the control. */
+ checked?: boolean;
+ /** Label of radio. */
+ children?: React.ReactNode;
+ /** Indicates if this radio children contain an interactive element (prevents the label from moving focus from the child element to the radio button) */
+ containsInteractiveElement?: boolean;
+ /** Add more detail about a radio element. */
+ description?: string;
+ /** Disable the checkbox from being changed. */
+ disabled?: boolean;
+ /** Used to get a ref to the input element. Useful for programmatically focusing the input */
+ inputRef?: React.RefObject;
+ /** Renders checkbox in errored state. */
+ error?: boolean;
+ /** Is radio focused / active? */
+ isFocused?: boolean;
+ /** Is parent RadioGroup focused by keyboard? */
+ isFocusVisible?: boolean;
+ /** How to position the label relative to the checkbox itself. */
+ labelPlacement?: LabelPlacement;
+ /** Passed to the input element name attribute */
+ name?: string;
+ /** handler for blur events on trigger element. */
+ onBlur?: (e: React.ChangeEvent) => unknown;
+ /** Handler for change events on trigger element. */
+ onChange?: (e: React.ChangeEvent) => unknown;
+ /** handler for focus events on trigger element. */
+ onFocus?: (e: React.ChangeEvent) => unknown;
+ /** Handler for mouseenter events on trigger element. */
+ onMouseEnter?: (e: React.ChangeEvent) => unknown;
+ /** Handler for mouseleave events on trigger element. */
+ onMouseLeave?: (e: React.ChangeEvent) => unknown;
+ /** Handler for mousedown events on trigger element. */
+ onMouseDown?: (e: React.ChangeEvent) => unknown;
+ /** Handler for mouseup events on trigger element. */
+ onMouseUp?: (e: React.ChangeEvent) => unknown;
+ overrides?: RadioOverrides;
+ /** Marks the checkbox as required. */
+ required?: boolean;
+ /** Passed to the input element value attribute */
+ value?: string;
+ /** Passed to the input element, typically managed by RadioGroup */
+ tabIndex?: string;
+};
+
+export type RadioState = {
+ isActive: boolean;
+ isHovered: boolean;
+};
+
+export type StateReducer = (
+ stateType: string,
+ nextState: State,
+ currentState: State,
+ event: React.ChangeEvent,
+) => State;
+
+export type StatelessState = {
+ isFocusVisible: boolean;
+ focusedRadioIndex: number;
+};
+
+export type DefaultStatefulProps = {
+ initialState: State;
+ children?: (props: RadioGroupProps) => React.ReactNode;
+ stateReducer: StateReducer;
+ onChange: (e: React.ChangeEvent) => unknown;
+};
+
+export type StatefulContainerProps = {
+ overrides?: RadioGroupOverrides;
+ /** Should return `RadioGroup` instance with standard or customized inner elements. */
+ children?: (props: RadioGroupProps) => React.ReactNode;
+ /** Initial state populated into the component */
+ initialState?: State;
+ /** Reducer function to manipulate internal state updates. */
+ stateReducer?: StateReducer;
+ /** Handler for change events on trigger element. */
+ onChange?: (e: React.ChangeEvent) => unknown;
+ /** Set to be focused (active) on selected\checked radio. */
+ autoFocus?: boolean;
+};
+
+export type StatefulRadioGroupProps = {
+ overrides?: RadioGroupOverrides;
+ /** A list of `Radio` components. */
+ children?: Array;
+ /** Initial state populated into the component */
+ initialState?: State;
+ /** Set to be focused (active) on selected\checked radio. */
+ autoFocus?: boolean;
+ /** Handler for change events on trigger element. */
+ onChange?: (e: React.ChangeEvent) => unknown;
+};
+
+export type StyleProps = {
+ $align?: Align;
+ $checked: boolean;
+ $disabled: boolean;
+ $hasDescription: boolean;
+ $isActive: boolean;
+ $error: boolean;
+ $isFocused: boolean;
+ $isFocusVisible: boolean;
+ $isHovered: boolean;
+ $labelPlacement: LabelPlacement;
+ $required: boolean;
+ $value: string;
+};
diff --git a/src/utils/get-shared-styles.ts b/src/utils/get-shared-styles.ts
index c6cfda8c11..0b8a2b238d 100644
--- a/src/utils/get-shared-styles.ts
+++ b/src/utils/get-shared-styles.ts
@@ -24,13 +24,13 @@ export const getOverlayColor = ({
const hoveredColor = $disabled
? 'transparent'
: $error
- ? colors.hoverNegativeAlpha
- : colors.hoverOverlayAlpha;
+ ? colors.hoverNegativeAlpha
+ : colors.hoverOverlayAlpha;
const pressedColor = $disabled
? 'transparent'
: $error
- ? colors.pressedNegativeAlpha
- : colors.pressedOverlayAlpha;
+ ? colors.pressedNegativeAlpha
+ : colors.pressedOverlayAlpha;
return {
hoveredColor,