Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/segmented-control-truncated-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

SegmentedControl: Prevent constrained labels from wrapping, reveal truncated labels on hover and keyboard focus, and preserve their full accessible names.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
449 changes: 449 additions & 0 deletions e2e/components/SegmentedControl.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.ConstrainedControls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--base-size-24);
}

.ChangingLabelControl {
width: 260px;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type {Meta} from '@storybook/react-vite'
import type {Meta, StoryFn} from '@storybook/react-vite'
import {useState} from 'react'
import {SegmentedControl} from '.'
import SegmentedControlIconButton from './SegmentedControlIconButton'
import SegmentedControlButton from './SegmentedControlButton'
import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
import classes from './SegmentedControl.dev.stories.module.css'
import TextInput from '../TextInput'

const excludedControlKeys = ['variant']

Expand Down Expand Up @@ -107,3 +110,106 @@ export const WithCss = () => (
</SegmentedControl.Button>
</SegmentedControl>
)

type ConstrainedControlsArgs = {
size: 'small' | 'medium'
appearance: 'default' | 'subtle'
fullWidthMode: 'omitted' | 'explicit-false'
textContent: 'plain' | 'unequal-labels-with-visuals'
}

export const ConstrainedControls: StoryFn<ConstrainedControlsArgs> = ({
size,
appearance,
fullWidthMode,
textContent,
}) => {
const props = {size, variant: appearance, fullWidth: fullWidthMode === 'explicit-false' ? false : undefined}
const withVisuals = textContent === 'unequal-labels-with-visuals'

return (
<div className={classes.ConstrainedControls}>
<div>
<SegmentedControl {...props} aria-label="Text file view">
<SegmentedControl.Button
defaultSelected
leadingVisual={withVisuals ? EyeIcon : undefined}
count={withVisuals ? 5 : undefined}
>
{withVisuals ? 'Preview changes before publishing '.repeat(8) : 'Preview changes before publishing'}
</SegmentedControl.Button>
<SegmentedControl.Button>Code</SegmentedControl.Button>
<SegmentedControl.Button
leadingVisual={withVisuals ? PeopleIcon : undefined}
count={withVisuals ? 2 : undefined}
>
Blame
</SegmentedControl.Button>
</SegmentedControl>
</div>
<div>
<SegmentedControl {...props} aria-label="Mixed file view">
<SegmentedControl.Button defaultSelected>Preview changes</SegmentedControl.Button>
<SegmentedControl.IconButton aria-label="Code" icon={FileCodeIcon} />
<SegmentedControl.IconButton aria-label="Blame" icon={PeopleIcon} />
</SegmentedControl>
</div>
<div>
<SegmentedControl {...props} aria-label="Icon file view">
<SegmentedControl.IconButton defaultSelected aria-label="Preview" icon={EyeIcon} />
<SegmentedControl.IconButton aria-label="Code" icon={FileCodeIcon} />
<SegmentedControl.IconButton aria-label="Blame" icon={PeopleIcon} />
</SegmentedControl>
</div>
</div>
)
}

ConstrainedControls.args = {size: 'medium', appearance: 'default', fullWidthMode: 'omitted', textContent: 'plain'}

ConstrainedControls.argTypes = {
size: {control: 'radio', options: ['small', 'medium']},
appearance: {control: 'radio', options: ['default', 'subtle']},
fullWidthMode: {control: 'radio', options: ['omitted', 'explicit-false']},
textContent: {control: 'radio', options: ['plain', 'unequal-labels-with-visuals']},
}

export const ChangingLabel = () => {
const [label, setLabel] = useState('Preview changes before publishing')
const [disabled, setDisabled] = useState(false)
const [focusEvents, setFocusEvents] = useState(0)
const [mouseOverEvents, setMouseOverEvents] = useState(0)
const [mouseEnterEvents, setMouseEnterEvents] = useState(0)

return (
<div className={classes.ConstrainedControls}>
<TextInput aria-label="Segment label" value={label} onChange={event => setLabel(event.target.value)} />
<label>
<input type="checkbox" checked={disabled} onChange={event => setDisabled(event.target.checked)} />
Disable first segment
</label>
<p id="changing-label-description">Choose a file view</p>
<div className={classes.ChangingLabelControl}>
<SegmentedControl aria-label="File view">
<SegmentedControl.Button
defaultSelected
leadingVisual={EyeIcon}
count={5}
disabled={disabled}
aria-describedby="changing-label-description"
onFocus={() => setFocusEvents(value => value + 1)}
onMouseOverCapture={() => setMouseOverEvents(value => value + 1)}
onMouseEnter={() => setMouseEnterEvents(value => value + 1)}
>
{label}
</SegmentedControl.Button>
<SegmentedControl.Button>Code</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
</div>
<output aria-label="Focus events">{focusEvents}</output>
<output aria-label="Mouse-over events">{mouseOverEvents}</output>
<output aria-label="Mouse-enter events">{mouseEnterEvents}</output>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,43 @@
font-size: var(--text-body-size-medium);
color: var(--fgColor-muted);
}

.CompactToolbar {
display: flex;
align-items: center;
gap: var(--base-size-8);
}

.ConsumerTypography {
font-size: var(--text-body-size-medium);
}

.ConsumerTypography button {
line-height: inherit;
}

.ConstrainedLabelExamples {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--base-size-24);
}

.ConstrainedLabelExample {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--base-size-8);
}

.ConstrainedLabelButton {
max-width: 120px;
}

.ConstrainedLabelStressExamples {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--base-size-16);
width: 260px;
}
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,79 @@ export const AssociatedWithALabelAndCaption = () => (
</div>
)
AssociatedWithALabelAndCaption.storyName = '[Example] Associated with a label and caption'

export const CompactToolbar = () => (
<div className={classes.CompactToolbar}>
<SegmentedControl aria-label="File view" size="small" className={classes.ConsumerTypography}>
<SegmentedControl.Button defaultSelected>Code</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
<Button size="small">Raw</Button>
</div>
)

export const ConstrainedLabelOverflow = () => (
<div className={classes.ConstrainedLabelExamples}>
<div className={classes.ConstrainedLabelExample}>
<Text className={classes.TextMediumSubtle}>Full label</Text>
<SegmentedControl aria-label="File view, full label">
<SegmentedControl.Button defaultSelected>Preview changes</SegmentedControl.Button>
<SegmentedControl.Button>Code</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
</div>
<div className={classes.ConstrainedLabelExample}>
<Text className={classes.TextMediumSubtle}>Constrained label</Text>
<SegmentedControl aria-label="File view, constrained label">
<SegmentedControl.Button defaultSelected className={classes.ConstrainedLabelButton}>
Preview changes
</SegmentedControl.Button>
<SegmentedControl.Button>Code</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
</div>
</div>
)

ConstrainedLabelOverflow.parameters = {
docs: {
description: {
story:
'The second example limits the first segment to show label truncation. Hover or focus it to reveal the full label. Prefer enough space to display labels in full.',
},
},
}

export const ConstrainedLabelOverflowStressTest = () => (
<div className={classes.ConstrainedLabelStressExamples}>
{(['small', 'medium'] as const).flatMap(size =>
(['default', 'subtle'] as const).map(variant => (
<SegmentedControl
key={`${size}-${variant}`}
aria-label={`${size} ${variant}`}
size={size}
variant={variant}
fullWidth
>
<SegmentedControl.Button defaultSelected leadingVisual={EyeIcon} count={5}>
Preview changes before publishing
</SegmentedControl.Button>
<SegmentedControl.Button>Code</SegmentedControl.Button>
<SegmentedControl.Button leadingVisual={PeopleIcon} count={2}>
Blame
</SegmentedControl.Button>
</SegmentedControl>
)),
)}
</div>
)

ConstrainedLabelOverflowStressTest.storyName = 'Constrained label overflow (stress test)'
ConstrainedLabelOverflowStressTest.parameters = {
docs: {
description: {
story:
'Stress test of defensive label truncation, not a recommended layout. Keep labels short and provide enough space to show them in full. If labels regularly truncate, give the control more space, shorten the labels or use a different component.',
},
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@
--segmented-control-outer-radius: var(--borderRadius-medium);

display: inline-flex;
min-width: 0;
max-width: 100%;

/* TODO: use primitive `control.{small|medium}.size` when it is available */
height: 32px;
Expand Down Expand Up @@ -450,6 +452,7 @@
}

.Counter {
grid-column: 3;
margin-inline-start: var(--base-size-8);
display: flex;
align-items: center;
Expand Down Expand Up @@ -547,7 +550,8 @@
}

.Content {
display: flex;
display: grid;
grid-template-columns: auto minmax(0, max-content) auto;
height: 100%;
/* stylelint-disable-next-line primer/spacing */
padding-right: calc(var(--segmented-control-button-inner-padding) - var(--segmented-control-button-bg-inset));
Expand Down Expand Up @@ -599,6 +603,14 @@
}
}

.Text {
grid-column: 2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
Comment thread
Copilot marked this conversation as resolved.
Comment on lines +609 to +611
}

.Text::after {
display: block;
height: 0;
Expand All @@ -611,5 +623,6 @@
}

.LeadingIcon {
grid-column: 1;
margin-right: var(--base-size-4);
}
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ describe('SegmentedControl', () => {
</BaseStyles>,
)

expect(getByRole('button', {name: 'All (5)'})).toHaveAccessibleName('All (5)')
const iconActionButton = getByRole('button', {name: 'Add view'})

expect(iconActionButton.parentElement?.closest('[data-component]')).toHaveAttribute(
Expand Down
Loading
Loading