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 1 commit
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
Next Next commit
implements basic SegmentedControl functionality
  • Loading branch information
mperrotti committed Jun 1, 2022
commit 515a0879f20ef42f15116424aa03f7c893c4a6aa
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 80 additions & 0 deletions src/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -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<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="group" 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>
)
}

export default Object.assign(SegmentedControl, {
Button,
IconButton: SegmentedControlIconButton
})
33 changes: 33 additions & 0 deletions src/SegmentedControl/SegmentedControlButton.tsx
Original file line number Diff line number Diff line change
@@ -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<IconProps>
} & SxProp &
HTMLAttributes<HTMLButtonElement>

// TODO: Try and get this to work without `fowardRef`
// Without it, the whole `<Box /> is marked as a very cryptic type error
const SegmentedControlButton = React.forwardRef<HTMLButtonElement, SegmentedControlButtonProps>(
({children, leadingIcon: LeadingIcon, selected, sx: sxProp = {}, ...rest}, forwardedRef) => {
const sx = merge(getSegmentedControlButtonStyles({selected, children}), sxProp as SxProp)

return (
<Box as="button" aria-pressed={selected} sx={sx} {...rest} ref={forwardedRef}>
<span className="segmentedControl-content">
<Box mr={1}>{LeadingIcon && <LeadingIcon />}</Box>
<Box className="segmentedControl-text">{children}</Box>
</span>
</Box>
)
}
)

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

36 changes: 36 additions & 0 deletions src/SegmentedControl/SegmentedControlIconButton.tsx
Original file line number Diff line number Diff line change
@@ -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<IconProps>
/** Whether the segment is selected */
selected?: boolean
} & SxProp &
HTMLAttributes<HTMLButtonElement>

// TODO: Try and get this to work without `fowardRef`
// Without it, the whole `<Box /> 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<HTMLButtonElement, SegmentedControlIconButtonProps>(
({icon: Icon, selected, sx: sxProp = {}, ...rest}, forwardedRef) => {
const sx = merge(getSegmentedControlButtonStyles({selected}), sxProp as SxProp)

return (
<Box as="button" aria-pressed={selected} sx={sx} {...rest} ref={forwardedRef}>
<span className="segmentedControl-content">
<Icon />
</span>
</Box>
)
}
)

export default SegmentedControlIconButton
83 changes: 83 additions & 0 deletions src/SegmentedControl/getSegmentedControlStyles.ts
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/SegmentedControl/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {default} from './SegmentedControl'
Loading