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 + + + + 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,