-
Notifications
You must be signed in to change notification settings - Fork 535
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
Changes from 1 commit
515a087
e0605c5
d2c6d5a
5f8bd3c
52eee4e
d10dd54
093e6a2
2ae5107
54ca574
054c2f0
d0a00d4
4958387
1f20ce9
813e1bb
afdeba8
563547b
8dbf6aa
c952c59
b1253ec
e14b6c0
5278c3b
4ab92d0
8bb8ba3
4695c84
8f22b04
c03b4e1
7ea6424
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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, | ||
sx: { | ||
'--separator-color': | ||
i === selectedIndex || i === selectedIndex - 1 ? 'transparent' : theme?.colors.border.default | ||
} as React.CSSProperties | ||
Comment on lines
+61
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we merge this with the
|
||
}) | ||
} | ||
})} | ||
</Box> | ||
) | ||
} | ||
|
||
export default Object.assign(SegmentedControl, { | ||
Button, | ||
IconButton: SegmentedControlIconButton | ||
}) |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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 |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export {default} from './SegmentedControl' |
There was a problem hiding this comment.
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: