Skip to content

Commit 13be7ef

Browse files
authored
SegmentedControl (#2083)
* Add SegmentedControl * Fix dividers * Follow Figma spec * Create famous-moles-bow.md * Rename item to button * Rename visual to icon * Rename text to label * Use new size and typography tokens * Support IconOnly * Add icon only when narrow variant * Increase touch target * Add disabled state * Avoid size increase when item becomes bold * Add loading state * Lint * Add more templates * Use Primitives * Lint * Use variable * yarn add @primer/primitives@0.0.0-20220604151305 * Update Primitives * Address accessibility feedback * Remove loading state * Rename to leadingVisual * Rename label to text * Remove shadow * Change to inset * Update transitions * Use SegmentedControl-button--selected class instead of aria * Remove disabled prop * Keep $width-md for now * Add min-width * Fix divider for selected item * Add inset hover style * Keep dividers * Disable hover/active state when selected * Fix a few more things * Lint * yarn add @primer/primitives@0.0.0-20220720082700 * yarn add @primer/primitives@^7.9.0
1 parent ee73cf9 commit 13be7ef

File tree

9 files changed

+349
-2
lines changed

9 files changed

+349
-2
lines changed

.changeset/famous-moles-bow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/css": patch
3+
---
4+
5+
Add `SegmentedControl` component
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from 'react'
2+
import {SegmentedControlButtonTemplate} from './SegmentedControlButton.stories' // import stories for component compositions
3+
4+
export default {
5+
title: 'Components/SegmentedControl',
6+
parameters: {
7+
layout: 'padded'
8+
},
9+
excludeStories: ['BasicTemplate', 'IconsAndTextTemplate', 'IconsOnlyTemplate'],
10+
controls: { expanded: true },
11+
argTypes: {
12+
ariaLabel: {
13+
type: 'string',
14+
description: 'Aria label',
15+
},
16+
fullWidth: {
17+
control: {type: 'boolean'},
18+
description: 'full width',
19+
},
20+
iconOnlyWhenNarrow: {
21+
control: {type: 'boolean'},
22+
description: 'icon only when narrow',
23+
},
24+
}
25+
}
26+
27+
function classNames(fullWidth, iconOnlyWhenNarrow) {
28+
const classNames = ['SegmentedControl'];
29+
30+
if (fullWidth) {
31+
classNames.push("SegmentedControl--fullWidth")
32+
}
33+
if (iconOnlyWhenNarrow) {
34+
classNames.push("SegmentedControl--iconOnly-whenNarrow")
35+
}
36+
37+
return classNames.join(' ')
38+
}
39+
40+
export const BasicTemplate = ({fullWidth, ariaLabel}) => (
41+
<>
42+
<segmented-control role="toolbar" aria-label={ariaLabel} class={classNames(fullWidth)}>
43+
<SegmentedControlButtonTemplate text="Outline" selected />
44+
<SegmentedControlButtonTemplate text="Write" />
45+
<SegmentedControlButtonTemplate text="Preview" />
46+
<SegmentedControlButtonTemplate text="Publish" />
47+
</segmented-control>
48+
</>
49+
)
50+
51+
export const Basic = BasicTemplate.bind({})
52+
Basic.args = {
53+
ariaLabel: "Label",
54+
fullWidth: false,
55+
iconOnlyWhenNarrow: false,
56+
}
57+
58+
export const IconsAndTextTemplate = ({fullWidth, ariaLabel, iconOnlyWhenNarrow}) => (
59+
<>
60+
<segmented-control role="toolbar" aria-label={ariaLabel} class={classNames(fullWidth, iconOnlyWhenNarrow)}>
61+
<SegmentedControlButtonTemplate text="Outline" leadingVisual />
62+
<SegmentedControlButtonTemplate text="Write" leadingVisual selected />
63+
<SegmentedControlButtonTemplate text="Preview" leadingVisual />
64+
<SegmentedControlButtonTemplate text="Publish" leadingVisual />
65+
</segmented-control>
66+
</>
67+
)
68+
69+
export const IconsAndText = IconsAndTextTemplate.bind({})
70+
IconsAndText.args = {
71+
ariaLabel: "Label",
72+
fullWidth: false,
73+
iconOnlyWhenNarrow: false,
74+
}
75+
76+
export const IconsOnlyTemplate = ({fullWidth, ariaLabel, iconOnlyWhenNarrow}) => (
77+
<>
78+
<segmented-control role="toolbar" aria-label={ariaLabel} class={classNames(fullWidth, iconOnlyWhenNarrow)}>
79+
<SegmentedControlButtonTemplate text="Outline" leadingVisual iconOnly />
80+
<SegmentedControlButtonTemplate text="Write" leadingVisual iconOnly />
81+
<SegmentedControlButtonTemplate text="Preview" leadingVisual iconOnly />
82+
<SegmentedControlButtonTemplate text="Publish" leadingVisual iconOnly selected />
83+
</segmented-control>
84+
</>
85+
)
86+
87+
export const IconsOnly = IconsOnlyTemplate.bind({})
88+
IconsOnly.args = {
89+
ariaLabel: "Label",
90+
fullWidth: false,
91+
iconOnlyWhenNarrow: false,
92+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React from 'react'
2+
import clsx from 'clsx'
3+
4+
export default {
5+
title: 'Components/SegmentedControl/SegmentedControlButton',
6+
excludeStories: ['SegmentedControlButtonTemplate'],
7+
layout: 'padded',
8+
9+
argTypes: {
10+
selected: {
11+
control: {type: 'boolean'},
12+
description: 'Currently selected item',
13+
},
14+
text: {
15+
defaultValue: 'Item',
16+
type: 'string',
17+
name: 'text',
18+
description: 'Button text',
19+
},
20+
leadingVisual: {
21+
defaultValue: false,
22+
control: {type: 'boolean'},
23+
description: 'Has icon'
24+
},
25+
iconOnly: {
26+
defaultValue: false,
27+
control: {type: 'boolean'},
28+
description: 'Show icon only',
29+
},
30+
}
31+
}
32+
33+
// build every component case here in the template (private api)
34+
export const SegmentedControlButtonTemplate = ({selected, text, leadingVisual, iconOnly }) => (
35+
<>
36+
<button className={clsx(
37+
'SegmentedControl-button',
38+
iconOnly && `SegmentedControl-button--iconOnly`,
39+
selected && `SegmentedControl-button--selected`,
40+
)}
41+
aria-current={selected}
42+
aria-label={iconOnly && text}
43+
>
44+
<div class="SegmentedControl-content">
45+
{leadingVisual && (
46+
<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>
47+
)}
48+
{!iconOnly && (
49+
<span class="SegmentedControl-text" data-content={text}>{text}</span>
50+
)}
51+
</div>
52+
</button>
53+
</>
54+
)
55+
56+
// create a "playground" demo page that may set some defaults and allow story to access component controls
57+
export const Playground = SegmentedControlButtonTemplate.bind({})
58+
Playground.args = {
59+
text: 'Preview',
60+
leadingVisual: true,
61+
selected: true,
62+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"storybook": "cd docs && yarn && yarn storybook"
4242
},
4343
"dependencies": {
44-
"@primer/primitives": "^7.8.4"
44+
"@primer/primitives": "^7.9.0"
4545
},
4646
"devDependencies": {
4747
"@changesets/changelog-github": "0.4.5",

src/core/index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
@import '../links/index.scss';
2323
@import '../navigation/index.scss';
2424
@import '../pagination/index.scss';
25+
@import '../segmented-control/index.scss';
2526
@import '../tooltips/index.scss';
2627
@import '../truncate/index.scss';
2728
@import '../overlay/index.scss';

src/segmented-control/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
bundle: "segmented-control"
3+
generated: true
4+
---
5+
6+
# Primer CSS: `segmented-control` bundle
7+
8+
## Usage
9+
10+
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:
11+
12+
```scss
13+
@import "@primer/css/segmented-control/index.scss";
14+
```
15+
16+
## Build
17+
18+
The `@primer/css` npm package includes a standalone CSS build of this module in `dist/segmented-control.css`.
19+
20+
## License
21+
22+
[MIT](https://github.com/primer/css/blob/main/LICENSE) &copy; [GitHub](https://github.com/)
23+
24+
25+
[scss]: https://sass-lang.com/documentation/syntax#scss

src/segmented-control/index.scss

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// support files
2+
@import '../support/index.scss';
3+
@import './segmented-control.scss';
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SegmentedControl
2+
3+
.SegmentedControl {
4+
display: inline-flex;
5+
background-color: var(--color-segmented-control-bg);
6+
// stylelint-disable-next-line primer/borders
7+
border-radius: var(--primer-borderRadius-medium, $border-radius);
8+
// stylelint-disable-next-line primer/box-shadow
9+
box-shadow: var(--primer-borderInset-thin, inset 0 0 0 $border-width) var(--color-border-default);
10+
}
11+
12+
// Button -----------------------------------------
13+
14+
.SegmentedControl-button {
15+
position: relative;
16+
display: inline-flex;
17+
height: var(--primer-control-medium-size, 32px);
18+
// stylelint-disable-next-line primer/spacing
19+
padding: calc(var(--primer-control-xsmall-paddingInline-condensed, 4px) - var(--primer-borderWidth-thin, 1px));
20+
// stylelint-disable-next-line primer/typography
21+
font-size: var(--primer-text-body-size-medium, $body-font-size);
22+
color: var(--color-fg-default);
23+
background-color: transparent;
24+
// stylelint-disable-next-line primer/borders
25+
border: var(--primer-borderWidth-thin, $border-width) $border-style transparent;
26+
// stylelint-disable-next-line primer/borders
27+
border-radius: var(--primer-borderRadius-medium, $border-radius);
28+
29+
&:not(.SegmentedControl-button--selected):hover .SegmentedControl-content {
30+
background-color: var(--color-segmented-control-button-hover-bg);
31+
transition-duration: var(--primer-duration-fast, 80ms);
32+
}
33+
34+
&:not(.SegmentedControl-button--selected):active .SegmentedControl-content {
35+
background-color: var(--color-segmented-control-button-active-bg);
36+
transition-duration: 0;
37+
}
38+
39+
// Selected
40+
41+
&.SegmentedControl-button--selected {
42+
// stylelint-disable-next-line primer/typography
43+
font-weight: var(--base-text-weight-semibold, $font-weight-bold);
44+
background-color: var(--color-btn-bg);
45+
border-color: var(--color-segmented-control-button-selected-border);
46+
}
47+
48+
// Divider
49+
50+
// stylelint-disable-next-line scss/selector-no-redundant-nesting-selector
51+
& + .SegmentedControl-button::before {
52+
position: absolute;
53+
inset: var(--primer-borderWidth-thin, 1px) 0 0 calc(var(--primer-borderWidth-thin, 1px) * -1);
54+
height: var(--primer-text-body-size-large, 16px);
55+
// stylelint-disable-next-line primer/spacing
56+
margin-top: var(--primer-control-medium-paddingBlock, 6px);
57+
content: '';
58+
// stylelint-disable-next-line primer/borders
59+
border-left: var(--primer-borderWidth-thin, $border-width) $border-style var(--color-border-default);
60+
transition: border-color var(--primer-duration-medium, 160ms) cubic-bezier(0.3, 0.1, 0.5, 1);
61+
}
62+
63+
&.SegmentedControl-button--selected::before,
64+
&.SegmentedControl-button--selected + .SegmentedControl-button::before {
65+
border-color: transparent;
66+
}
67+
}
68+
69+
// Content -----------------------------------------
70+
71+
.SegmentedControl-content {
72+
display: flex;
73+
align-items: center;
74+
justify-content: center;
75+
gap: var(--primer-control-medium-gap, $spacer-2);
76+
height: 100%;
77+
// stylelint-disable-next-line primer/spacing
78+
padding: 0 var(--primer-control-medium-paddingInline-condensed, 8px);
79+
// stylelint-disable-next-line primer/borders
80+
border-radius: var(--primer-borderRadius-medium, $border-radius);
81+
transition: background-color var(--primer-duration-medium, 160ms) cubic-bezier(0.3, 0.1, 0.5, 1);
82+
}
83+
84+
// Leading visual -----------------------------------------
85+
86+
.SegmentedControl-leadingVisual {
87+
color: var(--color-fg-muted);
88+
}
89+
90+
// Text -----------------------------------------
91+
92+
.SegmentedControl-text {
93+
// renders a visibly hidden "copy" of the text in bold, reserving box space for when text becomes bold on selected
94+
&[data-content]::before {
95+
display: block;
96+
height: 0;
97+
// stylelint-disable-next-line primer/typography
98+
font-weight: var(--base-text-weight-semibold, $font-weight-bold);
99+
visibility: hidden;
100+
content: attr(data-content);
101+
}
102+
}
103+
104+
// Variants -----------------------------------------
105+
106+
// fullWidth
107+
.SegmentedControl--fullWidth {
108+
display: flex;
109+
110+
.SegmentedControl-button {
111+
flex: 1;
112+
justify-content: center;
113+
}
114+
}
115+
116+
// Icon only
117+
.SegmentedControl-button--iconOnly {
118+
width: var(--primer-control-medium-size, 32px);
119+
120+
.SegmentedControl-content {
121+
padding: 0;
122+
flex: 1;
123+
}
124+
}
125+
126+
// Icon only when narrow
127+
@media (max-width: $width-md) {
128+
.SegmentedControl--iconOnly-whenNarrow {
129+
.SegmentedControl-button {
130+
width: var(--primer-control-medium-size, 32px);
131+
}
132+
133+
.SegmentedControl-content {
134+
padding: 0;
135+
flex: 1;
136+
}
137+
138+
.SegmentedControl-text {
139+
display: none;
140+
}
141+
}
142+
}
143+
144+
// Increase touch target
145+
@media (pointer: coarse) {
146+
.SegmentedControl-button {
147+
min-width: var(--primer-control-minTarget-coarse, 44px);
148+
149+
&::after {
150+
@include minTouchTarget($min-height: var(--primer-control-minTarget-coarse, 44px));
151+
}
152+
}
153+
154+
// reset for icon-only buttons
155+
.SegmentedControl-button--iconOnly,
156+
.SegmentedControl--iconOnly-whenNarrow .SegmentedControl-button {
157+
min-width: unset;
158+
}
159+
}

yarn.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1091,7 +1091,7 @@
10911091
"@nodelib/fs.scandir" "2.1.5"
10921092
fastq "^1.6.0"
10931093

1094-
"@primer/primitives@^7.8.4":
1094+
"@primer/primitives@^7.9.0":
10951095
version "7.9.0"
10961096
resolved "https://registry.yarnpkg.com/@primer/primitives/-/primitives-7.9.0.tgz#c8a27287488c8308b1715a7d73214629c331544a"
10971097
integrity sha512-ZHHfwB0z0z6nDJp263gyGIClYDy+rl0nwqyi4qhcv3Cxhkmtf+If2KVjr6FQqBBFfi1wQwUzaax2FBvfEMFBnw==

0 commit comments

Comments
 (0)