From 9f95c061d3f9706918f770a386cf7f8a461ffdf2 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Wed, 19 Aug 2026 11:01:29 -0700 Subject: [PATCH 1/9] subtle variant, action slot --- .../segmented-control-subtle-add-view.md | 5 +++ .../SegmentedControl.docs.json | 32 +++++++++++++- ...gmentedControl.features.stories.module.css | 4 ++ .../SegmentedControl.features.stories.tsx | 43 ++++++++++++++++++ .../SegmentedControl.module.css | 44 +++++++++++++++++++ .../SegmentedControl.test.tsx | 36 +++++++++++++++ .../src/SegmentedControl/SegmentedControl.tsx | 22 ++++++---- .../SegmentedControlAction.tsx | 24 ++++++++++ packages/react/src/SegmentedControl/index.ts | 1 + 9 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 .changeset/segmented-control-subtle-add-view.md create mode 100644 packages/react/src/SegmentedControl/SegmentedControlAction.tsx diff --git a/.changeset/segmented-control-subtle-add-view.md b/.changeset/segmented-control-subtle-add-view.md new file mode 100644 index 00000000000..3e49f61a51d --- /dev/null +++ b/.changeset/segmented-control-subtle-add-view.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +SegmentedControl: Add a subtle visual variant and an optional action child for custom controls. \ No newline at end of file diff --git a/packages/react/src/SegmentedControl/SegmentedControl.docs.json b/packages/react/src/SegmentedControl/SegmentedControl.docs.json index b55d8b7e50f..4cf08feb1c2 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.docs.json +++ b/packages/react/src/SegmentedControl/SegmentedControl.docs.json @@ -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", @@ -69,7 +75,7 @@ }, { "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" }, @@ -177,6 +183,28 @@ "defaultValue": "" } ] + }, + { + "name": "SegmentedControl.Action", + "props": [ + { + "name": "aria-label", + "type": "string", + "required": true, + "description": "Accessible label for the action button." + }, + { + "name": "icon", + "type": "Component", + "description": "The icon for the action button.", + "defaultValue": "PlusIcon" + }, + { + "name": "onClick", + "type": "(event: React.MouseEvent) => void", + "description": "The handler that gets called when the action button is clicked." + } + ] } ] -} \ No newline at end of file +} diff --git a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css index 8ebf0ef2aad..d578fa504b6 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.module.css @@ -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; diff --git a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx index a4d52c079f2..d42717eabe8 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx @@ -2,6 +2,7 @@ import {useState} from 'react' import type {Meta} from '@storybook/react-vite' 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' @@ -34,6 +35,48 @@ export const WithCounterLabels = () => ( ) +export const VariantSubtle = () => ( + + + All + + Active + Review requests + Done + +) +VariantSubtle.storyName = '[variant: subtle]' + +export const WithAddViewButton = () => { + const initialViews = [ + {label: 'All', count: 5}, + {label: 'Active', count: 3}, + {label: 'Review requests', count: 10}, + {label: 'Done', count: 2}, + ] + const [views, setViews] = useState(initialViews) + + const handleAddView = () => { + setViews(currentViews => [...currentViews, {label: `New view ${currentViews.length - 3}`, count: 0}]) + } + + return ( + <> + + {views.map((view, index) => ( + + {view.label} + + ))} + + + + + ) +} + export const Controlled = () => { const [selectedIndex, setSelectedIndex] = useState(0) const handleChange = (i: number) => { diff --git a/packages/react/src/SegmentedControl/SegmentedControl.module.css b/packages/react/src/SegmentedControl/SegmentedControl.module.css index b4567b1ba85..ce5a0fb7f09 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css @@ -107,6 +107,34 @@ display: none; } + &:where([data-variant='subtle']) { + background-color: transparent; + border-color: transparent; + + .Item:not(:last-child)::after { + background-color: transparent; + } + + .Button { + padding: 0; + color: var(--fgColor-muted); + + .Content { + padding-inline: var(--base-size-12); + border-color: transparent; + } + } + + .Button[aria-pressed='true'] { + color: var(--fgColor-default); + + .Content { + background-color: var(--bgColor-muted); + border-color: transparent; + } + } + } + /* Handle hideLabels variant - hide button text */ &[data-variant='hideLabels'] .Text { display: none; @@ -224,6 +252,18 @@ } } +.PlusItem { + display: flex; + align-items: center; + margin-inline-start: var(--base-size-4); +} + +.PlusItem button { + width: 32px; + height: 32px; + padding: 0; +} + .Button { /* TODO: use primitive `primer.control.medium.paddingInline.normal` when it is available */ --segmented-control-button-inner-padding: 12px; @@ -355,6 +395,10 @@ } } +.SegmentedControl:where([data-variant='subtle']) .Button:where(:not([aria-pressed='true'])):hover .Content { + background-color: color-mix(in srgb, var(--bgColor-muted) 50%, transparent); +} + .Text::after { display: block; height: 0; diff --git a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx index c0f6ff4f045..95fe9858a8c 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx @@ -46,6 +46,16 @@ describe('SegmentedControl', () => { expect(getByRole('list')).toHaveAttribute('data-component', 'SegmentedControl') }) + it('renders the subtle variant', () => { + const {getByRole} = render( + + Preview + , + ) + + expect(getByRole('list')).toHaveAttribute('data-variant', 'subtle') + }) + it('renders data-component attribute on segmented control buttons', () => { const {getByRole} = render( @@ -340,6 +350,32 @@ describe('SegmentedControl', () => { expect(handleClick).toHaveBeenCalled() }) + it('calls the action from the plus button', async () => { + const user = userEvent.setup() + const handleAddView = vi.fn() + const {getByRole} = render( + + + + All + + Active + Review requests + + + , + ) + + const plusButton = getByRole('button', {name: 'Add view'}) + expect(plusButton.querySelectorAll('svg')).toHaveLength(1) + expect(plusButton).not.toHaveAttribute('aria-pressed') + expect(plusButton.querySelector('[data-component="trailingAction"]')).not.toBeInTheDocument() + + await user.click(plusButton) + + expect(handleAddView).toHaveBeenCalledOnce() + }) + 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 707530b01f8..55996692a59 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.tsx @@ -3,9 +3,11 @@ import type {SegmentedControlButtonProps} from './SegmentedControlButton' import Button from './SegmentedControlButton' import type {SegmentedControlIconButtonProps} from './SegmentedControlIconButton' import SegmentedControlIconButton from './SegmentedControlIconButton' +import Action from './SegmentedControlAction' import {ActionList} from '../ActionList' import {ActionMenu} from '../ActionMenu' import type {ResponsiveValue} from '../hooks/useResponsiveValue' +import {useSlots} from '../hooks/useSlots' import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes' import type {WidthOnlyViewportRangeKeys} from '../utils/types/ViewportRangeKeys' import {isElement} from 'react-is' @@ -24,7 +26,7 @@ export type SegmentedControlProps = { /** The size of the buttons */ size?: 'small' | 'medium' /** Configure alternative ways to render the control when it gets rendered in tight spaces */ - variant?: 'default' | Partial> + variant?: 'default' | 'subtle' | Partial> className?: string } @@ -40,13 +42,15 @@ const Root: React.FC> = ({ ...rest }) => { const segmentedControlContainerRef = useRef(null) + const [slots, segmentChildren] = useSlots(children, {action: Action}) + const actionChild = slots.action const isUncontrolled = onChange === undefined || - React.Children.toArray(children).some( + segmentChildren.some( child => React.isValidElement(child) && child.props.defaultSelected !== undefined, ) - const selectedSegments = React.Children.toArray(children).map( + const selectedSegments = segmentChildren.map( child => React.isValidElement(child) && (child.props.defaultSelected || child.props.selected), @@ -56,9 +60,9 @@ const Root: React.FC> = ({ const [selectedIndexInternalState, setSelectedIndexInternalState] = useState(selectedIndexExternal) const selectedIndex = isUncontrolled ? selectedIndexInternalState : selectedIndexExternal const selectedChild = React.isValidElement( - React.Children.toArray(children)[selectedIndex], + segmentChildren[selectedIndex], ) - ? React.Children.toArray(children)[selectedIndex] + ? segmentChildren[selectedIndex] : undefined // eslint-disable-next-line @typescript-eslint/no-explicit-any const getChildIcon = (childArg: React.ReactNode): React.ReactElement | null => { @@ -130,7 +134,7 @@ const Root: React.FC> = ({ - {React.Children.map(children, (child, index) => { + {segmentChildren.map((child, index) => { const ChildIcon = getChildIcon(child) // Not a valid child element - skip rendering if (!React.isValidElement(child)) { @@ -170,7 +174,7 @@ const Root: React.FC> = ({ {...rest} data-component="SegmentedControl" > - {React.Children.map(children, (child, index) => { + {segmentChildren.map((child, index) => { // Not a valid child element - skip rendering child if (!React.isValidElement(child)) { return null @@ -207,8 +211,9 @@ const Root: React.FC> = ({ } // Render the children as-is and add the shared child props - return React.cloneElement(child, sharedChildProps) + return React.cloneElement(child, {key: child.key ?? `segmented-control-item-${index}`, ...sharedChildProps}) })} + {actionChild} ) @@ -228,4 +233,5 @@ Root.displayName = 'SegmentedControl' export const SegmentedControl = Object.assign(Root, { Button, IconButton: SegmentedControlIconButton, + Action, }) diff --git a/packages/react/src/SegmentedControl/SegmentedControlAction.tsx b/packages/react/src/SegmentedControl/SegmentedControlAction.tsx new file mode 100644 index 00000000000..501daa4acfd --- /dev/null +++ b/packages/react/src/SegmentedControl/SegmentedControlAction.tsx @@ -0,0 +1,24 @@ +import {PlusIcon} from '@primer/octicons-react' +import type {ElementType} from 'react' +import {IconButton} from '../Button' +import type {IconButtonProps} from '../Button' +import type {FCWithSlotMarker} from '../utils/types' +import classes from './SegmentedControl.module.css' + +type DistributiveOmit = Type extends unknown ? Omit : never + +export type SegmentedControlActionProps = DistributiveOmit & { + icon?: ElementType +} + +const SegmentedControlAction: FCWithSlotMarker = ({icon = PlusIcon, ...props}) => { + return ( +
  • + +
  • + ) +} + +export default SegmentedControlAction + +SegmentedControlAction.__SLOT__ = Symbol('SegmentedControl.Action') diff --git a/packages/react/src/SegmentedControl/index.ts b/packages/react/src/SegmentedControl/index.ts index abb2932cb5e..9d1e628ad4e 100644 --- a/packages/react/src/SegmentedControl/index.ts +++ b/packages/react/src/SegmentedControl/index.ts @@ -3,3 +3,4 @@ export * from './SegmentedControl' export type {SegmentedControlProps} from './SegmentedControl' export type {SegmentedControlButtonProps} from './SegmentedControlButton' export type {SegmentedControlIconButtonProps} from './SegmentedControlIconButton' +export type {SegmentedControlActionProps} from './SegmentedControlAction' From 7b4621e945cc4195cb22736bc3dff72b9e79fb30 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Wed, 19 Aug 2026 13:29:15 -0700 Subject: [PATCH 2/9] divider --- .changeset/segmented-control-subtle-add-view.md | 2 +- .../SegmentedControl/SegmentedControl.docs.json | 12 ++++++++++++ .../SegmentedControl.features.stories.tsx | 15 +++++++++++---- .../SegmentedControl/SegmentedControl.module.css | 10 ++++++++++ .../SegmentedControl/SegmentedControl.test.tsx | 10 ++++++++++ .../SegmentedControl/SegmentedControlButton.tsx | 4 ++++ .../SegmentedControlIconButton.tsx | 4 ++++ 7 files changed, 52 insertions(+), 5 deletions(-) diff --git a/.changeset/segmented-control-subtle-add-view.md b/.changeset/segmented-control-subtle-add-view.md index 3e49f61a51d..184e12211fd 100644 --- a/.changeset/segmented-control-subtle-add-view.md +++ b/.changeset/segmented-control-subtle-add-view.md @@ -2,4 +2,4 @@ '@primer/react': minor --- -SegmentedControl: Add a subtle visual variant and an optional action child for custom controls. \ No newline at end of file +SegmentedControl: Add a subtle visual variant, selective dividers, and an optional action child for custom controls. \ No newline at end of file diff --git a/packages/react/src/SegmentedControl/SegmentedControl.docs.json b/packages/react/src/SegmentedControl/SegmentedControl.docs.json index 4cf08feb1c2..364ab8877da 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.docs.json +++ b/packages/react/src/SegmentedControl/SegmentedControl.docs.json @@ -125,6 +125,12 @@ "defaultValue": "", "description": "The number to display in the counter label." }, + { + "name": "dividerAfter", + "type": "boolean", + "defaultValue": "false", + "description": "Whether to render a divider after the button." + }, { "name": "ref", "type": "React.RefObject" @@ -181,6 +187,12 @@ "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": "dividerAfter", + "type": "boolean", + "defaultValue": "false", + "description": "Whether to render a divider after the button." } ] }, diff --git a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx index d42717eabe8..27f613a2abc 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx @@ -37,12 +37,14 @@ export const WithCounterLabels = () => ( export const VariantSubtle = () => ( - + All Active - Review requests - Done + + Review requests + + Done ) VariantSubtle.storyName = '[variant: subtle]' @@ -64,7 +66,12 @@ export const WithAddViewButton = () => { <> {views.map((view, index) => ( - + {view.label} ))} diff --git a/packages/react/src/SegmentedControl/SegmentedControl.module.css b/packages/react/src/SegmentedControl/SegmentedControl.module.css index ce5a0fb7f09..0bdb964c45b 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css @@ -115,6 +115,16 @@ background-color: transparent; } + .Item[data-divider-after]::after { + position: absolute; + inset-block: var(--base-size-8); + inset-inline-end: calc(-1 * var(--base-size-2)); + width: 1px; + content: ''; + /* stylelint-disable-next-line primer/colors */ + background-color: var(--borderColor-default); + } + .Button { padding: 0; color: var(--fgColor-muted); diff --git a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx index 95fe9858a8c..693faf0d50a 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx @@ -69,6 +69,16 @@ describe('SegmentedControl', () => { ) }) + it('renders a divider after a button when requested', () => { + const {getByRole} = render( + + Preview + , + ) + + expect(getByRole('button', {name: 'Preview'}).closest('li')).toHaveAttribute('data-divider-after', '') + }) + it('renders data-component attribute on segmented control icon buttons', () => { const {getByRole} = render( diff --git a/packages/react/src/SegmentedControl/SegmentedControlButton.tsx b/packages/react/src/SegmentedControl/SegmentedControlButton.tsx index a1896c94d38..607ebb61349 100644 --- a/packages/react/src/SegmentedControl/SegmentedControlButton.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControlButton.tsx @@ -25,6 +25,8 @@ export type SegmentedControlButtonProps = { disabled?: boolean /** Optional counter to display on the right side of the button */ count?: number | string + /** Whether to render a divider after the button */ + dividerAfter?: boolean } & ButtonHTMLAttributes const SegmentedControlButton: FCWithSlotMarker> = ({ @@ -37,6 +39,7 @@ const SegmentedControlButton: FCWithSlotMarker { const {'aria-disabled': ariaDisabled, ...rest} = props @@ -47,6 +50,7 @@ const SegmentedControlButton: FCWithSlotMarker