diff --git a/.changeset/famous-moles-bow.md b/.changeset/famous-moles-bow.md new file mode 100644 index 0000000000..32e339ee79 --- /dev/null +++ b/.changeset/famous-moles-bow.md @@ -0,0 +1,5 @@ +--- +"@primer/css": patch +--- + +Add `SegmentedControl` component diff --git a/docs/src/stories/components/SegmentedControl/SegmentedControl.stories.jsx b/docs/src/stories/components/SegmentedControl/SegmentedControl.stories.jsx new file mode 100644 index 0000000000..969f6b7284 --- /dev/null +++ b/docs/src/stories/components/SegmentedControl/SegmentedControl.stories.jsx @@ -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}) => ( + <> + + + + + + + +) + +export const Basic = BasicTemplate.bind({}) +Basic.args = { + ariaLabel: "Label", + fullWidth: false, + iconOnlyWhenNarrow: false, +} + +export const IconsAndTextTemplate = ({fullWidth, ariaLabel, iconOnlyWhenNarrow}) => ( + <> + + + + + + + +) + +export const IconsAndText = IconsAndTextTemplate.bind({}) +IconsAndText.args = { + ariaLabel: "Label", + fullWidth: false, + iconOnlyWhenNarrow: false, +} + +export const IconsOnlyTemplate = ({fullWidth, ariaLabel, iconOnlyWhenNarrow}) => ( + <> + + + + + + + +) + +export const IconsOnly = IconsOnlyTemplate.bind({}) +IconsOnly.args = { + ariaLabel: "Label", + fullWidth: false, + iconOnlyWhenNarrow: false, +} diff --git a/docs/src/stories/components/SegmentedControl/SegmentedControlButton.stories.jsx b/docs/src/stories/components/SegmentedControl/SegmentedControlButton.stories.jsx new file mode 100644 index 0000000000..1959b0bc38 --- /dev/null +++ b/docs/src/stories/components/SegmentedControl/SegmentedControlButton.stories.jsx @@ -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 }) => ( + <> + + +) + +// 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, +} diff --git a/package.json b/package.json index 3a1b435cd7..a3840a459f 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "storybook": "cd docs && yarn && yarn storybook" }, "dependencies": { - "@primer/primitives": "^7.8.4" + "@primer/primitives": "^7.9.0" }, "devDependencies": { "@changesets/changelog-github": "0.4.5", diff --git a/src/core/index.scss b/src/core/index.scss index bc704ee041..dfba3821a7 100644 --- a/src/core/index.scss +++ b/src/core/index.scss @@ -22,6 +22,7 @@ @import '../links/index.scss'; @import '../navigation/index.scss'; @import '../pagination/index.scss'; +@import '../segmented-control/index.scss'; @import '../tooltips/index.scss'; @import '../truncate/index.scss'; @import '../overlay/index.scss'; diff --git a/src/segmented-control/README.md b/src/segmented-control/README.md new file mode 100644 index 0000000000..059a139a3b --- /dev/null +++ b/src/segmented-control/README.md @@ -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 diff --git a/src/segmented-control/index.scss b/src/segmented-control/index.scss new file mode 100644 index 0000000000..af2edf1547 --- /dev/null +++ b/src/segmented-control/index.scss @@ -0,0 +1,3 @@ +// support files +@import '../support/index.scss'; +@import './segmented-control.scss'; diff --git a/src/segmented-control/segmented-control.scss b/src/segmented-control/segmented-control.scss new file mode 100644 index 0000000000..811bf9ef24 --- /dev/null +++ b/src/segmented-control/segmented-control.scss @@ -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); + 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; + } +} + +// 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); + + .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; + } +} diff --git a/yarn.lock b/yarn.lock index decc893307..df5f8a40cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1091,7 +1091,7 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@primer/primitives@^7.8.4": +"@primer/primitives@^7.9.0": version "7.9.0" resolved "https://registry.yarnpkg.com/@primer/primitives/-/primitives-7.9.0.tgz#c8a27287488c8308b1715a7d73214629c331544a" integrity sha512-ZHHfwB0z0z6nDJp263gyGIClYDy+rl0nwqyi4qhcv3Cxhkmtf+If2KVjr6FQqBBFfi1wQwUzaax2FBvfEMFBnw==