diff --git a/.changeset/segmented-control-wrap-labels.md b/.changeset/segmented-control-wrap-labels.md new file mode 100644 index 00000000000..ff430891321 --- /dev/null +++ b/.changeset/segmented-control-wrap-labels.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +SegmentedControl: Allow controls to grow vertically and preserve visible labels when they wrap diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-colorblind-linux.png new file mode 100644 index 00000000000..0422615d0ec Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-dimmed-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-dimmed-linux.png new file mode 100644 index 00000000000..a5c60bd3421 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-high-contrast-linux.png new file mode 100644 index 00000000000..cb9504de66f Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-linux.png new file mode 100644 index 00000000000..0422615d0ec Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-tritanopia-linux.png new file mode 100644 index 00000000000..0422615d0ec Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-colorblind-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-colorblind-linux.png new file mode 100644 index 00000000000..12272a88e65 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-high-contrast-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-high-contrast-linux.png new file mode 100644 index 00000000000..e8f8c81d489 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-linux.png new file mode 100644 index 00000000000..12272a88e65 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-linux.png differ diff --git a/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-tritanopia-linux.png b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-tritanopia-linux.png new file mode 100644 index 00000000000..12272a88e65 Binary files /dev/null and b/.playwright/snapshots/components/SegmentedControl.test.ts-snapshots/SegmentedControl-Multiline-Labels-light-tritanopia-linux.png differ diff --git a/e2e/components/SegmentedControl.test.ts b/e2e/components/SegmentedControl.test.ts index 5e854c5d2b7..ba2ed170ffc 100644 --- a/e2e/components/SegmentedControl.test.ts +++ b/e2e/components/SegmentedControl.test.ts @@ -60,6 +60,10 @@ const stories = [ title: 'With Counter Labels', id: 'components-segmentedcontrol-features--with-counter-labels', }, + { + title: 'Multiline Labels', + id: 'components-segmentedcontrol-features--multiline-labels', + }, { title: 'SegmentedControlButton Playground', id: 'components-segmentedcontrol-segmentedcontrol-button--playground', @@ -121,4 +125,237 @@ test.describe('SegmentedControl', () => { } }) } + + test('naturally wrapped labels grow to the tallest segment without splitting words', async ({page}) => { + await visit(page, { + id: 'components-segmentedcontrol-features--multiline-labels', + }) + + const layout = await page + .getByTestId('multiline-natural-wrap') + .locator('[data-component="SegmentedControl"]') + .evaluate(control => { + const buttons = [...control.querySelectorAll('button')] + const buttonHeights = buttons.map(button => button.getBoundingClientRect().height) + const contents = [...control.querySelectorAll('.segmentedControl-content')] + const texts = [...control.querySelectorAll('.segmentedControl-text')] + const selectedButton = control.querySelector('button[aria-pressed="true"]') + const selectedContent = selectedButton?.querySelector('.segmentedControl-content') + + const textLayouts = texts.map(text => { + const textNode = text.firstChild + const style = getComputedStyle(text) + if (!(textNode instanceof Text)) { + return { + lineCount: 0, + splitWords: [], + whiteSpace: style.whiteSpace, + overflowWrap: style.overflowWrap, + wordBreak: style.wordBreak, + } + } + + const textRange = document.createRange() + textRange.selectNodeContents(textNode) + + const splitWords = [...textNode.data.matchAll(/\S+/g)] + .filter(match => { + const wordRange = document.createRange() + const start = match.index + wordRange.setStart(textNode, start) + wordRange.setEnd(textNode, start + match[0].length) + return wordRange.getClientRects().length > 1 + }) + .map(match => match[0]) + + return { + lineCount: textRange.getClientRects().length, + splitWords, + whiteSpace: style.whiteSpace, + overflowWrap: style.overflowWrap, + wordBreak: style.wordBreak, + } + }) + + return { + controlHeight: control.getBoundingClientRect().height, + buttonHeightSpread: Math.max(...buttonHeights) - Math.min(...buttonHeights), + contentFits: contents.every( + content => + content.scrollWidth <= content.clientWidth + 1 && content.scrollHeight <= content.clientHeight + 1, + ), + selectedContentHeight: selectedContent?.getBoundingClientRect().height, + selectedButtonHeight: selectedButton?.getBoundingClientRect().height, + textLayouts, + } + }) + + expect(layout.controlHeight).toBeGreaterThan(32) + expect(layout.buttonHeightSpread).toBeLessThanOrEqual(0.5) + expect(layout.contentFits).toBe(true) + expect(layout.textLayouts.every(text => text.lineCount > 1)).toBe(true) + expect(layout.textLayouts.flatMap(text => text.splitWords)).toEqual([]) + expect( + layout.textLayouts.every( + text => text.whiteSpace === 'normal' && text.overflowWrap === 'normal' && text.wordBreak === 'normal', + ), + ).toBe(true) + expect(layout.selectedContentHeight).toBeDefined() + expect(layout.selectedButtonHeight).toBeDefined() + expect(layout.selectedContentHeight ?? 0).toBeCloseTo(layout.selectedButtonHeight ?? 0, 1) + }) + + test('single-line labels preserve the existing control heights', async ({page}) => { + await visit(page, { + id: 'components-segmentedcontrol-features--multiline-labels', + }) + + const singleLineCases = [ + {testId: 'single-line-default-medium', height: 32}, + {testId: 'single-line-default-small', height: 28}, + {testId: 'single-line-subtle-medium', height: 32}, + {testId: 'single-line-subtle-small', height: 28}, + ] + + for (const {testId, height} of singleLineCases) { + const controlHeight = await page + .getByTestId(testId) + .locator('[data-component="SegmentedControl"]') + .evaluate(control => control.getBoundingClientRect().height) + + expect(controlHeight).toBe(height) + } + }) + + test('single-line controls do not stretch to adjacent label and caption content', async ({page}) => { + await visit(page, { + id: 'components-segmentedcontrol-features--associated-with-a-label-and-caption', + }) + + const controlHeight = await page + .locator('[data-component="SegmentedControl"]') + .evaluate(control => control.getBoundingClientRect().height) + + expect(controlHeight).toBe(32) + }) + + test('icon and counter labels remain unclipped without unnecessary wrapping', async ({page}) => { + await visit(page, { + id: 'components-segmentedcontrol-features--multiline-labels', + }) + + const layout = await page + .getByTestId('long-label-icons-counters') + .locator('[data-component="SegmentedControl"]') + .evaluate(control => { + const buttons = [...control.querySelectorAll('button')] + const buttonHeights = buttons.map(button => button.getBoundingClientRect().height) + const contents = [...control.querySelectorAll('.segmentedControl-content')] + const texts = [...control.querySelectorAll('.segmentedControl-text')] + const textLayouts = texts.map(text => { + const textNode = text.firstChild + const style = getComputedStyle(text) + if (!(textNode instanceof Text)) { + return { + lineCount: 0, + splitWords: [], + whiteSpace: style.whiteSpace, + overflowWrap: style.overflowWrap, + wordBreak: style.wordBreak, + fits: false, + } + } + + const textRange = document.createRange() + textRange.selectNodeContents(textNode) + + const splitWords = [...textNode.data.matchAll(/\S+/g)] + .filter(match => { + const wordRange = document.createRange() + const start = match.index + wordRange.setStart(textNode, start) + wordRange.setEnd(textNode, start + match[0].length) + return wordRange.getClientRects().length > 1 + }) + .map(match => match[0]) + + return { + lineCount: textRange.getClientRects().length, + splitWords, + whiteSpace: style.whiteSpace, + overflowWrap: style.overflowWrap, + wordBreak: style.wordBreak, + fits: text.scrollWidth <= text.clientWidth + 1 && text.scrollHeight <= text.clientHeight + 1, + } + }) + + return { + buttonHeightSpread: Math.max(...buttonHeights) - Math.min(...buttonHeights), + contentFits: contents.every( + content => + content.scrollWidth <= content.clientWidth + 1 && content.scrollHeight <= content.clientHeight + 1, + ), + textLayouts, + } + }) + + expect(layout.buttonHeightSpread).toBeLessThanOrEqual(0.5) + expect(layout.contentFits).toBe(true) + expect(layout.textLayouts.every(text => text.lineCount === 1)).toBe(true) + expect(layout.textLayouts.flatMap(text => text.splitWords)).toEqual([]) + expect(layout.textLayouts.every(text => text.fits)).toBe(true) + expect( + layout.textLayouts.every( + text => text.whiteSpace === 'normal' && text.overflowWrap === 'normal' && text.wordBreak === 'normal', + ), + ).toBe(true) + }) + + test('multiline labels reflow at 320px and preserve enlarged and spaced text', async ({page}) => { + await page.setViewportSize({width: 320, height: 768}) + await visit(page, { + id: 'components-segmentedcontrol-features--multiline-labels', + }) + + const control = page.getByTestId('reflow-stress').locator('[data-component="SegmentedControl"]') + + await control.evaluate(element => { + const htmlElement = element as HTMLElement + htmlElement.style.setProperty('--text-body-size-medium', '28px') + htmlElement.style.lineHeight = '1.5' + htmlElement.style.letterSpacing = '0.12em' + htmlElement.style.wordSpacing = '0.16em' + }) + + const layout = await control.evaluate(element => { + const contents = [...element.querySelectorAll('.segmentedControl-content')] + const texts = [...element.querySelectorAll('.segmentedControl-text')] + const splitWords = texts.flatMap(text => { + const textNode = text.firstChild + if (!(textNode instanceof Text)) return [] + + return [...textNode.data.matchAll(/\S+/g)] + .filter(match => { + const wordRange = document.createRange() + const start = match.index + wordRange.setStart(textNode, start) + wordRange.setEnd(textNode, start + match[0].length) + return wordRange.getClientRects().length > 1 + }) + .map(match => match[0]) + }) + + return { + hasHorizontalPageOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + contentFits: contents.every( + content => content.scrollWidth <= content.clientWidth + 1 && content.scrollHeight <= content.clientHeight + 1, + ), + splitWords, + } + }) + + expect(layout.hasHorizontalPageOverflow).toBe(false) + expect(layout.contentFits).toBe(true) + expect(layout.splitWords).toEqual([]) + }) }) diff --git a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css index d578fa504b6..d91ff953c60 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css @@ -8,6 +8,36 @@ margin-top: var(--base-size-24); } +.MultilineLabelsGrid { + display: flex; + flex-wrap: wrap; + gap: var(--base-size-24); + align-items: start; +} + +.MultilineLabelsExample { + width: 240px; +} + +.MultilineLabelsExampleWide { + flex-basis: 352px; +} + +.MultilineLabelsExampleVisuals { + flex-basis: 480px; +} + +.MultilineLabelsExampleReflow { + width: min(288px, 100%); +} + +.MultilineLabelsTitle { + display: block; + margin-bottom: var(--base-size-8); + font-size: var(--text-body-size-small); + font-weight: var(--base-text-weight-semibold); +} + @media screen and (min-width: 768px) { .LabelAndCaptionContainer { flex-direction: row; diff --git a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx index 1893aee05a7..b58c0d3211f 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx @@ -1,6 +1,7 @@ import {useState} from 'react' import type {Meta} from '@storybook/react-vite' import {PlusIcon, EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react' +import {clsx} from 'clsx' import {SegmentedControl} from '.' import {Button} from '../Button' import Text from '../Text' @@ -35,6 +36,116 @@ export const WithCounterLabels = () => ( ) +type MultilineLabelsExampleProps = { + label: string + testId: string + size?: 'small' | 'medium' + variant?: 'default' | 'subtle' + selectedIndex?: 0 | 1 + withVisuals?: boolean + wide?: boolean +} + +const MultilineLabelsExample = ({ + label, + testId, + size = 'medium', + variant = 'default', + selectedIndex = 0, + withVisuals = false, + wide = false, +}: MultilineLabelsExampleProps) => ( +
+ {label} + + + All industries + + + Information & technology + + +
+) + +const ReflowLabelsExample = () => ( +
+ 320px enlarged and spaced text + + All sectors + Tech services + +
+) + +const SingleLineLabelsExample = ({ + label, + testId, + size = 'medium', + variant = 'default', +}: Omit) => ( +
+ {label} + + All + Active + +
+) + +export const MultilineLabels = () => ( +
+ + + + + + + + + + +
+) + export const VariantSubtle = () => ( diff --git a/packages/react/src/SegmentedControl/SegmentedControl.module.css b/packages/react/src/SegmentedControl/SegmentedControl.module.css index aa7c5703d87..b017e21f267 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css @@ -103,7 +103,8 @@ display: inline-flex; /* TODO: use primitive `control.{small|medium}.size` when it is available */ - height: 32px; + height: fit-content; + min-height: 32px; padding: 0; margin: 0; font-size: var(--text-body-size-medium); @@ -278,7 +279,7 @@ &:where([data-size='small']) { /* TODO: use primitive `control.{small|medium}.size` when it is available */ - height: 28px; + min-height: 28px; font-size: var(--text-body-size-small); } }