Skip to content
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

Merged
merged 27 commits into from
Jun 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
515a087
implements basic SegmentedControl functionality
mperrotti Jun 1, 2022
e0605c5
updates file structure
mperrotti Jun 1, 2022
d2c6d5a
adds SegmentedControl to drafts
mperrotti Jun 1, 2022
5f8bd3c
adds changeset
mperrotti Jun 1, 2022
52eee4e
fixes TypeScripts issues
mperrotti Jun 2, 2022
d10dd54
revert package-lock.json changes
mperrotti Jun 2, 2022
093e6a2
fixes SegmentedControl tests and updates snapshot
mperrotti Jun 2, 2022
2ae5107
style bug fixes
mperrotti Jun 2, 2022
54ca574
Update src/SegmentedControl/fixtures.stories.tsx
mperrotti Jun 6, 2022
054c2f0
improve visual design for hover and active states
mperrotti Jun 7, 2022
d0a00d4
Merge branch 'main' of github.com:primer/react into mp/segmented-cont…
mperrotti Jun 7, 2022
4958387
ARIA updates from Chelsea's feedback
mperrotti Jun 7, 2022
1f20ce9
Merge branch 'mp/segmented-control-basic' of github.com:primer/react …
mperrotti Jun 7, 2022
813e1bb
updates tests and snapshots
mperrotti Jun 7, 2022
afdeba8
Ignore *.test.tsx files in build types
colebemis Jun 7, 2022
563547b
Use named export for SegmentedControl
colebemis Jun 7, 2022
8dbf6aa
Update package-lock.json
colebemis Jun 7, 2022
c952c59
Merge branch 'mp/segmented-control-basic' of github.com:primer/react …
mperrotti Jun 8, 2022
b1253ec
Merge branch 'main' of github.com:primer/react into mp/segmented-cont…
mperrotti Jun 8, 2022
e14b6c0
updates lock file
mperrotti Jun 8, 2022
5278c3b
fixes checkExports test for SegmentedControl
mperrotti Jun 8, 2022
4ab92d0
design tweak for icon-only segmented control button
mperrotti Jun 8, 2022
8bb8ba3
Merge branch 'main' of github.com:primer/react into mp/segmented-cont…
mperrotti Jun 8, 2022
4695c84
Merge branch 'main' into mp/segmented-control-basic
colebemis Jun 9, 2022
8f22b04
Merge branch 'main' into mp/segmented-control-basic
mperrotti Jun 20, 2022
c03b4e1
Merge branch 'main' into mp/segmented-control-basic
mperrotti Jun 21, 2022
7ea6424
Merge branch 'main' into mp/segmented-control-basic
mperrotti Jun 23, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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" />},
Copy link
Contributor

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-hidden

Copy link
Contributor Author

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.

{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,
Copy link
Contributor

@colebemis colebemis Jun 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary? Isn't selected a child prop? Could we instead pass along all the props from the child:

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we merge this with the sx prop passed to the child? Something like:

sx: merge({'--separator-color': ...}, child.props.sx)

})
}
})}
</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
Copy link
Contributor

Choose a reason for hiding this comment

The 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

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