Skip to content
Draft
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-subtle-add-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

SegmentedControl: Add a subtle visual variant with group dividers and an optional trailing action child.
46 changes: 43 additions & 3 deletions packages/react/src/SegmentedControl/SegmentedControl.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
},
{
"id": "components-segmentedcontrol-features--associated-with-a-label-and-caption"
},
{
"id": "components-segmentedcontrol-features--variant-subtle"
},
{
"id": "components-segmentedcontrol-features--with-add-view-button"
}
],
"importPath": "@primer/react",
Expand Down Expand Up @@ -69,9 +75,9 @@
},
{
"name": "variant",
"type": "| 'default' | { narrow?: 'hideLabels' | 'dropdown' | 'default' regular?: 'hideLabels' | 'dropdown' | 'default' wide?: 'hideLabels' | 'dropdown' | 'default' }",
"type": "| 'default' | 'subtle' | { narrow?: 'hideLabels' | 'dropdown' | 'default' regular?: 'hideLabels' | 'dropdown' | 'default' wide?: 'hideLabels' | 'dropdown' | 'default' }",
"defaultValue": "'default'",
"description": "Configure alternative ways to render the control when it gets rendered in tight spaces"
"description": "Configure the control's visual treatment or responsive behavior in tight spaces."
},
{
"name": "size",
Expand Down Expand Up @@ -119,6 +125,12 @@
"defaultValue": "",
"description": "The number to display in the counter label."
},
{
"name": "dividerBefore",
"type": "boolean",
"defaultValue": "false",
"description": "Whether to render a group divider before the button in the subtle variant."
},
{
"name": "ref",
"type": "React.RefObject<HTMLButtonElement>"
Expand Down Expand Up @@ -175,8 +187,36 @@
"required": false,
"description": "If `description` is provided, we will use a Tooltip to describe the button. Then `aria-label` is used to label the button.",
"defaultValue": ""
},
{
"name": "dividerBefore",
"type": "boolean",
"defaultValue": "false",
"description": "Whether to render a group divider before the button in the subtle variant."
}
]
},
{
"name": "SegmentedControl.Action",
"props": [
{
"name": "label",
"type": "string",
"required": true,
"description": "Accessible label for the action icon button."
},
{
"name": "icon",
"type": "Component",
"required": true,
"description": "The icon rendered by the action button."
},
{
"name": "onClick",
"type": "(event: React.MouseEvent<HTMLButtonElement>) => void",
"description": "The handler that gets called when the action button is clicked."
}
]
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
gap: var(--base-size-4);
}

.ResetButton {
margin-top: var(--base-size-24);
}

@media screen and (min-width: 768px) {
.LabelAndCaptionContainer {
flex-direction: row;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {useState} from 'react'
import type {Meta} from '@storybook/react-vite'
import {PlusIcon, EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
import {SegmentedControl} from '.'
import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
import {Button} from '../Button'
import Text from '../Text'
import classes from './SegmentedControl.features.stories.module.css'

Expand Down Expand Up @@ -34,6 +35,51 @@ export const WithCounterLabels = () => (
</SegmentedControl>
)

export const VariantSubtle = () => (
<SegmentedControl aria-label="View" variant="subtle">
<SegmentedControl.Button defaultSelected count={5}>
All
</SegmentedControl.Button>
<SegmentedControl.Button count={3} dividerBefore>
Active
</SegmentedControl.Button>
<SegmentedControl.Button count={10}>Review requests</SegmentedControl.Button>
<SegmentedControl.Button count={2} dividerBefore>
Done
</SegmentedControl.Button>
</SegmentedControl>
)
VariantSubtle.storyName = '[variant: subtle]'

export const WithAddViewButton = () => {
const initialViews = [{label: 'All'}, {label: 'Active'}, {label: 'Review requests'}, {label: 'Done'}]
const [views, setViews] = useState(initialViews)

const handleAddView = () => {
setViews(currentViews => [...currentViews, {label: `New view ${currentViews.length - 3}`}])
}

return (
<>
<SegmentedControl aria-label="View" variant="subtle">
{views.map((view, index) => (
<SegmentedControl.Button
key={view.label}
defaultSelected={index === 0}
dividerBefore={view.label === 'Active'}
>
{view.label}
</SegmentedControl.Button>
))}
<SegmentedControl.Action label="Add view" icon={PlusIcon} onClick={handleAddView} />
</SegmentedControl>
<Button className={classes.ResetButton} size="small" onClick={() => setViews(initialViews)}>
Reset views
</Button>
</>
)
}

export const Controlled = () => {
const [selectedIndex, setSelectedIndex] = useState(0)
const handleChange = (i: number) => {
Expand Down
77 changes: 77 additions & 0 deletions packages/react/src/SegmentedControl/SegmentedControl.module.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.SegmentedControl {
/* TODO: use primitive `control.medium.size` when it is available instead of '32px' */
--segmented-control-icon-width: 32px;
--segmented-control-action-size: 32px;
--segmented-control-outer-radius: var(--borderRadius-medium);

display: inline-flex;
Expand Down Expand Up @@ -107,6 +108,57 @@
display: none;
}

&:where([data-variant='subtle']) {
--segmented-control-outer-radius: var(--borderRadius-large);
--segmented-control-button-bg-inset: 0px;

background-color: transparent;
border: 0;

&::before {
display: none;
}

.Item:not(:last-child)::after {
background-color: transparent;
}

.Item[data-divider-before] {
margin-inline-start: var(--base-size-16);
}

.Item[data-divider-before]::before {
position: absolute;
inset-block: var(--base-size-8);
inset-inline-start: calc(-1 * var(--base-size-8));
width: 1px;
content: '';
z-index: 1;
/* stylelint-disable-next-line primer/colors */
background-color: var(--borderColor-default);
}

.Button {
padding: 0;
color: var(--fgColor-muted);
border: 0;
border-radius: var(--borderRadius-large);

.Content {
padding-inline: var(--base-size-12);
border: 0;
}
}

.Button[aria-pressed='true'] {
color: var(--fgColor-default);

.Content {
background-color: var(--bgColor-muted);
}
}
}

/* Handle hideLabels variant - hide button text */
&[data-variant='hideLabels'] .Text {
display: none;
Expand Down Expand Up @@ -144,11 +196,19 @@

&:where([data-size='small']) {
/* TODO: use primitive `control.{small|medium}.size` when it is available */
--segmented-control-action-size: 28px;

height: 28px;
font-size: var(--text-body-size-small);
}
}

.DropdownGroup {
display: inline-flex;
align-items: center;
gap: var(--base-size-4);
}

.DropdownContainer {
display: none;

Expand Down Expand Up @@ -224,6 +284,18 @@
}
}

.ActionItem {
display: flex;
align-items: center;
margin-inline-start: 0;
}

.ActionItem [data-component='IconButton'] {
width: var(--segmented-control-action-size);
height: var(--segmented-control-action-size);
padding: 0;
}

.Button {
/* TODO: use primitive `primer.control.medium.paddingInline.normal` when it is available */
--segmented-control-button-inner-padding: 12px;
Expand Down Expand Up @@ -355,6 +427,11 @@
}
}

.SegmentedControl:where([data-variant='subtle']) .Button:where(:not([aria-pressed='true'])):hover .Content {
background-color: var(--bgColor-muted);
background-color: color-mix(in srgb, var(--bgColor-muted) 50%, transparent);
}
Comment thread
Copilot marked this conversation as resolved.

.Text::after {
display: block;
height: 0;
Expand Down
48 changes: 47 additions & 1 deletion packages/react/src/SegmentedControl/SegmentedControl.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {render, fireEvent, waitFor} from '@testing-library/react'
import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
import {EyeIcon, FileCodeIcon, PeopleIcon, PlusIcon} from '@primer/octicons-react'
import userEvent from '@testing-library/user-event'
import {describe, expect, it, vi} from 'vitest'
import BaseStyles from '../BaseStyles'
Expand Down Expand Up @@ -46,6 +46,16 @@ describe('SegmentedControl', () => {
expect(getByRole('list')).toHaveAttribute('data-component', 'SegmentedControl')
})

it('renders the subtle variant', () => {
const {getByRole} = render(
<SegmentedControl aria-label="File view" variant="subtle">
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
</SegmentedControl>,
)

expect(getByRole('list')).toHaveAttribute('data-variant', 'subtle')
})

it('renders data-component attribute on segmented control buttons', () => {
const {getByRole} = render(
<SegmentedControl aria-label="File view">
Expand All @@ -59,6 +69,16 @@ describe('SegmentedControl', () => {
)
})

it('renders a divider before a button when requested', () => {
const {getByRole} = render(
<SegmentedControl aria-label="File view">
<SegmentedControl.Button dividerBefore>Preview</SegmentedControl.Button>
</SegmentedControl>,
)

expect(getByRole('button', {name: 'Preview'}).closest('li')).toHaveAttribute('data-divider-before', '')
})

it('renders data-component attribute on segmented control icon buttons', () => {
const {getByRole} = render(
<SegmentedControl aria-label="File view">
Expand Down Expand Up @@ -340,6 +360,32 @@ describe('SegmentedControl', () => {
expect(handleClick).toHaveBeenCalled()
})

it('calls the action from the icon action button', async () => {
const user = userEvent.setup()
const handleAddView = vi.fn()
const {getByRole} = render(
<BaseStyles>
<SegmentedControl aria-label="File view">
<SegmentedControl.Button defaultSelected count={5}>
All
</SegmentedControl.Button>
<SegmentedControl.Button count={3}>Active</SegmentedControl.Button>
<SegmentedControl.Button count={10}>Review requests</SegmentedControl.Button>
<SegmentedControl.Action label="Add view" icon={PlusIcon} onClick={handleAddView} />
</SegmentedControl>
</BaseStyles>,
)

const iconActionButton = getByRole('button', {name: 'Add view'})
expect(iconActionButton.querySelectorAll('svg')).toHaveLength(1)
expect(iconActionButton).not.toHaveAttribute('aria-pressed')
expect(iconActionButton.querySelector('[data-component="trailingAction"]')).not.toBeInTheDocument()

await user.click(iconActionButton)

expect(handleAddView).toHaveBeenCalledOnce()
})

it('supports deprecated leadingIcon prop for backward compatibility', () => {
const {getByText} = render(
<SegmentedControl aria-label="File view">
Expand Down
Loading
Loading