Skip to content

Commit dbcb8f7

Browse files
authoredFeb 11, 2025··
feat: Introduce Tooltip to SegmentedControlIconButton (#5679)
* feat: Introduce Tooltip to SegmentedControlIconButton * fix: Ensure tooltip renders description * Create sharp-flowers-repair.md * fix: run prettier * test to fill out * all tests pass * update docs * Run prettier * Swap to use feature flag * Fix syntax issue * Remove unsafeDisableTooltip from branch
1 parent 8bb78e1 commit dbcb8f7

File tree

5 files changed

+163
-32
lines changed

5 files changed

+163
-32
lines changed
 

‎.changeset/sharp-flowers-repair.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
feat: Introduce Tooltip to SegmentedControlIconButton

‎packages/react/src/FeatureFlags/DefaultFeatureFlags.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({
66
primer_react_action_list_item_as_button: false,
77
primer_react_select_panel_with_modern_action_list: false,
88
primer_react_overlay_overflow: false,
9+
primer_react_segmented_control_tooltip: false,
910
})

‎packages/react/src/SegmentedControl/SegmentedControl.docs.json

+14-2
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@
144144
"description": "Whether the segment is selected. This is used for controlled SegmentedControls, and needs to be updated using the onChange handler on SegmentedControl."
145145
},
146146
{
147-
"name": "selected",
147+
"name": "size",
148148
"type": "'small' | 'medium'",
149149
"defaultValue": "",
150150
"description": "The size of the buttons"
@@ -162,8 +162,20 @@
162162
{
163163
"name": "ref",
164164
"type": "React.RefObject<HTMLButtonElement>"
165+
},
166+
{
167+
"name": "tooltipDirection",
168+
"type": "'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'",
169+
"required": false
170+
},
171+
{
172+
"name": "description",
173+
"type": "string",
174+
"required": false,
175+
"description": "If `description` is provided, we will use a Tooltip to describe the button. Then `aria-label` is used to label the button.",
176+
"defaultValue": ""
165177
}
166178
]
167179
}
168180
]
169-
}
181+
}

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

+84-3
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,30 @@ import theme from '../theme'
99
import {BaseStyles, ThemeProvider} from '..'
1010
import {act} from 'react-test-renderer'
1111
import {viewportRanges} from '../hooks/useResponsiveValue'
12+
import {FeatureFlags} from '../FeatureFlags'
1213

1314
const segmentData = [
14-
{label: 'Preview', id: 'preview', iconLabel: 'EyeIcon', icon: () => <EyeIcon aria-label="EyeIcon" />},
15-
{label: 'Raw', id: 'raw', iconLabel: 'FileCodeIcon', icon: () => <FileCodeIcon aria-label="FileCodeIcon" />},
16-
{label: 'Blame', id: 'blame', iconLabel: 'PeopleIcon', icon: () => <PeopleIcon aria-label="PeopleIcon" />},
15+
{
16+
label: 'Preview',
17+
description: 'This preview does blah.',
18+
id: 'preview',
19+
iconLabel: 'EyeIcon',
20+
icon: () => <EyeIcon aria-label="EyeIcon" />,
21+
},
22+
{
23+
label: 'Raw',
24+
description: 'This shows the raw content.',
25+
id: 'raw',
26+
iconLabel: 'FileCodeIcon',
27+
icon: () => <FileCodeIcon aria-label="FileCodeIcon" />,
28+
},
29+
{
30+
label: 'Blame',
31+
description: 'This shows the blame.',
32+
id: 'blame',
33+
iconLabel: 'PeopleIcon',
34+
icon: () => <PeopleIcon aria-label="PeopleIcon" />,
35+
},
1736
]
1837

1938
let matchMedia: MatchMediaMock
@@ -164,6 +183,68 @@ describe('SegmentedControl', () => {
164183
}
165184
})
166185

186+
it('renders icon button with tooltip as label when feature flag is enabled', () => {
187+
const {getByRole, getByText} = render(
188+
<FeatureFlags
189+
flags={{
190+
primer_react_segmented_control_tooltip: true,
191+
}}
192+
>
193+
<SegmentedControl aria-label="File view">
194+
{segmentData.map(({label, icon}) => (
195+
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} />
196+
))}
197+
</SegmentedControl>
198+
</FeatureFlags>,
199+
)
200+
201+
for (const datum of segmentData) {
202+
const labelledButton = getByRole('button', {name: datum.label})
203+
const tooltipElement = getByText(datum.label)
204+
expect(labelledButton).toHaveAttribute('aria-labelledby', tooltipElement.id)
205+
expect(labelledButton).not.toHaveAttribute('aria-label')
206+
}
207+
})
208+
209+
it('renders icon button with tooltip description when feature flag is enabled', () => {
210+
const {getByRole, getByText} = render(
211+
<FeatureFlags
212+
flags={{
213+
primer_react_segmented_control_tooltip: true,
214+
}}
215+
>
216+
<SegmentedControl aria-label="File view">
217+
{segmentData.map(({label, icon, description}) => (
218+
<SegmentedControl.IconButton icon={icon} aria-label={label} description={description} key={label} />
219+
))}
220+
</SegmentedControl>
221+
</FeatureFlags>,
222+
)
223+
224+
for (const datum of segmentData) {
225+
const labelledButton = getByRole('button', {name: datum.label})
226+
const tooltipElement = getByText(datum.description)
227+
expect(labelledButton).toHaveAttribute('aria-describedby', tooltipElement.id)
228+
expect(labelledButton).toHaveAccessibleName(datum.label)
229+
expect(labelledButton).toHaveAttribute('aria-label', datum.label)
230+
}
231+
})
232+
233+
it('renders icon button with aria-label and no tooltip', () => {
234+
const {getByRole} = render(
235+
<SegmentedControl aria-label="File view">
236+
{segmentData.map(({label, icon}) => (
237+
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} />
238+
))}
239+
</SegmentedControl>,
240+
)
241+
242+
for (const datum of segmentData) {
243+
const labelledButton = getByRole('button', {name: datum.label})
244+
expect(labelledButton).toHaveAttribute('aria-label', datum.label)
245+
}
246+
})
247+
167248
it('calls onChange with index of clicked segment button', async () => {
168249
const user = userEvent.setup()
169250
const handleChange = jest.fn()

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

+59-27
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import {defaultSxProp} from '../utils/defaultSxProp'
1414
import {isElement} from 'react-is'
1515
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'
1616
import {useFeatureFlag} from '../FeatureFlags'
17-
17+
import type {TooltipDirection} from '../TooltipV2'
1818
import classes from './SegmentedControl.module.css'
1919
import {clsx} from 'clsx'
2020
import {toggleStyledComponent} from '../internal/utils/toggleStyledComponent'
21+
import {Tooltip} from '../TooltipV2'
2122

2223
export type SegmentedControlIconButtonProps = {
2324
'aria-label': string
@@ -27,6 +28,10 @@ export type SegmentedControlIconButtonProps = {
2728
selected?: boolean
2829
/** Whether the segment is selected. This is used for uncontrolled SegmentedControls to pick one SegmentedControlButton that is selected on the initial render. */
2930
defaultSelected?: boolean
31+
/** Supplementary description that renders inside tooltip in place of the label.*/
32+
description?: string
33+
/** The direction for the tooltip.*/
34+
tooltipDirection?: TooltipDirection
3035
} & SxProp &
3136
ButtonHTMLAttributes<HTMLButtonElement | HTMLLIElement>
3237

@@ -39,18 +44,14 @@ const SegmentedControlIconButtonStyled = toggleStyledComponent(
3944
`,
4045
)
4146

42-
// TODO: update this component to be accessible when we update the Tooltip component
43-
// - we wouldn't render tooltip content inside a pseudoelement
44-
// - users can pass custom tooltip text in addition to `ariaLabel`
45-
//
46-
// See Slack thread: https://github.slack.com/archives/C02NUUQ9C30/p1656444474509599
47-
//
4847
export const SegmentedControlIconButton: React.FC<React.PropsWithChildren<SegmentedControlIconButtonProps>> = ({
4948
'aria-label': ariaLabel,
5049
icon: Icon,
5150
selected,
5251
sx: sxProp = defaultSxProp,
5352
className,
53+
description,
54+
tooltipDirection,
5455
...rest
5556
}) => {
5657
const enabled = useFeatureFlag(SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG)
@@ -64,27 +65,58 @@ export const SegmentedControlIconButton: React.FC<React.PropsWithChildren<Segmen
6465
sxProp as SxProp,
6566
)
6667

67-
return (
68-
<Box
69-
as="li"
70-
sx={mergedSx}
71-
className={clsx(enabled && classes.Item, className)}
72-
data-selected={selected || undefined}
73-
>
74-
{/* TODO: Once the tooltip remediations are resolved (especially https://github.com/github/primer/issues/1909) - bring it back */}
75-
<SegmentedControlIconButtonStyled
76-
aria-label={ariaLabel}
77-
aria-current={selected}
78-
sx={enabled ? undefined : getSegmentedControlButtonStyles({selected})}
79-
className={clsx(enabled && classes.Button, enabled && classes.IconButton)}
80-
{...rest}
68+
const tooltipFlagEnabled = useFeatureFlag('primer_react_segmented_control_tooltip')
69+
if (tooltipFlagEnabled) {
70+
return (
71+
<Box
72+
as="li"
73+
sx={mergedSx}
74+
className={clsx(enabled && classes.Item, className)}
75+
data-selected={selected || undefined}
76+
>
77+
<Tooltip
78+
type={description ? undefined : 'label'}
79+
text={description ? description : ariaLabel}
80+
direction={tooltipDirection}
81+
>
82+
<SegmentedControlIconButtonStyled
83+
aria-current={selected}
84+
// 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.
85+
aria-label={description ? ariaLabel : undefined}
86+
sx={enabled ? undefined : getSegmentedControlButtonStyles({selected})}
87+
className={clsx(enabled && classes.Button, enabled && classes.IconButton)}
88+
{...rest}
89+
>
90+
<span className={clsx(enabled ? classes.Content : 'segmentedControl-content')}>
91+
{isElement(Icon) ? Icon : <Icon />}
92+
</span>
93+
</SegmentedControlIconButtonStyled>
94+
</Tooltip>
95+
</Box>
96+
)
97+
} else {
98+
// This can be removed when primer_react_segmented_control_tooltip feature flag is GA-ed.
99+
return (
100+
<Box
101+
as="li"
102+
sx={mergedSx}
103+
className={clsx(enabled && classes.Item, className)}
104+
data-selected={selected || undefined}
81105
>
82-
<span className={clsx(enabled ? classes.Content : 'segmentedControl-content')}>
83-
{isElement(Icon) ? Icon : <Icon />}
84-
</span>
85-
</SegmentedControlIconButtonStyled>
86-
</Box>
87-
)
106+
<SegmentedControlIconButtonStyled
107+
aria-label={ariaLabel}
108+
aria-current={selected}
109+
sx={enabled ? undefined : getSegmentedControlButtonStyles({selected})}
110+
className={clsx(enabled && classes.Button, enabled && classes.IconButton)}
111+
{...rest}
112+
>
113+
<span className={clsx(enabled ? classes.Content : 'segmentedControl-content')}>
114+
{isElement(Icon) ? Icon : <Icon />}
115+
</span>
116+
</SegmentedControlIconButtonStyled>
117+
</Box>
118+
)
119+
}
88120
}
89121

90122
export default SegmentedControlIconButton

0 commit comments

Comments
 (0)
Please sign in to comment.