diff --git a/.changeset/segmented-control-subtle-add-view.md b/.changeset/segmented-control-subtle-add-view.md new file mode 100644 index 00000000000..d07d0845e84 --- /dev/null +++ b/.changeset/segmented-control-subtle-add-view.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +SegmentedControl: Add a subtle visual variant with group dividers and an optional trailing action child. \ 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..f9729b3a0e3 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,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", @@ -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" @@ -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) => 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..221e19094ab 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx @@ -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' @@ -34,6 +35,51 @@ export const WithCounterLabels = () => ( ) +export const VariantSubtle = () => ( + + + All + + + Active + + Review requests + + Done + + +) +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 ( + <> + + {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..483feb38736 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.module.css +++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css @@ -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; @@ -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; @@ -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; @@ -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; @@ -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); +} + .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..22b125266ea 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx @@ -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' @@ -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( @@ -59,6 +69,16 @@ describe('SegmentedControl', () => { ) }) + it('renders a divider before a button when requested', () => { + const {getByRole} = render( + + Preview + , + ) + + expect(getByRole('button', {name: 'Preview'}).closest('li')).toHaveAttribute('data-divider-before', '') + }) + it('renders data-component attribute on segmented control icon buttons', () => { const {getByRole} = render( @@ -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( + + + + All + + Active + Review requests + + + , + ) + + 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( diff --git a/packages/react/src/SegmentedControl/SegmentedControl.tsx b/packages/react/src/SegmentedControl/SegmentedControl.tsx index 707530b01f8..b6dc8fea205 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,12 +60,11 @@ 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 => { + const getChildIcon = (childArg: React.ReactNode): React.ReactElement | null => { if ( React.isValidElement(childArg) && (childArg.type === Button || isSlot(childArg, Button)) @@ -69,12 +72,10 @@ const Root: React.FC> = ({ // Use leadingVisual if provided, otherwise fall back to leadingIcon for backwards compatibility const leadingVisual = childArg.props.leadingVisual ?? childArg.props.leadingIcon if (leadingVisual) { - if (isElement(leadingVisual)) { - return leadingVisual - } else { - const LeadingVisual = leadingVisual - return - } + if (isElement(leadingVisual)) return leadingVisual + + const LeadingVisual = leadingVisual + return } } @@ -82,12 +83,10 @@ const Root: React.FC> = ({ React.isValidElement(childArg) && (childArg.type === SegmentedControlIconButton || isSlot(childArg, SegmentedControlIconButton)) ) { - if (isElement(childArg.props.icon)) { - childArg.props.icon - } else { - const Icon = childArg.props.icon - return - } + if (isElement(childArg.props.icon)) return childArg.props.icon + + const Icon = childArg.props.icon + return } return null @@ -130,7 +129,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 +169,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,17 +206,18 @@ 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}) })} ) - // Return both variants when dropdown is used, otherwise just the segmented control - return hasDropdownVariant ? ( - <> + // Action is always a sibling of the segmented control; only the dropdown trigger depends on the variant. + return hasDropdownVariant || actionChild ? ( +
{dropdownContent} {segmentedControlContent} - + {actionChild} +
) : ( segmentedControlContent ) @@ -228,4 +228,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..1733ed3b07a --- /dev/null +++ b/packages/react/src/SegmentedControl/SegmentedControlAction.tsx @@ -0,0 +1,32 @@ +import type {ElementType} from 'react' +import {IconButton} from '../Button' +import type {IconButtonProps} from '../Button' +import type {DistributiveOmit} from '../utils/modern-polymorphic' +import type {FCWithSlotMarker} from '../utils/types' +import classes from './SegmentedControl.module.css' + +type SegmentedControlActionBaseProps = { + label: string +} + +type SegmentedControlIconActionProps = SegmentedControlActionBaseProps & + DistributiveOmit & { + icon: ElementType + } +export type SegmentedControlActionProps = SegmentedControlIconActionProps + +const SegmentedControlAction: FCWithSlotMarker = props => { + const {icon, label, ...iconButtonProps} = props + + return ( +
+ +
+ ) +} + +SegmentedControlAction.displayName = 'SegmentedControl.Action' + +export default SegmentedControlAction + +SegmentedControlAction.__SLOT__ = Symbol('SegmentedControl.Action') diff --git a/packages/react/src/SegmentedControl/SegmentedControlButton.tsx b/packages/react/src/SegmentedControl/SegmentedControlButton.tsx index a1896c94d38..78c124b4290 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 before the button */ + dividerBefore?: 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