diff --git a/.changeset/modern-fireants-destroy.md b/.changeset/modern-fireants-destroy.md new file mode 100644 index 00000000000..2db0246c301 --- /dev/null +++ b/.changeset/modern-fireants-destroy.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Adds a draft component to render a basic segmented control. diff --git a/docs/content/SegmentedControl.mdx b/docs/content/SegmentedControl.mdx index 4da191647e2..65b2b220701 100644 --- a/docs/content/SegmentedControl.mdx +++ b/docs/content/SegmentedControl.mdx @@ -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 /> - @@ -184,8 +184,13 @@ description: Use a segmented control to let users select an option from a short ### SegmentedControl.IconButton - - + + diff --git a/src/SegmentedControl/SegmentedControl.test.tsx b/src/SegmentedControl/SegmentedControl.test.tsx new file mode 100644 index 00000000000..30f574e984f --- /dev/null +++ b/src/SegmentedControl/SegmentedControl.test.tsx @@ -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: () => }, + {label: 'Raw', iconLabel: 'FileCodeIcon', icon: () => }, + {label: 'Blame', iconLabel: 'PeopleIcon', icon: () => } +] + +// TODO: improve test coverage +describe('SegmentedControl', () => { + behavesAsComponent({ + Component: SegmentedControl, + toRender: () => ( + + Preview + Raw + Blame + + ) + }) + + checkExports('SegmentedControl', { + default: undefined, + SegmentedControl + }) + + it('renders with a selected segment', () => { + const {getByText} = render( + + {segmentData.map(({label}, index) => ( + + {label} + + ))} + + ) + + 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( + + {segmentData.map(({label}) => ( + {label} + ))} + + ) + + 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( + + {segmentData.map(({label, icon}, index) => ( + + {label} + + ))} + + ) + + for (const datum of segmentData) { + const iconEl = getByLabelText(datum.iconLabel) + expect(iconEl).toBeDefined() + } + }) + + it('renders segments with accessible icon-only labels', () => { + const {getByLabelText} = render( + + {segmentData.map(({label, icon}) => ( + + ))} + + ) + + 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( + + {segmentData.map(({label}, index) => ( + + {label} + + ))} + + ) + + 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( + + {segmentData.map(({label}, index) => ( + + {label} + + ))} + + ) + + const buttonToClick = getByText('Raw').closest('button') + + expect(handleClick).not.toHaveBeenCalled() + if (buttonToClick) { + userEvent.click(buttonToClick) + } + expect(handleClick).toHaveBeenCalled() + }) +}) + +checkStoriesForAxeViolations('examples', '../SegmentedControl/') +checkStoriesForAxeViolations('fixtures', '../SegmentedControl/') diff --git a/src/SegmentedControl/SegmentedControl.tsx b/src/SegmentedControl/SegmentedControl.tsx new file mode 100644 index 00000000000..efb9767e0a2 --- /dev/null +++ b/src/SegmentedControl/SegmentedControl.tsx @@ -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 = ({children, fullWidth, onChange, sx: sxProp = {}, ...rest}) => { + const {theme} = useTheme() + const selectedChildren = React.Children.toArray(children).map( + child => + React.isValidElement(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 ( + + {React.Children.map(children, (child, i) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + onClick: onChange + ? (e: React.MouseEvent) => { + 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 + }) + } + })} + + ) +} + +Root.displayName = 'SegmentedControl' + +export const SegmentedControl = Object.assign(Root, { + Button, + IconButton: SegmentedControlIconButton +}) diff --git a/src/SegmentedControl/SegmentedControlButton.tsx b/src/SegmentedControl/SegmentedControlButton.tsx new file mode 100644 index 00000000000..7aacd5cb4d4 --- /dev/null +++ b/src/SegmentedControl/SegmentedControlButton.tsx @@ -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 +} & SxProp & + HTMLAttributes + +const SegmentedControlButtonStyled = styled.button` + ${sx}; +` + +const SegmentedControlButton: React.FC = ({ + children, + leadingIcon: LeadingIcon, + selected, + sx: sxProp = {}, + ...rest +}) => { + const mergedSx = merge(getSegmentedControlButtonStyles({selected, children}), sxProp as SxProp) + + return ( + + + {LeadingIcon && ( + + + + )} + {children} + + + ) +} + +export default SegmentedControlButton diff --git a/src/SegmentedControl/SegmentedControlIconButton.tsx b/src/SegmentedControl/SegmentedControlIconButton.tsx new file mode 100644 index 00000000000..3f6f9f0f075 --- /dev/null +++ b/src/SegmentedControl/SegmentedControlIconButton.tsx @@ -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 + /** Whether the segment is selected */ + selected?: boolean +} & SxProp & + HTMLAttributes + +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 = ({ + icon: Icon, + selected, + sx: sxProp = {}, + ...rest +}) => { + const mergedSx = merge(getSegmentedControlButtonStyles({selected, isIconOnly: true}), sxProp as SxProp) + + return ( + + + + + + ) +} + +export default SegmentedControlIconButton diff --git a/src/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap b/src/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap new file mode 100644 index 00000000000..997d12c64d9 --- /dev/null +++ b/src/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap @@ -0,0 +1,381 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SegmentedControl renders consistently 1`] = ` +.c0 { + background-color: #eaeef2; + border-color: #d0d7de; + border-radius: 6px; + border-style: solid; + border-width: 1px; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + height: 32px; +} + +.c1 { + --segmented-control-button-inner-padding: 12px; + --segmented-control-button-bg-inset: 4px; + --segmented-control-outer-radius: 6px; + background-color: transparent; + border-color: transparent; + border-radius: var(--segmented-control-outer-radius); + border-width: 0; + color: currentColor; + cursor: pointer; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + font-family: inherit; + font-weight: 600; + margin-top: -1px; + margin-bottom: -1px; + padding: 0; + position: relative; + --separator-color: transparent; +} + +.c1 .segmentedControl-content { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + background-color: #f6f8fa; + border-color: #8c959f; + border-style: solid; + border-width: 1px; + border-radius: var(--segmented-control-outer-radius); + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + height: 100%; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + padding-left: var(--segmented-control-button-inner-padding); + padding-right: var(--segmented-control-button-inner-padding); +} + +.c1 svg { + fill: #57606a; +} + +.c1:first-child { + margin-left: -1px; +} + +.c1:last-child { + margin-right: -1px; +} + +.c1:not(:last-child) { + margin-right: 1px; +} + +.c1:not(:last-child):after { + background-color: var(--separator-color); + content: ""; + position: absolute; + right: -2px; + top: 8px; + bottom: 8px; + width: 1px; +} + +.c1 .segmentedControl-text:after { + content: "Preview"; + display: block; + font-weight: 600; + height: 0; + overflow: hidden; + pointer-events: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + visibility: hidden; +} + +.c2 { + --segmented-control-button-inner-padding: 12px; + --segmented-control-button-bg-inset: 4px; + --segmented-control-outer-radius: 6px; + background-color: transparent; + border-color: transparent; + border-radius: var(--segmented-control-outer-radius); + border-width: 0; + color: currentColor; + cursor: pointer; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + font-family: inherit; + font-weight: 400; + margin-top: -1px; + margin-bottom: -1px; + padding: var(--segmented-control-button-bg-inset); + position: relative; + --separator-color: #d0d7de; +} + +.c2 .segmentedControl-content { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + background-color: transparent; + border-color: transparent; + border-style: solid; + border-width: 1px; + border-radius: calc(var(--segmented-control-outer-radius) - var(--segmented-control-button-bg-inset) / 2); + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + height: 100%; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + padding-left: calc(var(--segmented-control-button-inner-padding) - var(--segmented-control-button-bg-inset)); + padding-right: calc(var(--segmented-control-button-inner-padding) - var(--segmented-control-button-bg-inset)); +} + +.c2 svg { + fill: #57606a; +} + +.c2:hover .segmentedControl-content { + background-color: rgba(208,215,222,0.32); +} + +.c2:active .segmentedControl-content { + background-color: rgba(208,215,222,0.48); +} + +.c2:first-child { + margin-left: -1px; +} + +.c2:last-child { + margin-right: -1px; +} + +.c2:not(:last-child) { + margin-right: 1px; +} + +.c2:not(:last-child):after { + background-color: var(--separator-color); + content: ""; + position: absolute; + right: -2px; + top: 8px; + bottom: 8px; + width: 1px; +} + +.c2 .segmentedControl-text:after { + content: "Raw"; + display: block; + font-weight: 600; + height: 0; + overflow: hidden; + pointer-events: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + visibility: hidden; +} + +.c3 { + --segmented-control-button-inner-padding: 12px; + --segmented-control-button-bg-inset: 4px; + --segmented-control-outer-radius: 6px; + background-color: transparent; + border-color: transparent; + border-radius: var(--segmented-control-outer-radius); + border-width: 0; + color: currentColor; + cursor: pointer; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + font-family: inherit; + font-weight: 400; + margin-top: -1px; + margin-bottom: -1px; + padding: var(--segmented-control-button-bg-inset); + position: relative; + --separator-color: #d0d7de; +} + +.c3 .segmentedControl-content { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + background-color: transparent; + border-color: transparent; + border-style: solid; + border-width: 1px; + border-radius: calc(var(--segmented-control-outer-radius) - var(--segmented-control-button-bg-inset) / 2); + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + height: 100%; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + padding-left: calc(var(--segmented-control-button-inner-padding) - var(--segmented-control-button-bg-inset)); + padding-right: calc(var(--segmented-control-button-inner-padding) - var(--segmented-control-button-bg-inset)); +} + +.c3 svg { + fill: #57606a; +} + +.c3:hover .segmentedControl-content { + background-color: rgba(208,215,222,0.32); +} + +.c3:active .segmentedControl-content { + background-color: rgba(208,215,222,0.48); +} + +.c3:first-child { + margin-left: -1px; +} + +.c3:last-child { + margin-right: -1px; +} + +.c3:not(:last-child) { + margin-right: 1px; +} + +.c3:not(:last-child):after { + background-color: var(--separator-color); + content: ""; + position: absolute; + right: -2px; + top: 8px; + bottom: 8px; + width: 1px; +} + +.c3 .segmentedControl-text:after { + content: "Blame"; + display: block; + font-weight: 600; + height: 0; + overflow: hidden; + pointer-events: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + visibility: hidden; +} + +@media (pointer:coarse) { + .c1:before { + content: ""; + position: absolute; + left: 0; + right: 0; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + top: 50%; + min-height: 44px; + } +} + +@media (pointer:coarse) { + .c2:before { + content: ""; + position: absolute; + left: 0; + right: 0; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + top: 50%; + min-height: 44px; + } +} + +@media (pointer:coarse) { + .c3:before { + content: ""; + position: absolute; + left: 0; + right: 0; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + top: 50%; + min-height: 44px; + } +} + +
+ + + +
+`; diff --git a/src/SegmentedControl/examples.stories.tsx b/src/SegmentedControl/examples.stories.tsx new file mode 100644 index 00000000000..cef34a873c3 --- /dev/null +++ b/src/SegmentedControl/examples.stories.tsx @@ -0,0 +1,83 @@ +import React, {useState} from 'react' +import {Meta} from '@storybook/react' + +import {BaseStyles, ThemeProvider} from '..' +import {ComponentProps} from '../utils/types' +import {SegmentedControl} from '.' +import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react' + +type Args = ComponentProps + +const excludedControlKeys = ['aria-label', 'onChange', 'sx'] + +export default { + title: 'SegmentedControl/examples', + component: SegmentedControl, + argTypes: { + fullWidth: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + loading: { + defaultValue: false, + control: { + type: 'boolean' + } + } + }, + parameters: {controls: {exclude: excludedControlKeys}}, + decorators: [ + Story => { + return ( + + + + + + ) + } + ] +} as Meta + +export const Default = (args: Args) => ( + + Preview + Raw + Blame + +) + +export const Controlled = (args: Args) => { + const [selectedIndex, setSelectedIndex] = useState(1) + const handleChange = (i: number) => { + setSelectedIndex(i) + } + + return ( + + Preview + Raw + Blame + + ) +} + +export const WithIconsAndLabels = (args: Args) => ( + + + Preview + + Raw + Blame + +) + +export const IconsOnly = (args: Args) => ( + + + + + +) diff --git a/src/SegmentedControl/fixtures.stories.tsx b/src/SegmentedControl/fixtures.stories.tsx new file mode 100644 index 00000000000..888a8d36b20 --- /dev/null +++ b/src/SegmentedControl/fixtures.stories.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import {Meta} from '@storybook/react' + +import {BaseStyles, Box, Text, ThemeProvider} from '../' +import {SegmentedControl} from '.' + +export default { + title: 'SegmentedControl/fixtures', + component: SegmentedControl, + decorators: [ + Story => { + return ( + + + + + + ) + } + ] +} as Meta + +// TODO: make it possible to use FormControl +// - FormControl.Label needs to accept `id` prop +// - FormControl.Label needs to accept a prop that lets it render an element that isn't a `