From 515a0879f20ef42f15116424aa03f7c893c4a6aa Mon Sep 17 00:00:00 2001 From: Mike Perrotti Date: Wed, 1 Jun 2022 18:01:11 -0400 Subject: [PATCH 01/18] implements basic SegmentedControl functionality --- docs/content/SegmentedControl.mdx | 11 +- package-lock.json | 4 +- src/SegmentedControl/SegmentedControl.tsx | 80 +++++++++++ .../SegmentedControlButton.tsx | 33 +++++ .../SegmentedControlIconButton.tsx | 36 +++++ .../getSegmentedControlStyles.ts | 83 +++++++++++ src/SegmentedControl/index.ts | 1 + src/__tests__/SegmentedControl.test.tsx | 129 ++++++++++++++++++ .../SegmentedControl.test.tsx.snap | 21 +++ .../SegmentedControl/examples.stories.tsx | 83 +++++++++++ .../SegmentedControl/fixtures.stories.tsx | 50 +++++++ 11 files changed, 526 insertions(+), 5 deletions(-) create mode 100644 src/SegmentedControl/SegmentedControl.tsx create mode 100644 src/SegmentedControl/SegmentedControlButton.tsx create mode 100644 src/SegmentedControl/SegmentedControlIconButton.tsx create mode 100644 src/SegmentedControl/getSegmentedControlStyles.ts create mode 100644 src/SegmentedControl/index.ts create mode 100644 src/__tests__/SegmentedControl.test.tsx create mode 100644 src/__tests__/__snapshots__/SegmentedControl.test.tsx.snap create mode 100644 src/stories/SegmentedControl/examples.stories.tsx create mode 100644 src/stories/SegmentedControl/fixtures.stories.tsx 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/package-lock.json b/package-lock.json index a747516ceb2..a20a6aab774 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@primer/react", - "version": "35.2.1", + "version": "35.2.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@primer/react", - "version": "35.2.1", + "version": "35.2.2", "license": "MIT", "dependencies": { "@primer/behaviors": "1.1.1", diff --git a/src/SegmentedControl/SegmentedControl.tsx b/src/SegmentedControl/SegmentedControl.tsx new file mode 100644 index 00000000000..3c1518078ab --- /dev/null +++ b/src/SegmentedControl/SegmentedControl.tsx @@ -0,0 +1,80 @@ +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 +const SegmentedControl: 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 + }) + } + })} + + ) +} + +export default Object.assign(SegmentedControl, { + Button, + IconButton: SegmentedControlIconButton +}) diff --git a/src/SegmentedControl/SegmentedControlButton.tsx b/src/SegmentedControl/SegmentedControlButton.tsx new file mode 100644 index 00000000000..5f99b3d3fec --- /dev/null +++ b/src/SegmentedControl/SegmentedControlButton.tsx @@ -0,0 +1,33 @@ +import React, {HTMLAttributes} from 'react' +import {IconProps} from '@primer/octicons-react' +import {Box} from '..' +import {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 + +// TODO: Try and get this to work without `fowardRef` +// Without it, the whole ` is marked as a very cryptic type error +const SegmentedControlButton = React.forwardRef( + ({children, leadingIcon: LeadingIcon, selected, sx: sxProp = {}, ...rest}, forwardedRef) => { + const sx = 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..dd6ab535dad --- /dev/null +++ b/src/SegmentedControl/SegmentedControlIconButton.tsx @@ -0,0 +1,36 @@ +import React, {HTMLAttributes} from 'react' +import {IconProps} from '@primer/octicons-react' +import {Box} from '..' +import {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 + +// TODO: Try and get this to work without `fowardRef` +// Without it, the whole ` is marked as a very cryptic type error +// +// TODO: get tooltips working: +// - by default, the tooltip shows the `ariaLabel` content +// - allow users to pass custom tooltip text +export const SegmentedControlIconButton = React.forwardRef( + ({icon: Icon, selected, sx: sxProp = {}, ...rest}, forwardedRef) => { + const sx = merge(getSegmentedControlButtonStyles({selected}), sxProp as SxProp) + + return ( + + + + + + ) + } +) + +export default SegmentedControlIconButton diff --git a/src/SegmentedControl/getSegmentedControlStyles.ts b/src/SegmentedControl/getSegmentedControlStyles.ts new file mode 100644 index 00000000000..a1ce1646a05 --- /dev/null +++ b/src/SegmentedControl/getSegmentedControlStyles.ts @@ -0,0 +1,83 @@ +import {SegmentedControlButtonProps} from './SegmentedControlButton' + +const getSegmentedControlButtonStyles = (props?: SegmentedControlButtonProps) => ({ + '--segmented-control-button-inner-padding': '12px', // TODO: use primitive `primer.control.medium.paddingInline.normal` when it is available + backgroundColor: 'transparent', + borderColor: 'transparent', + borderRadius: 2, + borderWidth: 0, + color: 'currentColor', + cursor: 'pointer', + flexGrow: 1, + fontWeight: props?.selected ? 'bold' : 'normal', + marginTop: '-1px', + marginBottom: '-1px', + padding: props?.selected ? 0 : 'calc(var(--segmented-control-button-inner-padding) / 2)', + position: 'relative', + + '.segmentedControl-content': { + alignItems: 'center', + backgroundColor: props?.selected ? 'btn.bg' : 'transparent', + borderColor: props?.selected ? '#8c959f' : 'transparent', // TODO: use a functional primitive for the selected border color when it is available + borderStyle: 'solid', + borderWidth: 1, + borderRadius: 2, + display: 'flex', + height: '100%', + justifyContent: 'center', + paddingLeft: props?.selected + ? 'var(--segmented-control-button-inner-padding)' + : 'calc(var(--segmented-control-button-inner-padding) / 2)', + paddingRight: props?.selected + ? 'var(--segmented-control-button-inner-padding)' + : 'calc(var(--segmented-control-button-inner-padding) / 2)' + }, + + svg: { + fill: 'fg.muted' + }, + + ':hover .segmentedControl-content': { + backgroundColor: props?.selected ? undefined : 'rgba(0,0,0,0.08)' + }, + + ':active .segmentedControl-content': { + backgroundColor: props?.selected ? undefined : 'rgba(0,0,0,0.12)' + }, + + ':first-child': { + marginLeft: '-1px' + }, + + ':last-child': { + marginRight: '-1px' + }, + + ':not(:last-child)': { + marginRight: '1px', + ':after': { + backgroundColor: 'var(--separator-color)', + content: '""', + position: 'absolute', + right: '-2px', + top: 2, + bottom: 2, + width: '1px' + } + }, + + '.segmentedControl-text': { + ':after': { + content: `"${props?.children}"`, + display: 'block', + fontWeight: 'bold', + height: 0, + overflow: 'hidden', + pointerEvents: 'none', + userSelect: 'none', + visibility: 'hidden' + } + } +}) + +export default getSegmentedControlButtonStyles diff --git a/src/SegmentedControl/index.ts b/src/SegmentedControl/index.ts new file mode 100644 index 00000000000..6fee36daf72 --- /dev/null +++ b/src/SegmentedControl/index.ts @@ -0,0 +1 @@ +export {default} from './SegmentedControl' diff --git a/src/__tests__/SegmentedControl.test.tsx b/src/__tests__/SegmentedControl.test.tsx new file mode 100644 index 00000000000..5dd4ef29b24 --- /dev/null +++ b/src/__tests__/SegmentedControl.test.tsx @@ -0,0 +1,129 @@ +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 '../SegmentedControl' // 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}) + + checkExports('SegmentedControl', { + default: 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-pressed')).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-pressed')).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('SegmentedControl/examples') +checkStoriesForAxeViolations('SegmentedControl/fixtures') diff --git a/src/__tests__/__snapshots__/SegmentedControl.test.tsx.snap b/src/__tests__/__snapshots__/SegmentedControl.test.tsx.snap new file mode 100644 index 00000000000..a8d5bf6f8f5 --- /dev/null +++ b/src/__tests__/__snapshots__/SegmentedControl.test.tsx.snap @@ -0,0 +1,21 @@ +// 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; +} + +
+`; diff --git a/src/stories/SegmentedControl/examples.stories.tsx b/src/stories/SegmentedControl/examples.stories.tsx new file mode 100644 index 00000000000..2e34ec32cfb --- /dev/null +++ b/src/stories/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 '../../SegmentedControl' +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/stories/SegmentedControl/fixtures.stories.tsx b/src/stories/SegmentedControl/fixtures.stories.tsx new file mode 100644 index 00000000000..7a29e62c1c9 --- /dev/null +++ b/src/stories/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 '../../SegmentedControl' + +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 `