-
Notifications
You must be signed in to change notification settings - Fork 535
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Basic SegmentedControl functionality #2108
Changes from all commits
515a087
e0605c5
d2c6d5a
5f8bd3c
52eee4e
d10dd54
093e6a2
2ae5107
54ca574
054c2f0
d0a00d4
4958387
1f20ce9
813e1bb
afdeba8
563547b
8dbf6aa
c952c59
b1253ec
e14b6c0
5278c3b
4ab92d0
8bb8ba3
4695c84
8f22b04
c03b4e1
7ea6424
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@primer/react': minor | ||
--- | ||
|
||
Adds a draft component to render a basic segmented control. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
import React from 'react' | ||
import '@testing-library/jest-dom/extend-expect' | ||
import {render} from '@testing-library/react' | ||
import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react' | ||
import userEvent from '@testing-library/user-event' | ||
import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing' | ||
import {SegmentedControl} from '.' // TODO: update import when we move this to the global index | ||
|
||
const segmentData = [ | ||
{label: 'Preview', iconLabel: 'EyeIcon', icon: () => <EyeIcon aria-label="EyeIcon" />}, | ||
{label: 'Raw', iconLabel: 'FileCodeIcon', icon: () => <FileCodeIcon aria-label="FileCodeIcon" />}, | ||
{label: 'Blame', iconLabel: 'PeopleIcon', icon: () => <PeopleIcon aria-label="PeopleIcon" />} | ||
] | ||
|
||
// TODO: improve test coverage | ||
describe('SegmentedControl', () => { | ||
behavesAsComponent({ | ||
Component: SegmentedControl, | ||
toRender: () => ( | ||
<SegmentedControl aria-label="File view"> | ||
<SegmentedControl.Button selected>Preview</SegmentedControl.Button> | ||
<SegmentedControl.Button>Raw</SegmentedControl.Button> | ||
<SegmentedControl.Button>Blame</SegmentedControl.Button> | ||
</SegmentedControl> | ||
) | ||
}) | ||
|
||
checkExports('SegmentedControl', { | ||
default: undefined, | ||
SegmentedControl | ||
}) | ||
|
||
it('renders with a selected segment', () => { | ||
const {getByText} = render( | ||
<SegmentedControl aria-label="File view"> | ||
{segmentData.map(({label}, index) => ( | ||
<SegmentedControl.Button selected={index === 1} key={label}> | ||
{label} | ||
</SegmentedControl.Button> | ||
))} | ||
</SegmentedControl> | ||
) | ||
|
||
const selectedButton = getByText('Raw').closest('button') | ||
|
||
expect(selectedButton?.getAttribute('aria-current')).toBe('true') | ||
}) | ||
|
||
it('renders the first segment as selected if no child has the `selected` prop passed', () => { | ||
const {getByText} = render( | ||
<SegmentedControl aria-label="File view"> | ||
{segmentData.map(({label}) => ( | ||
<SegmentedControl.Button key={label}>{label}</SegmentedControl.Button> | ||
))} | ||
</SegmentedControl> | ||
) | ||
|
||
const selectedButton = getByText('Preview').closest('button') | ||
|
||
expect(selectedButton?.getAttribute('aria-current')).toBe('true') | ||
}) | ||
|
||
it('renders segments with segment labels that have leading icons', () => { | ||
const {getByLabelText} = render( | ||
<SegmentedControl aria-label="File view"> | ||
{segmentData.map(({label, icon}, index) => ( | ||
<SegmentedControl.Button selected={index === 0} leadingIcon={icon} key={label}> | ||
{label} | ||
</SegmentedControl.Button> | ||
))} | ||
</SegmentedControl> | ||
) | ||
|
||
for (const datum of segmentData) { | ||
const iconEl = getByLabelText(datum.iconLabel) | ||
expect(iconEl).toBeDefined() | ||
} | ||
}) | ||
|
||
it('renders segments with accessible icon-only labels', () => { | ||
const {getByLabelText} = render( | ||
<SegmentedControl aria-label="File view"> | ||
{segmentData.map(({label, icon}) => ( | ||
<SegmentedControl.IconButton icon={icon} aria-label={label} key={label} /> | ||
))} | ||
</SegmentedControl> | ||
) | ||
|
||
for (const datum of segmentData) { | ||
const labelledButton = getByLabelText(datum.label) | ||
expect(labelledButton).toBeDefined() | ||
} | ||
}) | ||
|
||
it('calls onChange with index of clicked segment button', () => { | ||
const handleChange = jest.fn() | ||
const {getByText} = render( | ||
<SegmentedControl aria-label="File view" onChange={handleChange}> | ||
{segmentData.map(({label}, index) => ( | ||
<SegmentedControl.Button selected={index === 0} key={label}> | ||
{label} | ||
</SegmentedControl.Button> | ||
))} | ||
</SegmentedControl> | ||
) | ||
|
||
const buttonToClick = getByText('Raw').closest('button') | ||
|
||
expect(handleChange).not.toHaveBeenCalled() | ||
if (buttonToClick) { | ||
userEvent.click(buttonToClick) | ||
} | ||
expect(handleChange).toHaveBeenCalledWith(1) | ||
}) | ||
|
||
it('calls segment button onClick if it is passed', () => { | ||
const handleClick = jest.fn() | ||
const {getByText} = render( | ||
<SegmentedControl aria-label="File view"> | ||
{segmentData.map(({label}, index) => ( | ||
<SegmentedControl.Button selected={index === 0} onClick={index === 1 ? handleClick : undefined} key={label}> | ||
{label} | ||
</SegmentedControl.Button> | ||
))} | ||
</SegmentedControl> | ||
) | ||
|
||
const buttonToClick = getByText('Raw').closest('button') | ||
|
||
expect(handleClick).not.toHaveBeenCalled() | ||
if (buttonToClick) { | ||
userEvent.click(buttonToClick) | ||
} | ||
expect(handleClick).toHaveBeenCalled() | ||
}) | ||
}) | ||
|
||
checkStoriesForAxeViolations('examples', '../SegmentedControl/') | ||
checkStoriesForAxeViolations('fixtures', '../SegmentedControl/') |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
import React from 'react' | ||
import Button, {SegmentedControlButtonProps} from './SegmentedControlButton' | ||
import SegmentedControlIconButton, {SegmentedControlIconButtonProps} from './SegmentedControlIconButton' | ||
import {Box, useTheme} from '..' | ||
import {merge, SxProp} from '../sx' | ||
|
||
type SegmentedControlProps = { | ||
'aria-label'?: string | ||
'aria-labelledby'?: string | ||
'aria-describedby'?: string | ||
/** Whether the control fills the width of its parent */ | ||
fullWidth?: boolean | ||
/** The handler that gets called when a segment is selected */ | ||
onChange?: (selectedIndex: number) => void // TODO: consider making onChange required if we force this component to be controlled | ||
} & SxProp | ||
|
||
const getSegmentedControlStyles = (props?: SegmentedControlProps) => ({ | ||
// TODO: update color primitive name(s) to use different primitives: | ||
// - try to use general 'control' primitives (e.g.: https://primer.style/primitives/spacing#ui-control) | ||
// - when that's not possible, use specific to segmented controls | ||
backgroundColor: 'switchTrack.bg', // TODO: update primitive when it is available | ||
borderColor: 'border.default', | ||
borderRadius: 2, | ||
borderStyle: 'solid', | ||
borderWidth: 1, | ||
display: props?.fullWidth ? 'flex' : 'inline-flex', | ||
height: '32px' // TODO: use primitive `primer.control.medium.size` when it is available | ||
}) | ||
|
||
// TODO: implement `variant` prop for responsive behavior | ||
// TODO: implement `loading` prop | ||
// TODO: log a warning if no `ariaLabel` or `ariaLabelledBy` prop is passed | ||
// TODO: implement keyboard behavior to move focus using the arrow keys | ||
const Root: React.FC<SegmentedControlProps> = ({children, fullWidth, onChange, sx: sxProp = {}, ...rest}) => { | ||
const {theme} = useTheme() | ||
const selectedChildren = React.Children.toArray(children).map( | ||
child => | ||
React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child) && child.props.selected | ||
) | ||
const hasSelectedButton = selectedChildren.some(isSelected => isSelected) | ||
const selectedIndex = hasSelectedButton ? selectedChildren.indexOf(true) : 0 | ||
const sx = merge( | ||
getSegmentedControlStyles({ | ||
fullWidth | ||
}), | ||
sxProp as SxProp | ||
) | ||
|
||
return ( | ||
<Box role="toolbar" sx={sx} {...rest}> | ||
{React.Children.map(children, (child, i) => { | ||
if (React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child)) { | ||
return React.cloneElement(child, { | ||
onClick: onChange | ||
? (e: React.MouseEvent<HTMLButtonElement>) => { | ||
onChange(i) | ||
child.props.onClick && child.props.onClick(e) | ||
} | ||
: child.props.onClick, | ||
selected: i === selectedIndex, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this necessary? Isn't React.cloneElement(child, { ...child.props, onClick: ..., sx: ...,}) |
||
sx: { | ||
'--separator-color': | ||
i === selectedIndex || i === selectedIndex - 1 ? 'transparent' : theme?.colors.border.default | ||
} as React.CSSProperties | ||
Comment on lines
+61
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we merge this with the
|
||
}) | ||
} | ||
})} | ||
</Box> | ||
) | ||
} | ||
|
||
Root.displayName = 'SegmentedControl' | ||
|
||
export const SegmentedControl = Object.assign(Root, { | ||
Button, | ||
IconButton: SegmentedControlIconButton | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import React, {HTMLAttributes} from 'react' | ||
import {IconProps} from '@primer/octicons-react' | ||
import styled from 'styled-components' | ||
import {Box} from '..' | ||
import sx, {merge, SxProp} from '../sx' | ||
import getSegmentedControlButtonStyles from './getSegmentedControlStyles' | ||
|
||
export type SegmentedControlButtonProps = { | ||
children?: string | ||
/** Whether the segment is selected */ | ||
selected?: boolean | ||
/** The leading icon comes before item label */ | ||
leadingIcon?: React.FunctionComponent<IconProps> | ||
} & SxProp & | ||
HTMLAttributes<HTMLButtonElement> | ||
|
||
const SegmentedControlButtonStyled = styled.button` | ||
${sx}; | ||
` | ||
|
||
const SegmentedControlButton: React.FC<SegmentedControlButtonProps> = ({ | ||
children, | ||
leadingIcon: LeadingIcon, | ||
selected, | ||
sx: sxProp = {}, | ||
...rest | ||
}) => { | ||
const mergedSx = merge(getSegmentedControlButtonStyles({selected, children}), sxProp as SxProp) | ||
|
||
return ( | ||
<SegmentedControlButtonStyled aria-current={selected} sx={mergedSx} {...rest}> | ||
<span className="segmentedControl-content"> | ||
{LeadingIcon && ( | ||
<Box mr={1}> | ||
<LeadingIcon /> | ||
</Box> | ||
)} | ||
<Box className="segmentedControl-text">{children}</Box> | ||
</span> | ||
</SegmentedControlButtonStyled> | ||
) | ||
} | ||
|
||
export default SegmentedControlButton | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking nit: In general, we've been moving towards using named exports for everything: - export default SegmentedControl
+ export const SegmentalControlButton |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import React, {HTMLAttributes} from 'react' | ||
import {IconProps} from '@primer/octicons-react' | ||
import styled from 'styled-components' | ||
import sx, {merge, SxProp} from '../sx' | ||
import getSegmentedControlButtonStyles from './getSegmentedControlStyles' | ||
|
||
export type SegmentedControlIconButtonProps = { | ||
'aria-label': string | ||
/** The icon that represents the segmented control item */ | ||
icon: React.FunctionComponent<IconProps> | ||
/** Whether the segment is selected */ | ||
selected?: boolean | ||
} & SxProp & | ||
HTMLAttributes<HTMLButtonElement> | ||
|
||
const SegmentedControlIconButtonStyled = styled.button` | ||
${sx}; | ||
` | ||
|
||
// TODO: get tooltips working: | ||
// - by default, the tooltip shows the `ariaLabel` content | ||
// - allow users to pass custom tooltip text | ||
export const SegmentedControlIconButton: React.FC<SegmentedControlIconButtonProps> = ({ | ||
icon: Icon, | ||
selected, | ||
sx: sxProp = {}, | ||
...rest | ||
}) => { | ||
const mergedSx = merge(getSegmentedControlButtonStyles({selected, isIconOnly: true}), sxProp as SxProp) | ||
|
||
return ( | ||
<SegmentedControlIconButtonStyled aria-pressed={selected} sx={mergedSx} {...rest}> | ||
<span className="segmentedControl-content"> | ||
<Icon /> | ||
</span> | ||
</SegmentedControlIconButtonStyled> | ||
) | ||
} | ||
|
||
export default SegmentedControlIconButton |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just curious, is there a reason you're setting
aria-label
on the SVG and the button? I think by default the SVGs rendered by octicons are aria-hiddenThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just used it so I could select it with React test utils.
I wouldn't do this in a real UI.