diff --git a/.changeset/switch-label-position.md b/.changeset/switch-label-position.md new file mode 100644 index 000000000..db72e507e --- /dev/null +++ b/.changeset/switch-label-position.md @@ -0,0 +1,5 @@ +--- +'@launchpad-ui/components': minor +--- + +Add `labelPosition` to `Switch`. Pass `labelPosition="start"` to render a visible label before the track instead of after it, while keeping the label inside the switch so it stays clickable and remains the accessible name. Defaults to `"end"`, matching current behavior. diff --git a/packages/components/__tests__/Switch.spec.tsx b/packages/components/__tests__/Switch.spec.tsx index 2a9a74bdc..7d5ac4b97 100644 --- a/packages/components/__tests__/Switch.spec.tsx +++ b/packages/components/__tests__/Switch.spec.tsx @@ -25,6 +25,16 @@ describe('Switch', () => { expect(screen.queryByText('On')).not.toBeInTheDocument(); }); + it('renders a visible label after the track by default', () => { + render(Dark mode); + expect(screen.getByRole('switch', { name: /Dark mode/ }).closest('label')?.className).not.toContain('labelStart'); + }); + + it('renders a visible label before the track when labelPosition is start', () => { + render(Dark mode); + expect(screen.getByRole('switch', { name: /Dark mode/ }).closest('label')?.className).toContain('labelStart'); + }); + it('renders with primary variant', () => { render(); expect(screen.getByRole('switch')).toBeVisible(); diff --git a/packages/components/src/Switch.tsx b/packages/components/src/Switch.tsx index bd2030b52..0a743e2b3 100644 --- a/packages/components/src/Switch.tsx +++ b/packages/components/src/Switch.tsx @@ -20,9 +20,14 @@ const switchStyles = cva(styles.switch, { compact: { true: styles.compact, }, + labelPosition: { + start: styles.labelStart, + end: '', + }, }, defaultVariants: { variant: 'default', + labelPosition: 'end', }, }); @@ -38,19 +43,21 @@ const SwitchContext = createContext> * A switch allows a user to turn a setting on or off. * * Provide an accessible name via `children` (visible label) or `aria-label` (visually hidden label). + * A visible label renders after the track by default; pass `labelPosition="start"` to render it + * before the track while keeping it part of the switch's label and click target. * * https://react-spectrum.adobe.com/react-aria/Switch.html */ const Switch = ({ ref, ...props }: SwitchProps) => { const [mergedProps, mergedRef] = useLPContextProps(props, ref, SwitchContext); - const { switchLabels, variant } = mergedProps; + const { switchLabels, variant, labelPosition } = mergedProps; const hideLabels = switchLabels === false ? true : undefined; return ( - switchStyles({ ...renderProps, variant, compact: hideLabels, className }), + switchStyles({ ...renderProps, variant, labelPosition, compact: hideLabels, className }), )} > {composeRenderProps(mergedProps.children, (children, { isSelected }) => ( diff --git a/packages/components/src/styles/Switch.module.css b/packages/components/src/styles/Switch.module.css index b5b7457a8..d3412ec36 100644 --- a/packages/components/src/styles/Switch.module.css +++ b/packages/components/src/styles/Switch.module.css @@ -6,6 +6,12 @@ isolation: isolate; } +.switch.labelStart { + & .track { + order: 1; + } +} + .track { position: relative; display: flex; diff --git a/packages/components/stories/Switch.stories.tsx b/packages/components/stories/Switch.stories.tsx index c2ec20b65..1984065e7 100644 --- a/packages/components/stories/Switch.stories.tsx +++ b/packages/components/stories/Switch.stories.tsx @@ -10,6 +10,10 @@ const meta: Meta = { control: 'select', options: ['default', 'primary'], }, + labelPosition: { + control: 'inline-radio', + options: ['start', 'end'], + }, }, parameters: { figma: { @@ -54,3 +58,11 @@ export const PrimaryHideLabels: Story = { export const WithChildren: Story = { args: { children: 'Dark mode' }, }; + +export const LabelStart: Story = { + args: { children: 'Dark mode', labelPosition: 'start' }, +}; + +export const LabelStartHideLabels: Story = { + args: { children: 'Dark mode', labelPosition: 'start', switchLabels: false }, +};