-
Notifications
You must be signed in to change notification settings - Fork 1.3k
SegmentedControl #2083
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
SegmentedControl #2083
Changes from all commits
48b39d5
c25185f
af466be
ec54b85
d2e7738
9d052fb
f99cab2
746a284
20f4ca5
539e249
2828547
d387489
856d2af
065151c
4089e1a
f2057fa
5a522c9
f9e5a20
2b6206a
6db93a2
829a386
c004088
49c6357
46e545b
3831e4b
4ee0844
abf3153
a90d72b
2994f39
3d98ee8
92e53f1
912039f
fb6807f
2af922d
5d1517e
660aa5b
c7077df
e3baab2
a39eaaf
be4188c
70b7019
147c07c
b426825
9b2bf73
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@primer/css": patch | ||
--- | ||
|
||
Add `SegmentedControl` component |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
import React from 'react' | ||
import {SegmentedControlButtonTemplate} from './SegmentedControlButton.stories' // import stories for component compositions | ||
|
||
export default { | ||
title: 'Components/SegmentedControl', | ||
parameters: { | ||
layout: 'padded' | ||
}, | ||
excludeStories: ['BasicTemplate', 'IconsAndTextTemplate', 'IconsOnlyTemplate'], | ||
controls: { expanded: true }, | ||
argTypes: { | ||
ariaLabel: { | ||
type: 'string', | ||
description: 'Aria label', | ||
}, | ||
fullWidth: { | ||
control: {type: 'boolean'}, | ||
description: 'full width', | ||
}, | ||
iconOnlyWhenNarrow: { | ||
control: {type: 'boolean'}, | ||
description: 'icon only when narrow', | ||
}, | ||
} | ||
} | ||
|
||
function classNames(fullWidth, iconOnlyWhenNarrow) { | ||
const classNames = ['SegmentedControl']; | ||
|
||
if (fullWidth) { | ||
classNames.push("SegmentedControl--fullWidth") | ||
} | ||
if (iconOnlyWhenNarrow) { | ||
classNames.push("SegmentedControl--iconOnly-whenNarrow") | ||
} | ||
|
||
return classNames.join(' ') | ||
} | ||
|
||
export const BasicTemplate = ({fullWidth, ariaLabel}) => ( | ||
<> | ||
<segmented-control role="toolbar" aria-label={ariaLabel} class={classNames(fullWidth)}> | ||
<SegmentedControlButtonTemplate text="Outline" selected /> | ||
<SegmentedControlButtonTemplate text="Write" /> | ||
<SegmentedControlButtonTemplate text="Preview" /> | ||
<SegmentedControlButtonTemplate text="Publish" /> | ||
</segmented-control> | ||
</> | ||
) | ||
|
||
export const Basic = BasicTemplate.bind({}) | ||
Basic.args = { | ||
ariaLabel: "Label", | ||
fullWidth: false, | ||
iconOnlyWhenNarrow: false, | ||
} | ||
|
||
export const IconsAndTextTemplate = ({fullWidth, ariaLabel, iconOnlyWhenNarrow}) => ( | ||
<> | ||
<segmented-control role="toolbar" aria-label={ariaLabel} class={classNames(fullWidth, iconOnlyWhenNarrow)}> | ||
<SegmentedControlButtonTemplate text="Outline" leadingVisual /> | ||
<SegmentedControlButtonTemplate text="Write" leadingVisual selected /> | ||
<SegmentedControlButtonTemplate text="Preview" leadingVisual /> | ||
<SegmentedControlButtonTemplate text="Publish" leadingVisual /> | ||
</segmented-control> | ||
</> | ||
) | ||
|
||
export const IconsAndText = IconsAndTextTemplate.bind({}) | ||
IconsAndText.args = { | ||
ariaLabel: "Label", | ||
fullWidth: false, | ||
iconOnlyWhenNarrow: false, | ||
} | ||
|
||
export const IconsOnlyTemplate = ({fullWidth, ariaLabel, iconOnlyWhenNarrow}) => ( | ||
<> | ||
<segmented-control role="toolbar" aria-label={ariaLabel} class={classNames(fullWidth, iconOnlyWhenNarrow)}> | ||
<SegmentedControlButtonTemplate text="Outline" leadingVisual iconOnly /> | ||
<SegmentedControlButtonTemplate text="Write" leadingVisual iconOnly /> | ||
<SegmentedControlButtonTemplate text="Preview" leadingVisual iconOnly /> | ||
<SegmentedControlButtonTemplate text="Publish" leadingVisual iconOnly selected /> | ||
</segmented-control> | ||
</> | ||
) | ||
|
||
export const IconsOnly = IconsOnlyTemplate.bind({}) | ||
IconsOnly.args = { | ||
ariaLabel: "Label", | ||
fullWidth: false, | ||
iconOnlyWhenNarrow: false, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import React from 'react' | ||
import clsx from 'clsx' | ||
|
||
export default { | ||
title: 'Components/SegmentedControl/SegmentedControlButton', | ||
excludeStories: ['SegmentedControlButtonTemplate'], | ||
layout: 'padded', | ||
|
||
argTypes: { | ||
selected: { | ||
control: {type: 'boolean'}, | ||
description: 'Currently selected item', | ||
}, | ||
text: { | ||
defaultValue: 'Item', | ||
type: 'string', | ||
name: 'text', | ||
description: 'Button text', | ||
}, | ||
leadingVisual: { | ||
defaultValue: false, | ||
control: {type: 'boolean'}, | ||
description: 'Has icon' | ||
}, | ||
iconOnly: { | ||
defaultValue: false, | ||
control: {type: 'boolean'}, | ||
description: 'Show icon only', | ||
}, | ||
} | ||
} | ||
|
||
// build every component case here in the template (private api) | ||
export const SegmentedControlButtonTemplate = ({selected, text, leadingVisual, iconOnly }) => ( | ||
<> | ||
<button className={clsx( | ||
'SegmentedControl-button', | ||
iconOnly && `SegmentedControl-button--iconOnly`, | ||
selected && `SegmentedControl-button--selected`, | ||
)} | ||
aria-current={selected} | ||
aria-label={iconOnly && text} | ||
> | ||
<div class="SegmentedControl-content"> | ||
{leadingVisual && ( | ||
<svg class="SegmentedControl-leadingVisual octicon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.679 7.932c.412-.621 1.242-1.75 2.366-2.717C5.175 4.242 6.527 3.5 8 3.5c1.473 0 2.824.742 3.955 1.715 1.124.967 1.954 2.096 2.366 2.717a.119.119 0 010 .136c-.412.621-1.242 1.75-2.366 2.717C10.825 11.758 9.473 12.5 8 12.5c-1.473 0-2.824-.742-3.955-1.715C2.92 9.818 2.09 8.69 1.679 8.068a.119.119 0 010-.136zM8 2c-1.981 0-3.67.992-4.933 2.078C1.797 5.169.88 6.423.43 7.1a1.619 1.619 0 000 1.798c.45.678 1.367 1.932 2.637 3.024C4.329 13.008 6.019 14 8 14c1.981 0 3.67-.992 4.933-2.078 1.27-1.091 2.187-2.345 2.637-3.023a1.619 1.619 0 000-1.798c-.45-.678-1.367-1.932-2.637-3.023C11.671 2.992 9.981 2 8 2zm0 8a2 2 0 100-4 2 2 0 000 4z"></path></svg> | ||
)} | ||
{!iconOnly && ( | ||
<span class="SegmentedControl-text" data-content={text}>{text}</span> | ||
)} | ||
</div> | ||
</button> | ||
</> | ||
) | ||
|
||
// create a "playground" demo page that may set some defaults and allow story to access component controls | ||
export const Playground = SegmentedControlButtonTemplate.bind({}) | ||
Playground.args = { | ||
text: 'Preview', | ||
leadingVisual: true, | ||
selected: true, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
--- | ||
bundle: "segmented-control" | ||
generated: true | ||
--- | ||
|
||
# Primer CSS: `segmented-control` bundle | ||
|
||
## Usage | ||
|
||
Primer CSS source files are written in [SCSS]. To include this Primer CSS module in your own build, ensure that your `node_modules` directory is listed in your Sass include paths, then import it with: | ||
|
||
```scss | ||
@import "@primer/css/segmented-control/index.scss"; | ||
``` | ||
|
||
## Build | ||
|
||
The `@primer/css` npm package includes a standalone CSS build of this module in `dist/segmented-control.css`. | ||
|
||
## License | ||
|
||
[MIT](https://github.com/primer/css/blob/main/LICENSE) © [GitHub](https://github.com/) | ||
|
||
|
||
[scss]: https://sass-lang.com/documentation/syntax#scss |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
// support files | ||
@import '../support/index.scss'; | ||
@import './segmented-control.scss'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
// SegmentedControl | ||
|
||
.SegmentedControl { | ||
display: inline-flex; | ||
background-color: var(--color-segmented-control-bg); | ||
// stylelint-disable-next-line primer/borders | ||
border-radius: var(--primer-borderRadius-medium, $border-radius); | ||
// stylelint-disable-next-line primer/box-shadow | ||
box-shadow: var(--primer-borderInset-thin, inset 0 0 0 $border-width) var(--color-border-default); | ||
} | ||
|
||
// Button ----------------------------------------- | ||
|
||
.SegmentedControl-button { | ||
position: relative; | ||
display: inline-flex; | ||
height: var(--primer-control-medium-size, 32px); | ||
// stylelint-disable-next-line primer/spacing | ||
padding: calc(var(--primer-control-xsmall-paddingInline-condensed, 4px) - var(--primer-borderWidth-thin, 1px)); | ||
// stylelint-disable-next-line primer/typography | ||
font-size: var(--primer-text-body-size-medium, $body-font-size); | ||
color: var(--color-fg-default); | ||
background-color: transparent; | ||
// stylelint-disable-next-line primer/borders | ||
border: var(--primer-borderWidth-thin, $border-width) $border-style transparent; | ||
// stylelint-disable-next-line primer/borders | ||
border-radius: var(--primer-borderRadius-medium, $border-radius); | ||
|
||
&:not(.SegmentedControl-button--selected):hover .SegmentedControl-content { | ||
background-color: var(--color-segmented-control-button-hover-bg); | ||
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. Just a heads up: I'm experimenting with an inset hover background in my Primer React PR (Storybook preview deployment). It's not quite right yet, but let me know what you think. 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. Interesting.. it feels a bit too much inset. But yeah, we would not need to hide the dividers when hovering, which makes it simpler. 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. I agree that it's too inset. I'm iterating on it. 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. 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. I think that's a good idea for this component, but we usually go darker on If we went lighter, we'd probably need component-specific color tokens. If we go darker, we could share a color token between other interactive elements like ActionList items. 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. I think in our sync with @vdepizzol we decided light didn't make sense here 😄 so disregard my suggestion! I agree Mike we should try and stick with standard conventions. 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. Thanks @langermank - I completely forgot about the discussion in our sync 😅 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. |
||
transition-duration: var(--primer-duration-fast, 80ms); | ||
} | ||
|
||
&:not(.SegmentedControl-button--selected):active .SegmentedControl-content { | ||
background-color: var(--color-segmented-control-button-active-bg); | ||
transition-duration: 0; | ||
} | ||
|
||
// Selected | ||
|
||
&.SegmentedControl-button--selected { | ||
// stylelint-disable-next-line primer/typography | ||
font-weight: var(--base-text-weight-semibold, $font-weight-bold); | ||
background-color: var(--color-btn-bg); | ||
border-color: var(--color-segmented-control-button-selected-border); | ||
} | ||
|
||
// Divider | ||
|
||
// stylelint-disable-next-line scss/selector-no-redundant-nesting-selector | ||
& + .SegmentedControl-button::before { | ||
position: absolute; | ||
inset: var(--primer-borderWidth-thin, 1px) 0 0 calc(var(--primer-borderWidth-thin, 1px) * -1); | ||
height: var(--primer-text-body-size-large, 16px); | ||
// stylelint-disable-next-line primer/spacing | ||
margin-top: var(--primer-control-medium-paddingBlock, 6px); | ||
content: ''; | ||
// stylelint-disable-next-line primer/borders | ||
border-left: var(--primer-borderWidth-thin, $border-width) $border-style var(--color-border-default); | ||
transition: border-color var(--primer-duration-medium, 160ms) cubic-bezier(0.3, 0.1, 0.5, 1); | ||
} | ||
|
||
&.SegmentedControl-button--selected::before, | ||
&.SegmentedControl-button--selected + .SegmentedControl-button::before { | ||
border-color: transparent; | ||
simurai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
|
||
// Content ----------------------------------------- | ||
|
||
.SegmentedControl-content { | ||
display: flex; | ||
align-items: center; | ||
justify-content: center; | ||
gap: var(--primer-control-medium-gap, $spacer-2); | ||
height: 100%; | ||
// stylelint-disable-next-line primer/spacing | ||
padding: 0 var(--primer-control-medium-paddingInline-condensed, 8px); | ||
// stylelint-disable-next-line primer/borders | ||
border-radius: var(--primer-borderRadius-medium, $border-radius); | ||
transition: background-color var(--primer-duration-medium, 160ms) cubic-bezier(0.3, 0.1, 0.5, 1); | ||
} | ||
|
||
// Leading visual ----------------------------------------- | ||
|
||
.SegmentedControl-leadingVisual { | ||
color: var(--color-fg-muted); | ||
} | ||
|
||
// Text ----------------------------------------- | ||
|
||
.SegmentedControl-text { | ||
// renders a visibly hidden "copy" of the text in bold, reserving box space for when text becomes bold on selected | ||
&[data-content]::before { | ||
display: block; | ||
height: 0; | ||
// stylelint-disable-next-line primer/typography | ||
font-weight: var(--base-text-weight-semibold, $font-weight-bold); | ||
visibility: hidden; | ||
content: attr(data-content); | ||
} | ||
} | ||
|
||
// Variants ----------------------------------------- | ||
|
||
// fullWidth | ||
.SegmentedControl--fullWidth { | ||
display: flex; | ||
|
||
.SegmentedControl-button { | ||
flex: 1; | ||
justify-content: center; | ||
} | ||
} | ||
|
||
// Icon only | ||
.SegmentedControl-button--iconOnly { | ||
width: var(--primer-control-medium-size, 32px); | ||
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. @mperrotti Primer React implementation should align with this design decision. |
||
|
||
.SegmentedControl-content { | ||
padding: 0; | ||
flex: 1; | ||
} | ||
} | ||
|
||
// Icon only when narrow | ||
@media (max-width: $width-md) { | ||
.SegmentedControl--iconOnly-whenNarrow { | ||
.SegmentedControl-button { | ||
width: var(--primer-control-medium-size, 32px); | ||
} | ||
|
||
.SegmentedControl-content { | ||
padding: 0; | ||
flex: 1; | ||
} | ||
|
||
.SegmentedControl-text { | ||
display: none; | ||
} | ||
} | ||
} | ||
|
||
// Increase touch target | ||
@media (pointer: coarse) { | ||
.SegmentedControl-button { | ||
min-width: var(--primer-control-minTarget-coarse, 44px); | ||
|
||
&::after { | ||
@include minTouchTarget($min-height: var(--primer-control-minTarget-coarse, 44px)); | ||
} | ||
} | ||
|
||
// reset for icon-only buttons | ||
.SegmentedControl-button--iconOnly, | ||
.SegmentedControl--iconOnly-whenNarrow .SegmentedControl-button { | ||
min-width: unset; | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.