Skip to content

Commit ecf9b28

Browse files
mperrottisiddharthkpcolebemis
authored andcommitted
Basic SegmentedControl functionality (primer#2108)
* implements basic SegmentedControl functionality * updates file structure * adds SegmentedControl to drafts * adds changeset * fixes TypeScripts issues * revert package-lock.json changes * fixes SegmentedControl tests and updates snapshot * style bug fixes * Update src/SegmentedControl/fixtures.stories.tsx Co-authored-by: Siddharth Kshetrapal <[email protected]> * improve visual design for hover and active states * ARIA updates from Chelsea's feedback * updates tests and snapshots * Ignore *.test.tsx files in build types * Use named export for SegmentedControl This fixes live code examples in the docs * Update package-lock.json * updates lock file * fixes checkExports test for SegmentedControl * design tweak for icon-only segmented control button Co-authored-by: Siddharth Kshetrapal <[email protected]> Co-authored-by: Cole Bemis <[email protected]>
1 parent 283f575 commit ecf9b28

14 files changed

+943
-6
lines changed

.changeset/modern-fireants-destroy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Adds a draft component to render a basic segmented control.

docs/content/SegmentedControl.mdx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ description: Use a segmented control to let users select an option from a short
157157
name="onChange"
158158
type="(selectedIndex?: number) => void"
159159
description="The handler that gets called when a segment is selected"
160+
required
160161
/>
161162
<PropsTableRow
162163
name="variant"
@@ -174,7 +175,6 @@ description: Use a segmented control to let users select an option from a short
174175
### SegmentedControl.Button
175176

176177
<PropsTable>
177-
<PropsTableRow name="aria-label" type="string" />
178178
<PropsTableRow name="leadingIcon" type="Component" description="The leading icon comes before item label" />
179179
<PropsTableRow name="selected" type="boolean" description="Whether the segment is selected" />
180180
<PropsTableSxRow />
@@ -184,8 +184,13 @@ description: Use a segmented control to let users select an option from a short
184184
### SegmentedControl.IconButton
185185

186186
<PropsTable>
187-
<PropsTableRow name="aria-label" type="string" />
188-
<PropsTableRow name="icon" type="Component" description="The icon that represents the segmented control item" />
187+
<PropsTableRow name="aria-label" type="string" required />
188+
<PropsTableRow
189+
name="icon"
190+
type="Component"
191+
description="The icon that represents the segmented control item"
192+
required
193+
/>
189194
<PropsTableRow name="selected" type="boolean" description="Whether the segment is selected" />
190195
<PropsTableSxRow />
191196
<PropsTableRefRow refType="HTMLButtonElement" />
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import React from 'react'
2+
import '@testing-library/jest-dom/extend-expect'
3+
import {render} from '@testing-library/react'
4+
import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
5+
import userEvent from '@testing-library/user-event'
6+
import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing'
7+
import {SegmentedControl} from '.' // TODO: update import when we move this to the global index
8+
9+
const segmentData = [
10+
{label: 'Preview', iconLabel: 'EyeIcon', icon: () => <EyeIcon aria-label="EyeIcon" />},
11+
{label: 'Raw', iconLabel: 'FileCodeIcon', icon: () => <FileCodeIcon aria-label="FileCodeIcon" />},
12+
{label: 'Blame', iconLabel: 'PeopleIcon', icon: () => <PeopleIcon aria-label="PeopleIcon" />}
13+
]
14+
15+
// TODO: improve test coverage
16+
describe('SegmentedControl', () => {
17+
behavesAsComponent({
18+
Component: SegmentedControl,
19+
toRender: () => (
20+
<SegmentedControl aria-label="File view">
21+
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
22+
<SegmentedControl.Button>Raw</SegmentedControl.Button>
23+
<SegmentedControl.Button>Blame</SegmentedControl.Button>
24+
</SegmentedControl>
25+
)
26+
})
27+
28+
checkExports('SegmentedControl', {
29+
default: undefined,
30+
SegmentedControl
31+
})
32+
33+
it('renders with a selected segment', () => {
34+
const {getByText} = render(
35+
<SegmentedControl aria-label="File view">
36+
{segmentData.map(({label}, index) => (
37+
<SegmentedControl.Button selected={index === 1} key={label}>
38+
{label}
39+
</SegmentedControl.Button>
40+
))}
41+
</SegmentedControl>
42+
)
43+
44+
const selectedButton = getByText('Raw').closest('button')
45+
46+
expect(selectedButton?.getAttribute('aria-current')).toBe('true')
47+
})
48+
49+
it('renders the first segment as selected if no child has the `selected` prop passed', () => {
50+
const {getByText} = render(
51+
<SegmentedControl aria-label="File view">
52+
{segmentData.map(({label}) => (
53+
<SegmentedControl.Button key={label}>{label}</SegmentedControl.Button>
54+
))}
55+
</SegmentedControl>
56+
)
57+
58+
const selectedButton = getByText('Preview').closest('button')
59+
60+
expect(selectedButton?.getAttribute('aria-current')).toBe('true')
61+
})
62+
63+
it('renders segments with segment labels that have leading icons', () => {
64+
const {getByLabelText} = render(
65+
<SegmentedControl aria-label="File view">
66+
{segmentData.map(({label, icon}, index) => (
67+
<SegmentedControl.Button selected={index === 0} leadingIcon={icon} key={label}>
68+
{label}
69+
</SegmentedControl.Button>
70+
))}
71+
</SegmentedControl>
72+
)
73+
74+
for (const datum of segmentData) {
75+
const iconEl = getByLabelText(datum.iconLabel)
76+
expect(iconEl).toBeDefined()
77+
}
78+
})
79+
80+
it('renders segments with accessible icon-only labels', () => {
81+
const {getByLabelText} = render(
82+
<SegmentedControl aria-label="File view">
83+
{segmentData.map(({label, icon}) => (
84+
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} />
85+
))}
86+
</SegmentedControl>
87+
)
88+
89+
for (const datum of segmentData) {
90+
const labelledButton = getByLabelText(datum.label)
91+
expect(labelledButton).toBeDefined()
92+
}
93+
})
94+
95+
it('calls onChange with index of clicked segment button', () => {
96+
const handleChange = jest.fn()
97+
const {getByText} = render(
98+
<SegmentedControl aria-label="File view" onChange={handleChange}>
99+
{segmentData.map(({label}, index) => (
100+
<SegmentedControl.Button selected={index === 0} key={label}>
101+
{label}
102+
</SegmentedControl.Button>
103+
))}
104+
</SegmentedControl>
105+
)
106+
107+
const buttonToClick = getByText('Raw').closest('button')
108+
109+
expect(handleChange).not.toHaveBeenCalled()
110+
if (buttonToClick) {
111+
userEvent.click(buttonToClick)
112+
}
113+
expect(handleChange).toHaveBeenCalledWith(1)
114+
})
115+
116+
it('calls segment button onClick if it is passed', () => {
117+
const handleClick = jest.fn()
118+
const {getByText} = render(
119+
<SegmentedControl aria-label="File view">
120+
{segmentData.map(({label}, index) => (
121+
<SegmentedControl.Button selected={index === 0} onClick={index === 1 ? handleClick : undefined} key={label}>
122+
{label}
123+
</SegmentedControl.Button>
124+
))}
125+
</SegmentedControl>
126+
)
127+
128+
const buttonToClick = getByText('Raw').closest('button')
129+
130+
expect(handleClick).not.toHaveBeenCalled()
131+
if (buttonToClick) {
132+
userEvent.click(buttonToClick)
133+
}
134+
expect(handleClick).toHaveBeenCalled()
135+
})
136+
})
137+
138+
checkStoriesForAxeViolations('examples', '../SegmentedControl/')
139+
checkStoriesForAxeViolations('fixtures', '../SegmentedControl/')
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import React from 'react'
2+
import Button, {SegmentedControlButtonProps} from './SegmentedControlButton'
3+
import SegmentedControlIconButton, {SegmentedControlIconButtonProps} from './SegmentedControlIconButton'
4+
import {Box, useTheme} from '..'
5+
import {merge, SxProp} from '../sx'
6+
7+
type SegmentedControlProps = {
8+
'aria-label'?: string
9+
'aria-labelledby'?: string
10+
'aria-describedby'?: string
11+
/** Whether the control fills the width of its parent */
12+
fullWidth?: boolean
13+
/** The handler that gets called when a segment is selected */
14+
onChange?: (selectedIndex: number) => void // TODO: consider making onChange required if we force this component to be controlled
15+
} & SxProp
16+
17+
const getSegmentedControlStyles = (props?: SegmentedControlProps) => ({
18+
// TODO: update color primitive name(s) to use different primitives:
19+
// - try to use general 'control' primitives (e.g.: https://primer.style/primitives/spacing#ui-control)
20+
// - when that's not possible, use specific to segmented controls
21+
backgroundColor: 'switchTrack.bg', // TODO: update primitive when it is available
22+
borderColor: 'border.default',
23+
borderRadius: 2,
24+
borderStyle: 'solid',
25+
borderWidth: 1,
26+
display: props?.fullWidth ? 'flex' : 'inline-flex',
27+
height: '32px' // TODO: use primitive `primer.control.medium.size` when it is available
28+
})
29+
30+
// TODO: implement `variant` prop for responsive behavior
31+
// TODO: implement `loading` prop
32+
// TODO: log a warning if no `ariaLabel` or `ariaLabelledBy` prop is passed
33+
// TODO: implement keyboard behavior to move focus using the arrow keys
34+
const Root: React.FC<SegmentedControlProps> = ({children, fullWidth, onChange, sx: sxProp = {}, ...rest}) => {
35+
const {theme} = useTheme()
36+
const selectedChildren = React.Children.toArray(children).map(
37+
child =>
38+
React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child) && child.props.selected
39+
)
40+
const hasSelectedButton = selectedChildren.some(isSelected => isSelected)
41+
const selectedIndex = hasSelectedButton ? selectedChildren.indexOf(true) : 0
42+
const sx = merge(
43+
getSegmentedControlStyles({
44+
fullWidth
45+
}),
46+
sxProp as SxProp
47+
)
48+
49+
return (
50+
<Box role="toolbar" sx={sx} {...rest}>
51+
{React.Children.map(children, (child, i) => {
52+
if (React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child)) {
53+
return React.cloneElement(child, {
54+
onClick: onChange
55+
? (e: React.MouseEvent<HTMLButtonElement>) => {
56+
onChange(i)
57+
child.props.onClick && child.props.onClick(e)
58+
}
59+
: child.props.onClick,
60+
selected: i === selectedIndex,
61+
sx: {
62+
'--separator-color':
63+
i === selectedIndex || i === selectedIndex - 1 ? 'transparent' : theme?.colors.border.default
64+
} as React.CSSProperties
65+
})
66+
}
67+
})}
68+
</Box>
69+
)
70+
}
71+
72+
Root.displayName = 'SegmentedControl'
73+
74+
export const SegmentedControl = Object.assign(Root, {
75+
Button,
76+
IconButton: SegmentedControlIconButton
77+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React, {HTMLAttributes} from 'react'
2+
import {IconProps} from '@primer/octicons-react'
3+
import styled from 'styled-components'
4+
import {Box} from '..'
5+
import sx, {merge, SxProp} from '../sx'
6+
import getSegmentedControlButtonStyles from './getSegmentedControlStyles'
7+
8+
export type SegmentedControlButtonProps = {
9+
children?: string
10+
/** Whether the segment is selected */
11+
selected?: boolean
12+
/** The leading icon comes before item label */
13+
leadingIcon?: React.FunctionComponent<IconProps>
14+
} & SxProp &
15+
HTMLAttributes<HTMLButtonElement>
16+
17+
const SegmentedControlButtonStyled = styled.button`
18+
${sx};
19+
`
20+
21+
const SegmentedControlButton: React.FC<SegmentedControlButtonProps> = ({
22+
children,
23+
leadingIcon: LeadingIcon,
24+
selected,
25+
sx: sxProp = {},
26+
...rest
27+
}) => {
28+
const mergedSx = merge(getSegmentedControlButtonStyles({selected, children}), sxProp as SxProp)
29+
30+
return (
31+
<SegmentedControlButtonStyled aria-current={selected} sx={mergedSx} {...rest}>
32+
<span className="segmentedControl-content">
33+
{LeadingIcon && (
34+
<Box mr={1}>
35+
<LeadingIcon />
36+
</Box>
37+
)}
38+
<Box className="segmentedControl-text">{children}</Box>
39+
</span>
40+
</SegmentedControlButtonStyled>
41+
)
42+
}
43+
44+
export default SegmentedControlButton
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React, {HTMLAttributes} from 'react'
2+
import {IconProps} from '@primer/octicons-react'
3+
import styled from 'styled-components'
4+
import sx, {merge, SxProp} from '../sx'
5+
import getSegmentedControlButtonStyles from './getSegmentedControlStyles'
6+
7+
export type SegmentedControlIconButtonProps = {
8+
'aria-label': string
9+
/** The icon that represents the segmented control item */
10+
icon: React.FunctionComponent<IconProps>
11+
/** Whether the segment is selected */
12+
selected?: boolean
13+
} & SxProp &
14+
HTMLAttributes<HTMLButtonElement>
15+
16+
const SegmentedControlIconButtonStyled = styled.button`
17+
${sx};
18+
`
19+
20+
// TODO: get tooltips working:
21+
// - by default, the tooltip shows the `ariaLabel` content
22+
// - allow users to pass custom tooltip text
23+
export const SegmentedControlIconButton: React.FC<SegmentedControlIconButtonProps> = ({
24+
icon: Icon,
25+
selected,
26+
sx: sxProp = {},
27+
...rest
28+
}) => {
29+
const mergedSx = merge(getSegmentedControlButtonStyles({selected, isIconOnly: true}), sxProp as SxProp)
30+
31+
return (
32+
<SegmentedControlIconButtonStyled aria-pressed={selected} sx={mergedSx} {...rest}>
33+
<span className="segmentedControl-content">
34+
<Icon />
35+
</span>
36+
</SegmentedControlIconButtonStyled>
37+
)
38+
}
39+
40+
export default SegmentedControlIconButton

0 commit comments

Comments
 (0)