diff --git a/src/components/InputLabel/InputLabel-v2.module.css b/src/components/InputLabel/InputLabel-v2.module.css new file mode 100644 index 0000000000..b25742268f --- /dev/null +++ b/src/components/InputLabel/InputLabel-v2.module.css @@ -0,0 +1,29 @@ +@import '../../design-tokens/mixins.css'; + +/*------------------------------------*\ + # INPUT LABEL +\*------------------------------------*/ + +/** + * Text labeling the input component. + */ +.label { + color: var(--eds-theme-color-text-utility-neutral-primary); +} + +/** + * Disabled variant of the input label. + */ +.label--disabled { + color: var(--eds-theme-color-text-utility-disabled-primary); +} + +/** + * Input label size variants. + */ +.label--md { + font: var(--eds-theme-typography-body-sm); +} +.label--lg { + font: var(--eds-theme-typography-body-md); +} diff --git a/src/components/InputLabel/InputLabel-v2.stories.ts b/src/components/InputLabel/InputLabel-v2.stories.ts new file mode 100644 index 0000000000..ca9ee08072 --- /dev/null +++ b/src/components/InputLabel/InputLabel-v2.stories.ts @@ -0,0 +1,55 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import type { ComponentProps } from 'react'; + +import { InputLabel } from './InputLabel-v2'; + +export default { + title: 'Components/V2/InputLabel', + component: InputLabel, + args: { + children: 'Label', + }, + parameters: { + badges: ['intro-1.0'], + }, +} as Meta; + +type Args = ComponentProps; + +export const Default: StoryObj = {}; + +export const Medium: StoryObj = { + args: { + size: 'md', + }, +}; + +export const LargeDisabled: StoryObj = { + args: { + disabled: true, + }, + parameters: { + axe: { + disabledRules: ['color-contrast'], + }, + }, +}; + +export const MediumDisabled: StoryObj = { + args: { + disabled: true, + size: 'md', + }, + parameters: { + axe: { + disabledRules: ['color-contrast'], + }, + }, +}; + +export const LongCopy: StoryObj = { + args: { + children: + 'Long label lorem ipsum dolor sit amet, consectetur adipiscing elit. Ac id velit ut egestas arcu. Atmaecenas urna, risus donec praesent eu consectetur.', + }, +}; diff --git a/src/components/InputLabel/InputLabel-v2.tsx b/src/components/InputLabel/InputLabel-v2.tsx new file mode 100644 index 0000000000..9485dc1526 --- /dev/null +++ b/src/components/InputLabel/InputLabel-v2.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import React from 'react'; +import type { ReactNode } from 'react'; +import type { Size } from '../../util/variant-types'; +import styles from './InputLabel-v2.module.css'; + +export type InputLabelProps = { + /** + * Text to render in label. + */ + children: ReactNode; + /** + * Additional classnames passed in for styling. + */ + className?: string; + /** + * ID of input that label is associated with. + */ + htmlFor: string; + /** + * Size of the label. + * + * **Default is `"lg"`**. + */ + size?: Extract; + /** + * Indicates disabled state of the input. + */ + disabled?: boolean; +}; + +/** + * `import {InputLabel} from "@chanzuckerberg/eds";` + * + * Label associated with an input element such as a radio or checkbox. + */ +export const InputLabel = ({ + children, + className, + htmlFor, + size = 'lg', + disabled, +}: InputLabelProps) => { + const componentClassName = clsx( + styles['label'], + size === 'md' && styles['label--md'], + size === 'lg' && styles['label--lg'], + disabled && styles['label--disabled'], + className, + ); + return ( + + ); +}; + +InputLabel.displayName = 'InputLabel'; diff --git a/src/components/Radio/Radio-v2.module.css b/src/components/Radio/Radio-v2.module.css new file mode 100755 index 0000000000..5a143fddfb --- /dev/null +++ b/src/components/Radio/Radio-v2.module.css @@ -0,0 +1,136 @@ +/*------------------------------------*\ + # RADIO BUTTON +\*------------------------------------*/ + +/** + * A custom individual radio control + */ +.radio { + display: flex; + gap: 0.5rem; +} + +/** + * Wraps the visually hidden radio input element and the visible sibling svg element. + */ +.input__wrapper { + position: relative; + /* Centers the radio icon in the wrapper. */ + display: inline-flex; + align-items: center; + /* Aligns the radio with the first line of the label. */ + align-self: flex-start; +} +/** + * The visually hidden input element for the radio. The visual radio is provided by an svg element. + */ +.radio__input { + /* Removes default margins placed by browser for radioes. */ + margin: 0; + /* Remove the radio from the page flow, positioning it on top of the SVG. */ + position: absolute; + /* Set same dimensions as the RadioSvg element. */ + height: 1.5rem; + width: 1.5rem; + /** + * Hide the input element visually. + * This ensures the radio is still "physically" present so that all users, + * especially on touch screen readers, still interact with the real radio element + * where it would naturally be present. + */ + opacity: 0; +} + +/** + * The disabled status of the visually hidden input element. + */ + .radio__input:disabled { + /* Needed since the input element overlays the visible svg icon for user input and cursor. */ + cursor: not-allowed; + pointer-events: none; +} + +.radio__labels { + position: relative; +} + +/** + * Text that labels a radio input. + */ +.radio__label { + position: relative; +} + +.radio__sub-label { + display: block; + + color: var(--eds-theme-color-text-utility-neutral-secondary); +} + +/** + * The visible radio svg icon element + */ +.radio__icon { + /* Creates space for the border so that there's no jitter when the focus border is visible. */ + border: 0.125rem solid transparent; + + /* Theming when unchecked */ + .radio__input:not(:checked) + & { + color: var(--eds-theme-color-border-utility-neutral-medium-emphasis); + } + + .radio__input:not(:checked):hover + & { + color: var(--eds-theme-color-border-utility-neutral-medium-emphasis-hover); + } + + .radio__input:not(:checked):active + & { + color: var(--eds-theme-color-border-utility-neutral-medium-emphasis-active); + } + + /* Theming when checked */ + .radio__input:checked + & { + color: var(--eds-theme-color-background-utility-interactive-high-emphasis); + } + + .radio__input:checked:hover ~ & { + color: var(--eds-theme-color-background-utility-interactive-high-emphasis-hover); + } + + .radio__input:checked:active ~ & { + color: var(--eds-theme-color-background-utility-interactive-high-emphasis-active); + } + + .radio__input:checked:disabled ~ & { + color: var(--eds-theme-color-border-utility-disabled); + } + + /** + * Error Theming + */ + .radio__input.radio--error ~ & { + color: var(--eds-theme-color-border-utility-critical); + } + + .radio__input.radio--error:hover ~ & { + color: var(--eds-theme-color-border-utility-critical-hover); + } + + .radio__input.radio--error:active ~ & { + color: var(--eds-theme-color-border-utility-critical-active); + } +} + +/** + * Handling focus ring + */ +.radio__input:focus-visible + .radio__icon { + border: 0.125rem solid var(--eds-theme-color-border-utility-focus); + border-radius: var(--eds-border-radius-full); +} + +@supports not selector(:focus-visible) { + .radio__input:focus + .radio__icon { + border: 0.125rem solid var(--eds-theme-color-border-utility-focus); + border-radius: var(--eds-border-radius-full); + } +} diff --git a/src/components/Radio/Radio-v2.stories.tsx b/src/components/Radio/Radio-v2.stories.tsx new file mode 100644 index 0000000000..9de2b76ad9 --- /dev/null +++ b/src/components/Radio/Radio-v2.stories.tsx @@ -0,0 +1,111 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import React from 'react'; + +import { Radio } from './Radio-v2'; + +const meta: Meta = { + title: 'Components/V2/Radio', + component: Radio, + parameters: { + badges: ['intro-1.0', 'current-2.0'], + }, + decorators: [(Story) =>
{Story()}
], +}; + +export default meta; + +type Args = React.ComponentProps; +type Story = StoryObj; + +export const Default: Story = { + args: { + name: 'option-1', + label: 'Option 1', + disabled: false, + checked: false, + readOnly: true, + }, +}; + +export const Checked: Story = { + args: { + ...Default.args, + name: 'option-checked', + checked: true, + readOnly: true, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + name: 'option-disabled', + disabled: true, + }, + parameters: { + axe: { + disabledRules: ['color-contrast'], + }, + }, +}; + +export const Error: Story = { + args: { + ...Default.args, + name: 'option-error', + isError: true, + }, +}; + +export const ErrorAndChecked: Story = { + args: { + ...Error.args, + name: 'option-error', + checked: true, + readOnly: true, + }, +}; + +export const WithSublabel: Story = { + args: { + ...Default.args, + subLabel: 'Some additional label text', + }, +}; + +export const WithoutVisibleLabel: Story = { + args: { + ...Default.args, + label: undefined, + 'aria-label': 'unchecked radio button', + }, + parameters: { + axe: { + disabledRules: ['color-contrast'], + }, + }, +}; + +export const LongLabels = { + render: () => { + const label = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit'; + + return ( +
+ +
+ ); + }, + parameters: { + axe: { + disabledRules: ['color-contrast'], + }, + }, +}; diff --git a/src/components/Radio/Radio-v2.tsx b/src/components/Radio/Radio-v2.tsx new file mode 100644 index 0000000000..4ffb74e80f --- /dev/null +++ b/src/components/Radio/Radio-v2.tsx @@ -0,0 +1,180 @@ +import clsx from 'clsx'; +import React from 'react'; +import type { ReactNode, InputHTMLAttributes } from 'react'; +import { useId } from '../../util/useId'; +import type { EitherInclusive } from '../../util/utility-types'; +import Icon, { type IconName } from '../Icon'; +import { InputLabel, type InputLabelProps } from '../InputLabel/InputLabel'; +import Text from '../Text'; +import styles from './Radio-v2.module.css'; + +type RadioProps = RadioInputProps & { + // Component API + /** + * HTML id attribute. If not passed, this component + * will generate an id to use for accessibility. + */ + id?: string; + // Design API + /** + * Whether the radio button is in an error state + */ + isError?: boolean; + /** + * + */ + subLabel?: string; +} & EitherInclusive< + { + /** + * Visible text label for the component. + */ + label: ReactNode; + }, + { + /** + * Aria-label to provide an accesible name for the text input if no visible label is provided. + */ + 'aria-label': string; + } + >; + +type RadioInputProps = Omit< + InputHTMLAttributes, + 'id' | 'size' +> & { + /** + * Additional classnames passed in for styling. + */ + className?: string; + /** + * Radio ID. Used to connect the input with a label for accessibility purposes. + */ + id?: string; + /** + * Whether the radio button is in an error state + */ + isError?: boolean; +}; + +const RadioSvg = ({ checked }: Pick) => { + const iconClassName = clsx(styles['radio__icon']); + const icon: IconName = checked ? 'radio-selected' : 'radio-unselected'; + return ( + + ); +}; + +/** + * Radio input element, exported for greater flexibility. + * You must provide an `id` prop and connect it to a visible label. + */ +export const RadioInput = ({ + checked, + className, + disabled, + isError, + ...other +}: RadioInputProps) => { + return ( + + + + + ); +}; + +/** + * Radio label element, styles and relies on the InputLabel component. + */ +export const RadioLabel = ({ + className, + size = 'lg', + ...other +}: InputLabelProps) => { + const componentClassName = clsx( + styles['radio__label'], + styles[`radio__label--${size}`], + className, + ); + + return ; +}; + +/** + * `import {Radio} from "@chanzuckerberg/eds";` + * + * Radio control indicating if one item is selected or unselected from a set of other options. Uncontrolled by default, it can be used in place of a select field in form data. + * + * NOTE: This component requires `label` or `aria-label` prop + */ +export const Radio = ({ + className, + disabled, + label, + id, + isError = false, + subLabel, + ...other +}: RadioProps) => { + const generatedId = useId(); + const radioId = id || generatedId; + + const componentClassName = clsx( + styles['radio'], + isError && styles['radio--error'], + className, + ); + + return ( +
+ +
+ + {label} + + {subLabel && ( + + {subLabel} + + )} +
+
+ ); +}; + +Radio.displayName = 'Radio'; +RadioInput.displayName = 'Radio.Input'; +RadioLabel.displayName = 'Radio.Label'; + +Radio.Input = RadioInput; +Radio.Label = RadioLabel;