-
Notifications
You must be signed in to change notification settings - Fork 535
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Basic SegmentedControl functionality (#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 <siddharthkp@github.com> * 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 <siddharthkp@github.com> Co-authored-by: Cole Bemis <colebemis@github.com>
- Loading branch information
1 parent
df26f3c
commit e5be3db
Showing
14 changed files
with
943 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
sx: { | ||
'--separator-color': | ||
i === selectedIndex || i === selectedIndex - 1 ? 'transparent' : theme?.colors.border.default | ||
} as React.CSSProperties | ||
}) | ||
} | ||
})} | ||
</Box> | ||
) | ||
} | ||
|
||
Root.displayName = 'SegmentedControl' | ||
|
||
export const SegmentedControl = Object.assign(Root, { | ||
Button, | ||
IconButton: SegmentedControlIconButton | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.