Skip to content

Commit

Permalink
Basic SegmentedControl functionality (#2108)
Browse files Browse the repository at this point in the history
* 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
3 people committed Jun 23, 2022
1 parent df26f3c commit e5be3db
Show file tree
Hide file tree
Showing 14 changed files with 943 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/modern-fireants-destroy.md
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.
11 changes: 8 additions & 3 deletions docs/content/SegmentedControl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ description: Use a segmented control to let users select an option from a short
name="onChange"
type="(selectedIndex?: number) => void"
description="The handler that gets called when a segment is selected"
required
/>
<PropsTableRow
name="variant"
Expand All @@ -174,7 +175,6 @@ description: Use a segmented control to let users select an option from a short
### SegmentedControl.Button

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

<PropsTable>
<PropsTableRow name="aria-label" type="string" />
<PropsTableRow name="icon" type="Component" description="The icon that represents the segmented control item" />
<PropsTableRow name="aria-label" type="string" required />
<PropsTableRow
name="icon"
type="Component"
description="The icon that represents the segmented control item"
required
/>
<PropsTableRow name="selected" type="boolean" description="Whether the segment is selected" />
<PropsTableSxRow />
<PropsTableRefRow refType="HTMLButtonElement" />
Expand Down
139 changes: 139 additions & 0 deletions src/SegmentedControl/SegmentedControl.test.tsx
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/')
77 changes: 77 additions & 0 deletions src/SegmentedControl/SegmentedControl.tsx
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
})
44 changes: 44 additions & 0 deletions src/SegmentedControl/SegmentedControlButton.tsx
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
40 changes: 40 additions & 0 deletions src/SegmentedControl/SegmentedControlIconButton.tsx
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
Loading

0 comments on commit e5be3db

Please sign in to comment.