diff --git a/.changeset/segmentedcontrol-responsive.md b/.changeset/segmentedcontrol-responsive.md new file mode 100644 index 00000000000..36fa3a6caf3 --- /dev/null +++ b/.changeset/segmentedcontrol-responsive.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +SegmentedControl: Remove useResponsiveValue hook from fullWidth and variant props to use `getResponsiveAttributes` instead. diff --git a/packages/react/src/SegmentedControl/SegmentedControl.module.css b/packages/react/src/SegmentedControl/SegmentedControl.module.css index 2717c72816b..77270b6d227 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css @@ -1,4 +1,7 @@ .SegmentedControl { + /* TODO: use primitive `control.medium.size` when it is available instead of '32px' */ + --segmented-control-icon-width: 32px; + display: inline-flex; /* TODO: use primitive `control.{small|medium}.size` when it is available */ @@ -10,9 +13,114 @@ border: var(--borderWidth-thin) solid var(--controlTrack-borderColor-rest, transparent); border-radius: var(--borderRadius-medium); - &:where([data-full-width]) { + /* Responsive full-width */ + &[data-full-width='true'] { display: flex; width: 100%; + + --segmented-control-icon-width: 100%; + } + + &[data-full-width='false'] { + display: inline-flex; + width: auto; + + --segmented-control-icon-width: 32px; + } + + @media (--viewportRange-narrow) { + &[data-full-width-narrow='true'] { + display: flex; + width: 100%; + + --segmented-control-icon-width: 100%; + } + + &[data-full-width-narrow='false'] { + display: inline-flex; + width: auto; + + --segmented-control-icon-width: 32px; + } + } + + @media (--viewportRange-regular) { + &[data-full-width-regular='true'] { + display: flex; + width: 100%; + + --segmented-control-icon-width: 100%; + } + + &[data-full-width-regular='false'] { + display: inline-flex; + width: auto; + + --segmented-control-icon-width: 32px; + } + } + + @media (--viewportRange-wide) { + &[data-full-width-wide='true'] { + display: flex; + width: 100%; + + --segmented-control-icon-width: 100%; + } + + &[data-full-width-wide='false'] { + display: inline-flex; + width: auto; + + --segmented-control-icon-width: 32px; + } + + &[data-full-width-regular='true']:not([data-full-width-wide='true']) { + display: inline-flex; + width: auto; + + --segmented-control-icon-width: 32px; + } + } + + /* Hide when dropdown variant is active */ + &[data-variant='dropdown'] { + display: none; + } + + /* Handle hideLabels variant - hide button text */ + &[data-variant='hideLabels'] .Text { + display: none; + } + + @media (--viewportRange-narrow) { + &[data-variant-narrow='dropdown'] { + display: none; + } + + &[data-variant-narrow='hideLabels'] .Text { + display: none; + } + } + + @media (--viewportRange-regular) { + &[data-variant-regular='dropdown'] { + display: none; + } + + &[data-variant-regular='hideLabels'] .Text { + display: none; + } + } + + @media (--viewportRange-wide) { + &[data-variant-wide='dropdown'] { + display: none; + } + + &[data-variant-wide='hideLabels'] .Text { + display: none; + } } &:where([data-size='small']) { @@ -22,6 +130,33 @@ } } +.DropdownContainer { + display: none; + + /* Show when dropdown variant is active */ + &[data-variant='dropdown'] { + display: block; + } + + @media (--viewportRange-narrow) { + &[data-variant-narrow='dropdown'] { + display: block; + } + } + + @media (--viewportRange-regular) { + &[data-variant-regular='dropdown'] { + display: block; + } + } + + @media (--viewportRange-wide) { + &[data-variant-wide='dropdown'] { + display: block; + } + } +} + .Item { position: relative; display: block; @@ -138,12 +273,7 @@ } .IconButton { - /* TODO: use primitive `control.medium.size` when it is available instead of '32px' */ - width: 32px; - - .SegmentedControl:where([data-full-width]) & { - width: 100%; - } + width: var(--segmented-control-icon-width, 32px); } .Content { diff --git a/packages/react/src/SegmentedControl/SegmentedControl.responsive.stories.tsx b/packages/react/src/SegmentedControl/SegmentedControl.responsive.stories.tsx new file mode 100644 index 00000000000..5b1099d59b4 --- /dev/null +++ b/packages/react/src/SegmentedControl/SegmentedControl.responsive.stories.tsx @@ -0,0 +1,148 @@ +import type {Meta, StoryFn} from '@storybook/react-vite' +import React from 'react' +import {SegmentedControl} from './SegmentedControl' +import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react' + +const meta: Meta = { + title: 'Components/SegmentedControl/Responsive Tests', + parameters: { + layout: 'padded', + controls: {expanded: true}, + }, +} + +export default meta + +/** + * Test responsive fullWidth behavior. + * Resize the viewport to see the control change width at different breakpoints. + */ +export const FullWidthResponsive: StoryFn = () => ( +
+

Full width: yes (narrow) → no (regular + wide)

+ + + Preview + + Raw + Blame + +
+) + +FullWidthResponsive.parameters = { + docs: { + description: { + story: + 'The control fills the full width on **narrow** viewports and uses inline width on **regular** and **wide** viewports.', + }, + }, +} + +/** + * Test responsive variant behavior with hideLabels. + * Resize the viewport to see labels hide/show at different breakpoints. + */ +export const VariantHideLabelsResponsive: StoryFn = () => ( +
+

Labels: hidden (narrow) → visible (regular + wide)

+ + + Preview + + Raw + Blame + +
+) + +VariantHideLabelsResponsive.parameters = { + docs: { + description: { + story: + 'Labels are **hidden** (icon-only) on narrow viewports and **visible** on regular and wide viewports. Note: leadingVisual prop is required for hideLabels variant.', + }, + }, +} + +/** + * Test responsive variant behavior with dropdown. + * Resize the viewport to see the control switch between dropdown and buttons. + */ +export const VariantDropdownResponsive: StoryFn = () => ( +
+

Variant: dropdown (narrow) → buttons (regular + wide)

+ + + Preview + + Raw + Blame + +
+) + +VariantDropdownResponsive.parameters = { + docs: { + description: { + story: + 'Renders as a **dropdown menu** on narrow viewports and as **segmented buttons** on regular and wide viewports.', + }, + }, +} + +/** + * Test responsive variant behavior when only the narrow breakpoint is specified. + */ +export const VariantDropdownNarrowOnly: StoryFn = () => ( +
+

Variant: dropdown (narrow) → buttons (others inherit default)

+ + + Preview + + Raw + Blame + +
+) + +VariantDropdownNarrowOnly.parameters = { + docs: { + description: { + story: + 'Only the **narrow** breakpoint sets the dropdown variant; wider breakpoints fall back to the default segmented buttons.', + }, + }, +} + +/** + * Test complex responsive behavior combining fullWidth and variant. + */ +export const ComplexResponsive: StoryFn = () => ( +
+

+ Complex: full-width + icon-only (narrow) → full-width + labels (regular) → inline + labels (wide) +

+ + + Preview + + Raw + Blame + +
+) + +ComplexResponsive.parameters = { + docs: { + description: { + story: + 'Complex: **full-width + icon-only** (narrow) → **full-width + labels** (regular) → **inline + labels** (wide)', + }, + }, +} diff --git a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx index 0c1fd59a9b1..284cc9a1906 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx @@ -64,7 +64,7 @@ describe('SegmentedControl', () => { }) it('renders the dropdown variant', () => { - const {getByText} = render( + const {getByRole} = render( {segmentData.map(({label}, index) => ( @@ -73,14 +73,14 @@ describe('SegmentedControl', () => { ))} , ) - const button = getByText(segmentData[1].label) + const button = getByRole('button', {name: `${segmentData[1].label}, File view`}) expect(button).toBeInTheDocument() expect(button.closest('button')?.getAttribute('aria-haspopup')).toBe('true') }) it('renders the hideLabels variant', () => { - const {getByLabelText} = render( + const {getByRole} = render( {segmentData.map(({label, icon}, index) => ( @@ -91,8 +91,8 @@ describe('SegmentedControl', () => { ) for (const datum of segmentData) { - const labelledButton = getByLabelText(datum.label) - expect(labelledButton).toBeDefined() + const labelledButton = getByRole('button', {name: datum.iconLabel}) + expect(labelledButton).toBeInTheDocument() } }) @@ -280,7 +280,7 @@ describe('SegmentedControl', () => { , ) - const button = component.getByText(segmentData[0].label) + const button = component.getByRole('button', {name: `${segmentData[0].label}, File view`}) fireEvent.click(button) expect(handleChange).not.toHaveBeenCalled() @@ -303,7 +303,7 @@ describe('SegmentedControl', () => { , ) - const button = component.getByText(segmentData[0].label) + const button = component.getByRole('button', {name: `${segmentData[0].label}, File view`}) fireEvent.click(button) expect(handleClick).not.toHaveBeenCalled() @@ -313,23 +313,6 @@ describe('SegmentedControl', () => { expect(handleClick).toHaveBeenCalled() }) - it('warns users if they try to use the hideLabels variant without a leadingVisual', () => { - const spy = vi.spyOn(globalThis.console, 'warn').mockImplementation(() => {}) - - render( - - {segmentData.map(({label}, index) => ( - - {label} - - ))} - , - ) - - expect(spy).toHaveBeenCalledTimes(3) - spy.mockRestore() - }) - it('supports deprecated leadingIcon prop for backward compatibility', () => { const {getByText} = render( diff --git a/packages/react/src/SegmentedControl/SegmentedControl.tsx b/packages/react/src/SegmentedControl/SegmentedControl.tsx index 76d25fbb64c..fe2a9b5a81b 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.tsx @@ -6,7 +6,7 @@ import SegmentedControlIconButton from './SegmentedControlIconButton' import {ActionList} from '../ActionList' import {ActionMenu} from '../ActionMenu' import type {ResponsiveValue} from '../hooks/useResponsiveValue' -import {useResponsiveValue} from '../hooks/useResponsiveValue' +import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes' import type {WidthOnlyViewportRangeKeys} from '../utils/types/ViewportRangeKeys' import {isElement} from 'react-is' import classes from './SegmentedControl.module.css' @@ -45,8 +45,7 @@ const Root: React.FC> = ({ React.Children.toArray(children).some( child => React.isValidElement(child) && child.props.defaultSelected !== undefined, ) - const responsiveVariant = useResponsiveValue(variant, 'default') - const isFullWidth = useResponsiveValue(fullWidth, false) + const selectedSegments = React.Children.toArray(children).map( child => React.isValidElement(child) && @@ -110,9 +109,13 @@ const Root: React.FC> = ({ ) } - return responsiveVariant === 'dropdown' ? ( - // Render the 'dropdown' variant of the SegmentedControlButton or SegmentedControlIconButton - <> + // Check if dropdown variant is used at any breakpoint + const responsiveVariant = typeof variant === 'object' ? variant : undefined + const hasDropdownVariant = responsiveVariant ? Object.values(responsiveVariant).includes('dropdown') : false + + // Render dropdown variant if needed + const dropdownContent = hasDropdownVariant && ( +
{/* The aria-label is only provided as a backup when the designer or engineer neglects to show a label for the SegmentedControl. @@ -150,15 +153,18 @@ const Root: React.FC> = ({ - - ) : ( - // Render a segmented control +
+ ) + + // Render segmented control (default or hideLabels variant) + const segmentedControlContent = (
    @@ -186,43 +192,21 @@ const Root: React.FC> = ({ }, } - // Render the 'hideLabels' variant of the SegmentedControlButton - if ( - responsiveVariant === 'hideLabels' && - React.isValidElement(child) && - (child.type === Button || isSlot(child, Button)) - ) { - const { - 'aria-label': childAriaLabel, - leadingVisual, - leadingIcon, - children: childPropsChildren, - ...restChildProps - } = child.props - // Use leadingVisual if provided, otherwise fall back to leadingIcon - const visual = leadingVisual ?? leadingIcon - if (!visual) { - // eslint-disable-next-line no-console - console.warn('A `leadingVisual` or `leadingIcon` prop is required when hiding visible labels') - } else { - return ( - - ) - } - } - // Render the children as-is and add the shared child props return React.cloneElement(child, sharedChildProps) })}
) + + // Return both variants when dropdown is used, otherwise just the segmented control + return hasDropdownVariant ? ( + <> + {dropdownContent} + {segmentedControlContent} + + ) : ( + segmentedControlContent + ) } Root.displayName = 'SegmentedControl'