-
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 20 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,102 @@ | ||
| import React from 'react' | ||
| import {SegmentedControlButtonTemplate} from './SegmentedControlButton.stories' // import stories for component compositions | ||
|
|
||
| export default { | ||
| title: 'Components/SegmentedControl', | ||
| parameters: { | ||
| layout: 'padded' | ||
| }, | ||
| excludeStories: ['BasicTemplate', 'IconsAndLabelsTemplate', 'IconsOnlyTemplate'], | ||
| controls: { expanded: true }, | ||
| argTypes: { | ||
| ariaLabel: { | ||
| type: 'string', | ||
| description: 'Aria label', | ||
| }, | ||
| disabled: { | ||
| control: {type: 'boolean'}, | ||
| description: 'disabled', | ||
| }, | ||
| fullWidth: { | ||
| control: {type: 'boolean'}, | ||
| description: 'full width', | ||
| }, | ||
| iconOnlyWhenNarrow: { | ||
| control: {type: 'boolean'}, | ||
| description: 'icon only when narrow', | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| function classNames(disabled, fullWidth, iconOnlyWhenNarrow) { | ||
| const classNames = ['SegmentedControl']; | ||
|
|
||
| if (disabled) { | ||
| classNames.push("SegmentedControl--disabled") | ||
| } | ||
| if (fullWidth) { | ||
| classNames.push("SegmentedControl--fullWidth") | ||
| } | ||
| if (iconOnlyWhenNarrow) { | ||
| classNames.push("SegmentedControl--iconOnly-whenNarrow") | ||
| } | ||
|
|
||
| return classNames.join(' ') | ||
| } | ||
|
|
||
| export const BasicTemplate = ({disabled, fullWidth, ariaLabel}) => ( | ||
| <> | ||
| <segmented-control role="group" aria-label={ariaLabel} class={classNames(disabled, fullWidth)}> | ||
| <SegmentedControlButtonTemplate label="Outline" ariaPressed /> | ||
| <SegmentedControlButtonTemplate label="Write" /> | ||
| <SegmentedControlButtonTemplate label="Preview" /> | ||
| <SegmentedControlButtonTemplate label="Publish" /> | ||
| </segmented-control> | ||
| </> | ||
| ) | ||
|
|
||
| export const Basic = BasicTemplate.bind({}) | ||
| Basic.args = { | ||
| ariaLabel: "Label", | ||
| disabled: false, | ||
| fullWidth: false, | ||
| iconOnlyWhenNarrow: false, | ||
| } | ||
|
|
||
| export const IconsAndLabelsTemplate = ({disabled, fullWidth, ariaLabel, iconOnlyWhenNarrow}) => ( | ||
| <> | ||
| <segmented-control role="group" aria-label={ariaLabel} class={classNames(disabled, fullWidth, iconOnlyWhenNarrow)}> | ||
| <SegmentedControlButtonTemplate label="Outline" leadingIcon ariaPressed /> | ||
| <SegmentedControlButtonTemplate label="Write" leadingIcon /> | ||
| <SegmentedControlButtonTemplate label="Preview" leadingIcon /> | ||
| <SegmentedControlButtonTemplate label="Publish" leadingIcon /> | ||
| </segmented-control> | ||
| </> | ||
| ) | ||
|
|
||
| export const IconsAndLabels = IconsAndLabelsTemplate.bind({}) | ||
| IconsAndLabels.args = { | ||
| ariaLabel: "Label", | ||
| disabled: false, | ||
| fullWidth: false, | ||
| iconOnlyWhenNarrow: false, | ||
| } | ||
|
|
||
| export const IconsOnlyTemplate = ({disabled, fullWidth, ariaLabel, iconOnlyWhenNarrow}) => ( | ||
| <> | ||
| <segmented-control role="group" aria-label={ariaLabel} class={classNames(disabled, fullWidth, iconOnlyWhenNarrow)}> | ||
| <SegmentedControlButtonTemplate label="Outline" leadingIcon iconOnly ariaPressed /> | ||
| <SegmentedControlButtonTemplate label="Write" leadingIcon iconOnly /> | ||
| <SegmentedControlButtonTemplate label="Preview" leadingIcon iconOnly /> | ||
| <SegmentedControlButtonTemplate label="Publish" leadingIcon iconOnly /> | ||
| </segmented-control> | ||
| </> | ||
| ) | ||
|
|
||
| export const IconsOnly = IconsOnlyTemplate.bind({}) | ||
| IconsOnly.args = { | ||
| ariaLabel: "Label", | ||
| disabled: false, | ||
| fullWidth: false, | ||
| iconOnlyWhenNarrow: false, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import React from 'react' | ||
| import clsx from 'clsx' | ||
|
|
||
| export default { | ||
| title: 'Components/SegmentedControl/SegmentedControlButton', | ||
| excludeStories: ['SegmentedControlButtonTemplate'], | ||
| layout: 'padded', | ||
|
|
||
| argTypes: { | ||
| ariaPressed: { | ||
| control: {type: 'boolean'}, | ||
| description: 'Currently selected item', | ||
| }, | ||
| isLoading: { | ||
| control: {type: 'boolean'}, | ||
| description: 'Pressed item is loading', | ||
| }, | ||
| label: { | ||
| defaultValue: 'Item', | ||
| type: 'string', | ||
| name: 'label', | ||
| description: 'Button label', | ||
| }, | ||
| leadingIcon: { | ||
| 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 = ({ariaPressed, isLoading, label, leadingIcon, iconOnly }) => ( | ||
| <> | ||
| <button className={clsx( | ||
| 'SegmentedControl-button', | ||
| iconOnly && `SegmentedControl-button--iconOnly`, | ||
| isLoading && `SegmentedControl-button--isLoading`, | ||
| )} | ||
| aria-pressed={ariaPressed} | ||
| aria-label={iconOnly && label} | ||
| > | ||
| {leadingIcon && ( | ||
| <svg class="SegmentedControl-leadingIcon 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-label" data-content={label}>{label}</span> | ||
| )} | ||
| {isLoading && ( | ||
| <svg style={{boxSizing: "content-box", color: "var(--color-icon-primary)"}} width="16" height="16" viewBox="0 0 16 16" fill="none" data-view-component="true" class="anim-rotate"> <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-opacity="0.25" stroke-width="2" vector-effect="non-scaling-stroke"></circle> <path d="M15 8a7.002 7.002 0 00-7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" vector-effect="non-scaling-stroke"></path></svg> | ||
| )} | ||
| </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 = { | ||
| label: 'Preview', | ||
| leadingIcon: true, | ||
| ariaPressed: true, | ||
| isLoading: false, | ||
| } |
| 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,173 @@ | ||
| // 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: 0 var(--primer-control-medium-paddingInline-normal, 12px); | ||
| // 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); | ||
| transition: border-color, background-color 180ms cubic-bezier(0.3, 0.1, 0.5, 1); | ||
| align-items: center; | ||
| justify-content: center; | ||
| gap: var(--primer-control-medium-gap, $spacer-2); | ||
|
|
||
| &:hover { | ||
| background-color: var(--color-segmented-control-hover-bg); | ||
| transition-duration: 120ms; | ||
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| &:active { | ||
| background-color: var(--color-segmented-control-active-bg); | ||
| border-color: var(--color-segmented-control-active-border); | ||
| transition-duration: 60ms; | ||
| } | ||
|
|
||
| // Pressed | ||
|
|
||
| // TODO: Change to aria-current or aria-selected? | ||
| &[aria-pressed='true'] { | ||
|
||
| // stylelint-disable-next-line primer/typography | ||
| font-weight: var(--base-text-weight-semibold, $font-weight-bold); | ||
| background-color: var(--color-segmented-control-pressed-bg); | ||
| border-color: var(--color-segmented-control-pressed-border); | ||
| box-shadow: var(--color-shadow-medium); | ||
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // Divider | ||
|
|
||
| // stylelint-disable-next-line scss/selector-no-redundant-nesting-selector | ||
| & + .SegmentedControl-button::before { | ||
| position: absolute; | ||
| top: 1px; // Account for border width | ||
| left: -1px; | ||
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 180ms cubic-bezier(0.3, 0.1, 0.5, 1); | ||
| } | ||
|
|
||
| // Remove dividers | ||
|
|
||
| &[aria-pressed='true'] + .SegmentedControl-button::before { | ||
| border-color: transparent; | ||
simurai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| &:hover, | ||
| &:active, | ||
| &:focus-visible { | ||
| &::before, | ||
| + .SegmentedControl-button::before { | ||
| border-color: transparent; | ||
| transition-duration: 60ms; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Icon ----------------------------------------- | ||
|
|
||
| .SegmentedControl-leadingIcon { | ||
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| color: var(--color-fg-muted); | ||
| } | ||
|
|
||
| // Label ----------------------------------------- | ||
|
|
||
| .SegmentedControl-label { | ||
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| // renders a visibly hidden "copy" of the label in bold, reserving box space for when label 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 ----------------------------------------- | ||
|
|
||
| // disabled | ||
| .SegmentedControl--disabled { // TODO: Replace with aria-disabled? | ||
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| .SegmentedControl-button { | ||
| color: var(--color-primer-fg-disabled); | ||
| pointer-events: none; | ||
| cursor: default; | ||
|
|
||
| &[aria-pressed='true'] { | ||
| border-color: var(--color-border-default); | ||
| } | ||
| } | ||
|
|
||
| .SegmentedControl-leadingIcon { | ||
| color: var(--color-primer-fg-disabled); | ||
| } | ||
| } | ||
|
|
||
| .SegmentedControl-button--isLoading.SegmentedControl-button--isLoading { | ||
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| pointer-events: none; | ||
| cursor: default; | ||
|
|
||
| .SegmentedControl-label, | ||
| .SegmentedControl-leadingIcon { | ||
| color: var(--color-primer-fg-disabled); | ||
| } | ||
| } | ||
|
|
||
| // fullWidth | ||
| .SegmentedControl--fullWidth { | ||
| display: flex; | ||
|
|
||
| .SegmentedControl-button { | ||
| flex: 1; | ||
| } | ||
| } | ||
|
|
||
| // Icon only | ||
| .SegmentedControl-button--iconOnly { | ||
| width: var(--primer-control-medium-size, 32px); | ||
|
Contributor
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. |
||
| padding-right: 0; | ||
| padding-left: 0; | ||
| } | ||
|
|
||
| // Icon only when narrow | ||
| @media (max-width: $width-md) { // TODO: use token | ||
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| .SegmentedControl--iconOnly-whenNarrow { | ||
| .SegmentedControl-button { | ||
| width: var(--primer-control-medium-size, 32px); | ||
| padding-right: 0; | ||
| padding-left: 0; | ||
| } | ||
|
|
||
| .SegmentedControl-label { | ||
| display: none; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Increase touch target | ||
| @media (pointer: coarse) { | ||
| .SegmentedControl-button::after { | ||
| @include minTouchTarget($min-height: var(--primer-control-minTarget-coarse, 44px)); | ||
simurai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.