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
6 changes: 6 additions & 0 deletions .changeset/strong-mangos-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@primer/react": minor
---

Remove the feature flag for `primer_react_segmented_control_tooltip` and GA tooltip by default behavior.
- Ensure that when `disabled` is applied, the tooltip is still triggered.
1 change: 0 additions & 1 deletion packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({
primer_react_action_list_item_as_button: false,
primer_react_breadcrumbs_overflow_menu: false,
primer_react_overlay_overflow: false,
primer_react_segmented_control_tooltip: false,
primer_react_select_panel_fullscreen_on_narrow: false,
primer_react_select_panel_order_selected_at_top: false,
primer_react_select_panel_remove_active_descendant: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,82 @@ export default {
parameters: {controls: {exclude: excludedControlKeys}},
} as Meta<typeof SegmentedControl>

export const WithAriaDisabled = () => {
const handleOnClick = () => {
alert('Button clicked!')
}

return (
<SegmentedControl aria-label="File view" className="testCustomClassnameMono">
<SegmentedControl.IconButton
onClick={handleOnClick}
aria-label={'Preview'}
aria-disabled={true}
icon={EyeIcon}
className="testCustomClassnameColor"
>
Preview
</SegmentedControl.IconButton>
<SegmentedControl.IconButton
aria-disabled={true}
onClick={handleOnClick}
aria-label={'Raw'}
icon={FileCodeIcon}
className="testCustomClassnameColor"
>
Raw
</SegmentedControl.IconButton>
<SegmentedControl.IconButton
aria-disabled={true}
onClick={handleOnClick}
aria-label={'Blame'}
icon={PeopleIcon}
className="testCustomClassnameColor"
>
Blame
</SegmentedControl.IconButton>
</SegmentedControl>
)
}

export const WithDisabled = () => {
const handleOnClick = () => {
alert('Button clicked!')
}

return (
<SegmentedControl aria-label="File view" className="testCustomClassnameMono">
<SegmentedControl.IconButton
onClick={handleOnClick}
aria-label={'Preview'}
disabled={true}
icon={EyeIcon}
className="testCustomClassnameColor"
>
Preview
</SegmentedControl.IconButton>
<SegmentedControl.IconButton
disabled={true}
onClick={handleOnClick}
aria-label={'Raw'}
icon={FileCodeIcon}
className="testCustomClassnameColor"
>
Raw
</SegmentedControl.IconButton>
<SegmentedControl.IconButton
disabled={true}
onClick={handleOnClick}
aria-label={'Blame'}
icon={PeopleIcon}
className="testCustomClassnameColor"
>
Blame
</SegmentedControl.IconButton>
</SegmentedControl>
)
}

export const WithCss = () => (
<SegmentedControl aria-label="File view" className="testCustomClassnameMono">
<SegmentedControl.Button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {Meta} from '@storybook/react-vite'
import {SegmentedControl} from '.'
import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'

export default {
title: 'Components/SegmentedControl/Examples',
component: SegmentedControl,
} as Meta<typeof SegmentedControl>

export const WithDisabledButtons = () => (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need VRT?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah! I'll add one.

<SegmentedControl aria-label="File view">
<SegmentedControl.Button defaultSelected aria-label={'Preview'} leadingIcon={EyeIcon} disabled>
Preview
</SegmentedControl.Button>
<SegmentedControl.Button aria-label={'Raw'} leadingIcon={FileCodeIcon}>
Raw
</SegmentedControl.Button>
<SegmentedControl.Button aria-label={'Blame'} leadingIcon={PeopleIcon} disabled>
Blame
</SegmentedControl.Button>
</SegmentedControl>
)
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@
width: 0;
}

&[aria-disabled='true']:not([aria-current='true']) {
cursor: not-allowed;
color: var(--fgColor-disabled);
background-color: transparent;

& svg {
fill: var(--fgColor-disabled);
color: var(--fgColor-disabled);
}
}

@media (pointer: coarse) {
&::before {
position: absolute;
Expand Down Expand Up @@ -183,7 +194,7 @@
}
}

.Button:not([aria-current='true']) {
.Button:not([aria-current='true'], [aria-disabled='true']) {
&:hover .Content {
background-color: var(--controlTrack-bgColor-hover);
}
Expand Down
52 changes: 12 additions & 40 deletions packages/react/src/SegmentedControl/SegmentedControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
import userEvent from '@testing-library/user-event'
import {describe, expect, it, vi} from 'vitest'
import BaseStyles from '../BaseStyles'
import {FeatureFlags} from '../FeatureFlags'
import {SegmentedControl} from '../SegmentedControl'

const segmentData = [
Expand Down Expand Up @@ -142,19 +141,13 @@ describe('SegmentedControl', () => {
}
})

it('renders icon button with tooltip as label when feature flag is enabled', () => {
it('renders icon button with tooltip as label', () => {
const {getByRole, getByText} = render(
<FeatureFlags
flags={{
primer_react_segmented_control_tooltip: true,
}}
>
<SegmentedControl aria-label="File view">
{segmentData.map(({label, icon}) => (
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} />
))}
</SegmentedControl>
</FeatureFlags>,
<SegmentedControl aria-label="File view">
{segmentData.map(({label, icon}) => (
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} />
))}
</SegmentedControl>,
)

for (const datum of segmentData) {
Expand All @@ -165,41 +158,20 @@ describe('SegmentedControl', () => {
}
})

it('renders icon button with tooltip description when feature flag is enabled', () => {
it('renders icon button with tooltip description', () => {
const {getByRole, getByText} = render(
<FeatureFlags
flags={{
primer_react_segmented_control_tooltip: true,
}}
>
<SegmentedControl aria-label="File view">
{segmentData.map(({label, icon, description}) => (
<SegmentedControl.IconButton icon={icon} aria-label={label} description={description} key={label} />
))}
</SegmentedControl>
</FeatureFlags>,
)

for (const datum of segmentData) {
const labelledButton = getByRole('button', {name: datum.label})
const tooltipElement = getByText(datum.description)
expect(labelledButton).toHaveAttribute('aria-describedby', tooltipElement.id)
expect(labelledButton).toHaveAccessibleName(datum.label)
expect(labelledButton).toHaveAttribute('aria-label', datum.label)
}
})

it('renders icon button with aria-label and no tooltip', () => {
const {getByRole} = render(
<SegmentedControl aria-label="File view">
{segmentData.map(({label, icon}) => (
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} />
{segmentData.map(({label, icon, description}) => (
<SegmentedControl.IconButton icon={icon} aria-label={label} description={description} key={label} />
))}
</SegmentedControl>,
)

for (const datum of segmentData) {
const labelledButton = getByRole('button', {name: datum.label})
const tooltipElement = getByText(datum.description)
expect(labelledButton).toHaveAttribute('aria-describedby', tooltipElement.id)
expect(labelledButton).toHaveAccessibleName(datum.label)
expect(labelledButton).toHaveAttribute('aria-label', datum.label)
}
})
Expand Down
16 changes: 11 additions & 5 deletions packages/react/src/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,19 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
const sharedChildProps = {
onClick: onChange
? (event: React.MouseEvent<HTMLButtonElement>) => {
onChange(index)
isUncontrolled && setSelectedIndexInternalState(index)
child.props.onClick && child.props.onClick(event)
const isDisabled = child.props.disabled === true || child.props['aria-disabled'] === true
Copy link

Copilot AI Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disabled check is duplicated on lines 170 and 178. Consider extracting this logic into a reusable function or variable before the conditional branches to avoid repetition.

Copilot uses AI. Check for mistakes.
if (!isDisabled) {
onChange(index)
isUncontrolled && setSelectedIndexInternalState(index)
child.props.onClick && child.props.onClick(event)
}
}
: (event: React.MouseEvent<HTMLButtonElement>) => {
child.props.onClick && child.props.onClick(event)
isUncontrolled && setSelectedIndexInternalState(index)
const isDisabled = child.props.disabled === true || child.props['aria-disabled'] === true
if (!isDisabled) {
child.props.onClick && child.props.onClick(event)
isUncontrolled && setSelectedIndexInternalState(index)
}
},
selected: index === selectedIndex,
style: {
Expand Down
14 changes: 13 additions & 1 deletion packages/react/src/SegmentedControl/SegmentedControlButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export type SegmentedControlButtonProps = {
defaultSelected?: boolean
/** The leading icon comes before item label */
leadingIcon?: React.FunctionComponent<React.PropsWithChildren<IconProps>> | React.ReactElement
/** Applies `aria-disabled` to the button. This will disable certain functionality, such as `onClick` events. */
disabled?: boolean
/** Applies `aria-disabled` to the button. This will disable certain functionality, such as `onClick` events. */
Comment on lines +20 to +22
Copy link

Copilot AI Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for both disabled and aria-disabled is identical. The disabled prop should be documented to clarify that it applies the aria-disabled attribute and disables click functionality, distinguishing it from the aria-disabled prop.

Suggested change
/** Applies `aria-disabled` to the button. This will disable certain functionality, such as `onClick` events. */
disabled?: boolean
/** Applies `aria-disabled` to the button. This will disable certain functionality, such as `onClick` events. */
/**
* Disables the button, preventing click functionality and applying the `aria-disabled` attribute.
* Use this prop to make the button non-interactive.
*/
disabled?: boolean
/**
* Applies the `aria-disabled` attribute to the button for accessibility purposes, but does not disable click functionality.
* Use this prop if you want the button to appear disabled to assistive technologies but remain interactive.
*/

Copilot uses AI. Check for mistakes.
'aria-disabled'?: boolean
/** Optional counter to display on the right side of the button */
count?: number | string
} & ButtonHTMLAttributes<HTMLButtonElement | HTMLLIElement>
Expand All @@ -26,14 +30,22 @@ const SegmentedControlButton: FCWithSlotMarker<React.PropsWithChildren<Segmented
leadingIcon: LeadingIcon,
selected,
className,
disabled,
'aria-disabled': ariaDisabled,
Comment on lines +33 to +34
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im confused on what the difference between these two props is.
Also, does the *.docs.json need to be updated?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to be backwards compatible from what I recall. Ideally we'll remove one or the other once we reduce usage in dotcom.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we deprecate one now then?

// Note: this value is read in the `SegmentedControl` component to determine which button is selected but we do not need to apply it to an underlying element
defaultSelected: _defaultSelected,
count,
...rest
}) => {
return (
<li className={clsx(classes.Item)} data-selected={selected ? '' : undefined}>
<button aria-current={selected} className={clsx(classes.Button, className)} type="button" {...rest}>
<button
aria-current={selected}
aria-disabled={disabled || ariaDisabled || undefined}
className={clsx(classes.Button, className)}
type="button"
{...rest}
>
<span className={clsx(classes.Content, 'segmentedControl-content')}>
{LeadingIcon && (
<div className={classes.LeadingIcon}>{isElement(LeadingIcon) ? LeadingIcon : <LeadingIcon />}</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export default {
icon: FileCodeIcon,
selected: false,
defaultSelected: false,
disabled: false,
'aria-disabled': false,
},
argTypes: {
icon: {
Expand All @@ -26,6 +28,12 @@ export default {
defaultSelected: {
type: 'boolean',
},
disabled: {
type: 'boolean',
},
'aria-disabled': {
type: 'boolean',
},
},
decorators: [
Story => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type {ButtonHTMLAttributes} from 'react'
import type React from 'react'
import type {IconProps} from '@primer/octicons-react'
import {isElement} from 'react-is'
import {useFeatureFlag} from '../FeatureFlags'
import type {TooltipDirection} from '../TooltipV2'
import classes from './SegmentedControl.module.css'
import {clsx} from 'clsx'
Expand All @@ -21,6 +20,10 @@ export type SegmentedControlIconButtonProps = {
description?: string
/** The direction for the tooltip.*/
tooltipDirection?: TooltipDirection
/** Whether the button is disabled. */
disabled?: boolean
/** Whether the button is aria-disabled. */
'aria-disabled'?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement | HTMLLIElement>

export const SegmentedControlIconButton: FCWithSlotMarker<React.PropsWithChildren<SegmentedControlIconButtonProps>> = ({
Expand All @@ -30,48 +33,31 @@ export const SegmentedControlIconButton: FCWithSlotMarker<React.PropsWithChildre
className,
description,
tooltipDirection,
disabled,
'aria-disabled': ariaDisabled,
...rest
}) => {
const tooltipFlagEnabled = useFeatureFlag('primer_react_segmented_control_tooltip')
if (tooltipFlagEnabled) {
return (
<li className={clsx(classes.Item, className)} data-selected={selected || undefined}>
<Tooltip
type={description ? undefined : 'label'}
text={description ? description : ariaLabel}
direction={tooltipDirection}
>
<button
type="button"
aria-current={selected}
// If description is provided, we will use the tooltip to describe the button, so we need to keep the aria-label to label the button.
aria-label={description ? ariaLabel : undefined}
className={clsx(classes.Button, classes.IconButton)}
{...rest}
>
<span className={clsx(classes.Content, 'segmentedControl-content')}>
{isElement(Icon) ? Icon : <Icon />}
</span>
</button>
</Tooltip>
</li>
)
} else {
// This can be removed when primer_react_segmented_control_tooltip feature flag is GA-ed.
return (
<li className={clsx(classes.Item, className)} data-selected={selected || undefined}>
return (
<li className={clsx(classes.Item, className)} data-selected={selected || undefined}>
<Tooltip
type={description ? undefined : 'label'}
text={description ? description : ariaLabel}
direction={tooltipDirection}
>
<button
type="button"
aria-label={ariaLabel}
aria-current={selected}
// If description is provided, we will use the tooltip to describe the button, so we need to keep the aria-label to label the button.
aria-label={description ? ariaLabel : undefined}
aria-disabled={disabled || ariaDisabled || undefined}
className={clsx(classes.Button, classes.IconButton)}
{...rest}
>
<span className={clsx(classes.Content, 'segmentedControl-content')}>{isElement(Icon) ? Icon : <Icon />}</span>
</button>
</li>
)
}
</Tooltip>
</li>
)
}

SegmentedControlIconButton.__SLOT__ = Symbol('SegmentedControl.IconButton')
Expand Down
Loading