Skip to content

Commit f896b37

Browse files
khiga8TylerJDevCopilothectahertz
authored
[a11y] When aria-disabled is set on SegmentedControl, don't allow action (#6516)
Co-authored-by: Tyler Jones <tylerjdev@github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Hector Garcia <hectahertz@github.com>
1 parent d8b0e1b commit f896b37

10 files changed

+179
-81
lines changed

.changeset/strong-mangos-rest.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
Remove the feature flag for `primer_react_segmented_control_tooltip` and GA tooltip by default behavior.
6+
- Ensure that when `disabled` is applied, the tooltip is still triggered.

packages/react/src/FeatureFlags/DefaultFeatureFlags.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({
44
primer_react_action_list_item_as_button: false,
55
primer_react_breadcrumbs_overflow_menu: false,
66
primer_react_overlay_overflow: false,
7-
primer_react_segmented_control_tooltip: false,
87
primer_react_select_panel_fullscreen_on_narrow: false,
98
primer_react_select_panel_order_selected_at_top: false,
109
primer_react_select_panel_remove_active_descendant: false,

packages/react/src/SegmentedControl/SegmentedControl.dev.stories.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,82 @@ export default {
1313
parameters: {controls: {exclude: excludedControlKeys}},
1414
} as Meta<typeof SegmentedControl>
1515

16+
export const WithAriaDisabled = () => {
17+
const handleOnClick = () => {
18+
alert('Button clicked!')
19+
}
20+
21+
return (
22+
<SegmentedControl aria-label="File view" className="testCustomClassnameMono">
23+
<SegmentedControl.IconButton
24+
onClick={handleOnClick}
25+
aria-label={'Preview'}
26+
aria-disabled={true}
27+
icon={EyeIcon}
28+
className="testCustomClassnameColor"
29+
>
30+
Preview
31+
</SegmentedControl.IconButton>
32+
<SegmentedControl.IconButton
33+
aria-disabled={true}
34+
onClick={handleOnClick}
35+
aria-label={'Raw'}
36+
icon={FileCodeIcon}
37+
className="testCustomClassnameColor"
38+
>
39+
Raw
40+
</SegmentedControl.IconButton>
41+
<SegmentedControl.IconButton
42+
aria-disabled={true}
43+
onClick={handleOnClick}
44+
aria-label={'Blame'}
45+
icon={PeopleIcon}
46+
className="testCustomClassnameColor"
47+
>
48+
Blame
49+
</SegmentedControl.IconButton>
50+
</SegmentedControl>
51+
)
52+
}
53+
54+
export const WithDisabled = () => {
55+
const handleOnClick = () => {
56+
alert('Button clicked!')
57+
}
58+
59+
return (
60+
<SegmentedControl aria-label="File view" className="testCustomClassnameMono">
61+
<SegmentedControl.IconButton
62+
onClick={handleOnClick}
63+
aria-label={'Preview'}
64+
disabled={true}
65+
icon={EyeIcon}
66+
className="testCustomClassnameColor"
67+
>
68+
Preview
69+
</SegmentedControl.IconButton>
70+
<SegmentedControl.IconButton
71+
disabled={true}
72+
onClick={handleOnClick}
73+
aria-label={'Raw'}
74+
icon={FileCodeIcon}
75+
className="testCustomClassnameColor"
76+
>
77+
Raw
78+
</SegmentedControl.IconButton>
79+
<SegmentedControl.IconButton
80+
disabled={true}
81+
onClick={handleOnClick}
82+
aria-label={'Blame'}
83+
icon={PeopleIcon}
84+
className="testCustomClassnameColor"
85+
>
86+
Blame
87+
</SegmentedControl.IconButton>
88+
</SegmentedControl>
89+
)
90+
}
91+
1692
export const WithCss = () => (
1793
<SegmentedControl aria-label="File view" className="testCustomClassnameMono">
1894
<SegmentedControl.Button
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type {Meta} from '@storybook/react-vite'
2+
import {SegmentedControl} from '.'
3+
import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
4+
5+
export default {
6+
title: 'Components/SegmentedControl/Examples',
7+
component: SegmentedControl,
8+
} as Meta<typeof SegmentedControl>
9+
10+
export const WithDisabledButtons = () => (
11+
<SegmentedControl aria-label="File view">
12+
<SegmentedControl.Button defaultSelected aria-label={'Preview'} leadingIcon={EyeIcon} disabled>
13+
Preview
14+
</SegmentedControl.Button>
15+
<SegmentedControl.Button aria-label={'Raw'} leadingIcon={FileCodeIcon}>
16+
Raw
17+
</SegmentedControl.Button>
18+
<SegmentedControl.Button aria-label={'Blame'} leadingIcon={PeopleIcon} disabled>
19+
Blame
20+
</SegmentedControl.Button>
21+
</SegmentedControl>
22+
)

packages/react/src/SegmentedControl/SegmentedControl.module.css

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@
124124
width: 0;
125125
}
126126

127+
&[aria-disabled='true']:not([aria-current='true']) {
128+
cursor: not-allowed;
129+
color: var(--fgColor-disabled);
130+
background-color: transparent;
131+
132+
& svg {
133+
fill: var(--fgColor-disabled);
134+
color: var(--fgColor-disabled);
135+
}
136+
}
137+
127138
@media (pointer: coarse) {
128139
&::before {
129140
position: absolute;
@@ -183,7 +194,7 @@
183194
}
184195
}
185196

186-
.Button:not([aria-current='true']) {
197+
.Button:not([aria-current='true'], [aria-disabled='true']) {
187198
&:hover .Content {
188199
background-color: var(--controlTrack-bgColor-hover);
189200
}

packages/react/src/SegmentedControl/SegmentedControl.test.tsx

Lines changed: 12 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {describe, expect, it, vi} from 'vitest'
55
import BaseStyles from '../BaseStyles'
66
import theme from '../theme'
77
import ThemeProvider from '../ThemeProvider'
8-
import {FeatureFlags} from '../FeatureFlags'
98
import {SegmentedControl} from '../SegmentedControl'
109

1110
const segmentData = [
@@ -144,19 +143,13 @@ describe('SegmentedControl', () => {
144143
}
145144
})
146145

147-
it('renders icon button with tooltip as label when feature flag is enabled', () => {
146+
it('renders icon button with tooltip as label', () => {
148147
const {getByRole, getByText} = render(
149-
<FeatureFlags
150-
flags={{
151-
primer_react_segmented_control_tooltip: true,
152-
}}
153-
>
154-
<SegmentedControl aria-label="File view">
155-
{segmentData.map(({label, icon}) => (
156-
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} />
157-
))}
158-
</SegmentedControl>
159-
</FeatureFlags>,
148+
<SegmentedControl aria-label="File view">
149+
{segmentData.map(({label, icon}) => (
150+
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} />
151+
))}
152+
</SegmentedControl>,
160153
)
161154

162155
for (const datum of segmentData) {
@@ -167,41 +160,20 @@ describe('SegmentedControl', () => {
167160
}
168161
})
169162

170-
it('renders icon button with tooltip description when feature flag is enabled', () => {
163+
it('renders icon button with tooltip description', () => {
171164
const {getByRole, getByText} = render(
172-
<FeatureFlags
173-
flags={{
174-
primer_react_segmented_control_tooltip: true,
175-
}}
176-
>
177-
<SegmentedControl aria-label="File view">
178-
{segmentData.map(({label, icon, description}) => (
179-
<SegmentedControl.IconButton icon={icon} aria-label={label} description={description} key={label} />
180-
))}
181-
</SegmentedControl>
182-
</FeatureFlags>,
183-
)
184-
185-
for (const datum of segmentData) {
186-
const labelledButton = getByRole('button', {name: datum.label})
187-
const tooltipElement = getByText(datum.description)
188-
expect(labelledButton).toHaveAttribute('aria-describedby', tooltipElement.id)
189-
expect(labelledButton).toHaveAccessibleName(datum.label)
190-
expect(labelledButton).toHaveAttribute('aria-label', datum.label)
191-
}
192-
})
193-
194-
it('renders icon button with aria-label and no tooltip', () => {
195-
const {getByRole} = render(
196165
<SegmentedControl aria-label="File view">
197-
{segmentData.map(({label, icon}) => (
198-
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} />
166+
{segmentData.map(({label, icon, description}) => (
167+
<SegmentedControl.IconButton icon={icon} aria-label={label} description={description} key={label} />
199168
))}
200169
</SegmentedControl>,
201170
)
202171

203172
for (const datum of segmentData) {
204173
const labelledButton = getByRole('button', {name: datum.label})
174+
const tooltipElement = getByText(datum.description)
175+
expect(labelledButton).toHaveAttribute('aria-describedby', tooltipElement.id)
176+
expect(labelledButton).toHaveAccessibleName(datum.label)
205177
expect(labelledButton).toHaveAttribute('aria-label', datum.label)
206178
}
207179
})

packages/react/src/SegmentedControl/SegmentedControl.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,19 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
163163
const sharedChildProps = {
164164
onClick: onChange
165165
? (event: React.MouseEvent<HTMLButtonElement>) => {
166-
onChange(index)
167-
isUncontrolled && setSelectedIndexInternalState(index)
168-
child.props.onClick && child.props.onClick(event)
166+
const isDisabled = child.props.disabled === true || child.props['aria-disabled'] === true
167+
if (!isDisabled) {
168+
onChange(index)
169+
isUncontrolled && setSelectedIndexInternalState(index)
170+
child.props.onClick && child.props.onClick(event)
171+
}
169172
}
170173
: (event: React.MouseEvent<HTMLButtonElement>) => {
171-
child.props.onClick && child.props.onClick(event)
172-
isUncontrolled && setSelectedIndexInternalState(index)
174+
const isDisabled = child.props.disabled === true || child.props['aria-disabled'] === true
175+
if (!isDisabled) {
176+
child.props.onClick && child.props.onClick(event)
177+
isUncontrolled && setSelectedIndexInternalState(index)
178+
}
173179
},
174180
selected: index === selectedIndex,
175181
style: {

packages/react/src/SegmentedControl/SegmentedControlButton.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export type SegmentedControlButtonProps = {
1616
defaultSelected?: boolean
1717
/** The leading icon comes before item label */
1818
leadingIcon?: React.FunctionComponent<React.PropsWithChildren<IconProps>> | React.ReactElement
19+
/** Applies `aria-disabled` to the button. This will disable certain functionality, such as `onClick` events. */
20+
disabled?: boolean
21+
/** Applies `aria-disabled` to the button. This will disable certain functionality, such as `onClick` events. */
22+
'aria-disabled'?: boolean
1923
/** Optional counter to display on the right side of the button */
2024
count?: number | string
2125
} & ButtonHTMLAttributes<HTMLButtonElement | HTMLLIElement>
@@ -25,14 +29,22 @@ const SegmentedControlButton: React.FC<React.PropsWithChildren<SegmentedControlB
2529
leadingIcon: LeadingIcon,
2630
selected,
2731
className,
32+
disabled,
33+
'aria-disabled': ariaDisabled,
2834
// 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
2935
defaultSelected: _defaultSelected,
3036
count,
3137
...rest
3238
}) => {
3339
return (
3440
<li className={clsx(classes.Item)} data-selected={selected ? '' : undefined}>
35-
<button aria-current={selected} className={clsx(classes.Button, className)} type="button" {...rest}>
41+
<button
42+
aria-current={selected}
43+
aria-disabled={disabled || ariaDisabled || undefined}
44+
className={clsx(classes.Button, className)}
45+
type="button"
46+
{...rest}
47+
>
3648
<span className={clsx(classes.Content, 'segmentedControl-content')}>
3749
{LeadingIcon && (
3850
<div className={classes.LeadingIcon}>{isElement(LeadingIcon) ? LeadingIcon : <LeadingIcon />}</div>

packages/react/src/SegmentedControl/SegmentedControlIconButton.stories.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export default {
1313
icon: FileCodeIcon,
1414
selected: false,
1515
defaultSelected: false,
16+
disabled: false,
17+
'aria-disabled': false,
1618
},
1719
argTypes: {
1820
icon: {
@@ -26,6 +28,12 @@ export default {
2628
defaultSelected: {
2729
type: 'boolean',
2830
},
31+
disabled: {
32+
type: 'boolean',
33+
},
34+
'aria-disabled': {
35+
type: 'boolean',
36+
},
2937
},
3038
decorators: [
3139
Story => {

packages/react/src/SegmentedControl/SegmentedControlIconButton.tsx

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type {ButtonHTMLAttributes} from 'react'
22
import type React from 'react'
33
import type {IconProps} from '@primer/octicons-react'
44
import {isElement} from 'react-is'
5-
import {useFeatureFlag} from '../FeatureFlags'
65
import type {TooltipDirection} from '../TooltipV2'
76
import classes from './SegmentedControl.module.css'
87
import {clsx} from 'clsx'
@@ -20,6 +19,10 @@ export type SegmentedControlIconButtonProps = {
2019
description?: string
2120
/** The direction for the tooltip.*/
2221
tooltipDirection?: TooltipDirection
22+
/** Whether the button is disabled. */
23+
disabled?: boolean
24+
/** Whether the button is aria-disabled. */
25+
'aria-disabled'?: boolean
2326
} & ButtonHTMLAttributes<HTMLButtonElement | HTMLLIElement>
2427

2528
export const SegmentedControlIconButton: React.FC<React.PropsWithChildren<SegmentedControlIconButtonProps>> = ({
@@ -29,48 +32,31 @@ export const SegmentedControlIconButton: React.FC<React.PropsWithChildren<Segmen
2932
className,
3033
description,
3134
tooltipDirection,
35+
disabled,
36+
'aria-disabled': ariaDisabled,
3237
...rest
3338
}) => {
34-
const tooltipFlagEnabled = useFeatureFlag('primer_react_segmented_control_tooltip')
35-
if (tooltipFlagEnabled) {
36-
return (
37-
<li className={clsx(classes.Item, className)} data-selected={selected || undefined}>
38-
<Tooltip
39-
type={description ? undefined : 'label'}
40-
text={description ? description : ariaLabel}
41-
direction={tooltipDirection}
42-
>
43-
<button
44-
type="button"
45-
aria-current={selected}
46-
// 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.
47-
aria-label={description ? ariaLabel : undefined}
48-
className={clsx(classes.Button, classes.IconButton)}
49-
{...rest}
50-
>
51-
<span className={clsx(classes.Content, 'segmentedControl-content')}>
52-
{isElement(Icon) ? Icon : <Icon />}
53-
</span>
54-
</button>
55-
</Tooltip>
56-
</li>
57-
)
58-
} else {
59-
// This can be removed when primer_react_segmented_control_tooltip feature flag is GA-ed.
60-
return (
61-
<li className={clsx(classes.Item, className)} data-selected={selected || undefined}>
39+
return (
40+
<li className={clsx(classes.Item, className)} data-selected={selected || undefined}>
41+
<Tooltip
42+
type={description ? undefined : 'label'}
43+
text={description ? description : ariaLabel}
44+
direction={tooltipDirection}
45+
>
6246
<button
6347
type="button"
64-
aria-label={ariaLabel}
6548
aria-current={selected}
49+
// 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.
50+
aria-label={description ? ariaLabel : undefined}
51+
aria-disabled={disabled || ariaDisabled || undefined}
6652
className={clsx(classes.Button, classes.IconButton)}
6753
{...rest}
6854
>
6955
<span className={clsx(classes.Content, 'segmentedControl-content')}>{isElement(Icon) ? Icon : <Icon />}</span>
7056
</button>
71-
</li>
72-
)
73-
}
57+
</Tooltip>
58+
</li>
59+
)
7460
}
7561

7662
export default SegmentedControlIconButton

0 commit comments

Comments
 (0)