diff --git a/.storybook/helpers.js b/.storybook/helpers.js index 5870e0a15..4f76fc66f 100644 --- a/.storybook/helpers.js +++ b/.storybook/helpers.js @@ -11,7 +11,9 @@ function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } -export const generateLabel = ( +export const generateLabel = (items) => capitalizeFirstLetter(items.join(' ')); + +export const generateButtonLabel = ( shapeVariant = [], colorVariant = [], disabled = false, diff --git a/CHANGELOG.md b/CHANGELOG.md index 23681f3d8..c9ce63ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - It is now possible to import bitstyles on a per-layer basis, instead of per-module or all at once. It is still possible to override all modules inside each layer in the normal way. - There are now design tokens as `design-tokens/focus` to describe a consistent `:focus` appearance, that are currently used in `base/anchor/`, `atoms/buttons`, and `atoms/links`. - Anchor elements and `atoms/link` components now have a disabled state, applied using the `aria-disabled` attribute. +- A new layout component at `atoms/switcher`, that lays out its children in a horizontal row with consistent spacing between children. The layout switches to a vertical stack once the width of the component passes below a threshold, or the number of children goes over a limit. ## [[5.0.0]](https://github.com/bitcrowd/bitstyles/releases/tag/v5.0.0) - 2023-01-03 diff --git a/scss/bitstyles/atoms/_index.scss b/scss/bitstyles/atoms/_index.scss index 82c48b558..5200c9c6b 100644 --- a/scss/bitstyles/atoms/_index.scss +++ b/scss/bitstyles/atoms/_index.scss @@ -13,4 +13,5 @@ @forward './icon' as icon-*; @forward './link' as link-*; @forward './skip-link' as skip-link-*; +@forward './switcher' as switcher-*; @forward './topbar' as topbar-*; diff --git a/scss/bitstyles/atoms/content/_settings.scss b/scss/bitstyles/atoms/content/_settings.scss index 8799fba3d..e010093e9 100644 --- a/scss/bitstyles/atoms/content/_settings.scss +++ b/scss/bitstyles/atoms/content/_settings.scss @@ -12,5 +12,5 @@ $max-width-base: 'm' !default; $padding: ( '#{setup.$no-media-query}': var(design-token.get('content', 'padding', 'base')), - 'l': var(design-token.get('content', 'padding', 'l')), + 'l': var(design-token.get('content', 'padding', 'l1')), ) !default; diff --git a/scss/bitstyles/atoms/switcher/Switcher.js b/scss/bitstyles/atoms/switcher/Switcher.js new file mode 100644 index 000000000..9522e2763 --- /dev/null +++ b/scss/bitstyles/atoms/switcher/Switcher.js @@ -0,0 +1,35 @@ +import { generateLabel } from '../../../../.storybook/helpers'; + +const SwitcherItem = ({ + children, + backgroundColor = 'var(--bs-color-grayscale-light-2)', +}) => { + const switcherItem = document.createElement('div'); + switcherItem.style.backgroundColor = backgroundColor; + switcherItem.style.paddingBlock = 'var(--bs-size-s2)'; + switcherItem.style.paddingInline = 'var(--bs-content-padding-base)'; + switcherItem.style.borderRadius = 'var(--bs-size-s4)'; + switcherItem.innerHTML = children; + return switcherItem; +}; + +const Switcher = ({ length = 3, classname = [], children = [] }) => { + const switcher = document.createElement('div'); + switcher.classList.add('a-switcher'); + classname.forEach((cls) => switcher.classList.add(cls)); + + if (children.length) { + children.forEach((child) => switcher.append(child)); + } else { + for (let child = 0; child < length; child += 1) { + switcher.append( + SwitcherItem({ + children: generateLabel(['switcher', 'child', child + 1]), + }) + ); + } + } + return switcher; +}; + +export { Switcher, SwitcherItem }; diff --git a/scss/bitstyles/atoms/switcher/_index.scss b/scss/bitstyles/atoms/switcher/_index.scss new file mode 100644 index 000000000..31f1fe052 --- /dev/null +++ b/scss/bitstyles/atoms/switcher/_index.scss @@ -0,0 +1,54 @@ +@forward 'settings'; +@use './settings'; +@use '../../tools/classname'; +@use '../../tools/design-token'; +@use '../../tools/media-query'; +@use 'sass:map'; + +/* stylelint-disable scss/dollar-variable-default */ +$breakpoint-property-name: design-token.get('switcher', 'breakpoint'); +$spacing-property-name: design-token.get('switcher', 'spacing'); + +#{classname.get($classname-items: 'switcher', $layer: 'atom')} { + display: flex; + flex-wrap: wrap; + gap: var(#{$spacing-property-name}); + + > * { + flex-basis: calc((var(#{$breakpoint-property-name}) - 100%) * 999); + flex-grow: 1; + } +} +/* stylelint-enable scss/dollar-variable-default */ + +@each $breakpoint, $size-variants in settings.$size-variants { + @include media-query.get($breakpoint) { + @each $size-variant-name, $size-variant in ($size-variants) { + $class: ''; + @if $size-variant-name == '' { + $class: 'switcher'; + } @else { + $class: 'switcher--#{$size-variant-name}'; + } + + /* stylelint-disable max-nesting-depth */ + #{classname.get($classname-items: $class, $layer: 'atom')} { + @if map.has-key($size-variant, 'breakpoint') { + #{$breakpoint-property-name}: #{map.get($size-variant, 'breakpoint')}; + } + + @if map.has-key($size-variant, 'spacing') { + #{$spacing-property-name}: #{map.get($size-variant, 'spacing')}; + } + + @if map.has-key($size-variant, 'limit') { + > :nth-last-child(n + #{map.get($size-variant, 'limit') + 1}), + > :nth-last-child(n + #{map.get($size-variant, 'limit') + 1}) ~ * { + flex-basis: 100%; + } + } + } + /* stylelint-enable max-nesting-depth */ + } + } +} diff --git a/scss/bitstyles/atoms/switcher/_settings.scss b/scss/bitstyles/atoms/switcher/_settings.scss new file mode 100644 index 000000000..f53bcaf33 --- /dev/null +++ b/scss/bitstyles/atoms/switcher/_settings.scss @@ -0,0 +1,17 @@ +@use '../../settings/setup'; +@use '../../tools/design-token'; + +$size-variants: ( + '#{setup.$no-media-query}': ( + '': ( + 'spacing': var(design-token.get('size', 's5')), + 'breakpoint': 30rem, + 'limit': 4, + ), + ), + 'l': ( + '': ( + 'spacing': var(design-token.get('size', 's3')), + ), + ), +) !default; diff --git a/scss/bitstyles/atoms/switcher/switcher.stories.js b/scss/bitstyles/atoms/switcher/switcher.stories.js new file mode 100644 index 000000000..91079a96d --- /dev/null +++ b/scss/bitstyles/atoms/switcher/switcher.stories.js @@ -0,0 +1,26 @@ +import { Switcher } from './Switcher'; + +export default { + title: 'Atoms/Switcher', + component: Switcher, + argTypes: {}, +}; + +const Template = (args) => Switcher(args); + +// ***** Size variants ****************** // + +export const One = Template.bind({}); +One.args = { length: 1 }; + +export const Two = Template.bind({}); +Two.args = { length: 2 }; + +export const Three = Template.bind({}); +Three.args = { length: 3 }; + +export const Four = Template.bind({}); +Four.args = { length: 4 }; + +export const Five = Template.bind({}); +Five.args = { length: 5 }; diff --git a/scss/bitstyles/atoms/switcher/switcher.stories.mdx b/scss/bitstyles/atoms/switcher/switcher.stories.mdx new file mode 100644 index 000000000..9ac4854b2 --- /dev/null +++ b/scss/bitstyles/atoms/switcher/switcher.stories.mdx @@ -0,0 +1,99 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs'; + + + + + + + +# Switcher + +A layout atom that places its children in a single horizontal row and ensures consistent space between each. The layout switches to a vertical stack when it is less than `30rem` wide, or when there are more than 4 children (with default configuration). All children will be the same width in both layouts. This component is responsive, applying larger spacing between children when rendered on larger viewports. + +The layout is best suited to small blocks of content such as a list of related buttons or stats. + +The spacing and the breakpoints the component responds to can be [customized](#customization). You can also add extra size variants of the switcher that apply different spacing; the default configuration provides one size variant that applies a larger spacing at the `l` breakpoint. + + + + + + + + + + + + + + + + + +When a switcher has more children than the specified limit (4 in the default configuration), it will switch to the vertical layout, to avoid the children being squashed. + + + + + +## Customization + +The component expects a Sass list of Sass maps, with the keys being the name of the breakpoint (use `setup.$no-media-query` for the base mobile-first styles) and the values being map with the key being the name of the switcher variant, and the value a list of properties. You can change the breakpoint names or add new breakpoints if you want the component to apply different spacing at extra breakpoints (in which case you probably also want to [edit the available `content` padding design tokens](/docs/design-tokens-content--page) available to you, though you can also pass `size` design tokens directly) + +```scss +@use '~bitstyles/scss/bitstyles/atoms/switcher' with ( + $size-variants: ( + '#{setup.$no-media-query}': ( + '': ( + 'spacing': var(design-token.get('size', 's5')), + 'breakpoint': 30rem, + 'limit': 4, + ), + ), + 'l': ( + '': ( + 'spacing': var(design-token.get('size', 's3')), + ), + ), + ) +); +``` + +### Available properties + +| Property | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `spacing` | Spacing between each child. This value is used in both horizontal and vertical layouts. | +| `breakpoint` | Minimum inline-size of the row layout, below which it switches to the stack layout. | +| `limit` | The maximum number of children that will fit in the row. If there are more children, the switcher will render a stack layout. | + +### New size variants + +The keys of the variants values above are deliberately left blank — that results in those properties being applied to the base `a-stack` component. If you provide a key, that will be used to create a stack variant. See the example below, where a `tight` variant is created with no spacing + +```scss +@use '~bitstyles/scss/bitstyles/atoms/switcher' with ( + $size-variants: ( + '#{setup.$no-media-query}': ( + '': ( + 'spacing': var(design-token.get('size', 's5')), + ), + 'tight': ( + 'spacing': 0, + ), + ), + ) +); +``` + +Produces CSS similar to the following: + +```css +.a-switcher { + gap: var(--bs-size-s5); +} + +.a-switcher--tight { + gap: 0; +} +``` diff --git a/scss/bitstyles/design-tokens/_content.scss b/scss/bitstyles/design-tokens/_content.scss index afd112b83..d8f2761c9 100644 --- a/scss/bitstyles/design-tokens/_content.scss +++ b/scss/bitstyles/design-tokens/_content.scss @@ -5,11 +5,11 @@ $padding: ( '#{setup.$no-media-query}': ( 'base': var(design-token.get('size', 's1')), - 'l': var(design-token.get('size', 'm')), + 'l1': var(design-token.get('size', 'm')), ), 'm': ( 'base': var(design-token.get('size', 'l1')), - 'l': var(design-token.get('size', 'l1')), + 'l1': var(design-token.get('size', 'l3')), ), ) !default; diff --git a/test/scss/fixtures/bitstyles-overrides.css b/test/scss/fixtures/bitstyles-overrides.css index 715c0900d..878834c2b 100644 --- a/test/scss/fixtures/bitstyles-overrides.css +++ b/test/scss/fixtures/bitstyles-overrides.css @@ -129,12 +129,12 @@ } :root { --bscpn-content-padding-base: var(--bscpn-size-s1); - --bscpn-content-padding-l: var(--bscpn-size-m); + --bscpn-content-padding-l1: var(--bscpn-size-m); } @media screen and (min-width: 30em) { :root { --bscpn-content-padding-base: var(--bscpn-size-l1); - --bscpn-content-padding-l: var(--bscpn-size-l1); + --bscpn-content-padding-l1: var(--bscpn-size-l3); } } html { @@ -1786,8 +1786,8 @@ table { } @media screen and (min-width: 55em) { .bs-at-content { - padding-left: var(--bscpn-content-padding-l); - padding-right: var(--bscpn-content-padding-l); + padding-left: var(--bscpn-content-padding-l1); + padding-right: var(--bscpn-content-padding-l1); } } .bs-at-content--xs { @@ -2035,6 +2035,26 @@ table { calc(var(--bscpn-size-s7) + var(--bscpn-size-s7) / 2); outline-offset: var(--bscpn-size-s7); } +.bs-at-switcher { + display: flex; + flex-wrap: wrap; + gap: var(--bscpn-switcher-spacing); +} +.bs-at-switcher > * { + flex-basis: calc((var(--bscpn-switcher-breakpoint) - 100%) * 999); + flex-grow: 1; +} +.bs-at-switcher { + --bscpn-switcher-spacing: var(--bscpn-content-padding-base); +} +.bs-at-switcher--large { + --bscpn-switcher-spacing: var(--bscpn-content-padding-l1); +} +@media screen and (min-width: 30em) { + .bs-at-switcher { + --bscpn-switcher-spacing: var(--bscpn-content-padding-l1); + } +} .bs-at-topbar { left: 0; padding: 10rem var(--bscpn-size-s1); diff --git a/test/scss/fixtures/bitstyles.css b/test/scss/fixtures/bitstyles.css index c6879a4b2..7d074b47f 100644 --- a/test/scss/fixtures/bitstyles.css +++ b/test/scss/fixtures/bitstyles.css @@ -376,12 +376,12 @@ } :root { --bs-content-padding-base: var(--bs-size-s1); - --bs-content-padding-l: var(--bs-size-m); + --bs-content-padding-l1: var(--bs-size-m); } @media screen and (min-width: 30em) { :root { --bs-content-padding-base: var(--bs-size-l1); - --bs-content-padding-l: var(--bs-size-l1); + --bs-content-padding-l1: var(--bs-size-l3); } } html { @@ -2198,8 +2198,8 @@ table { } @media screen and (min-width: 55em) { .a-content { - padding-left: var(--bs-content-padding-l); - padding-right: var(--bs-content-padding-l); + padding-left: var(--bs-content-padding-l1); + padding-right: var(--bs-content-padding-l1); } } .a-content--xs { @@ -2463,6 +2463,28 @@ table { calc(var(--bs-size-s7) + var(--bs-size-s7) / 2); outline-offset: var(--bs-size-s7); } +.a-switcher { + display: flex; + flex-wrap: wrap; + gap: var(--bs-switcher-spacing); +} +.a-switcher > * { + flex-basis: calc((var(--bs-switcher-breakpoint) - 100%) * 999); + flex-grow: 1; +} +.a-switcher { + --bs-switcher-breakpoint: 30rem; + --bs-switcher-spacing: var(--bs-size-s5); +} +.a-switcher > :nth-last-child(n + 5), +.a-switcher > :nth-last-child(n + 5) ~ * { + flex-basis: 100%; +} +@media screen and (min-width: 55em) { + .a-switcher { + --bs-switcher-spacing: var(--bs-size-s3); + } +} .a-topbar { left: 0; padding: var(--bs-size-s3) var(--bs-size-s1); diff --git a/test/scss/test-use-all.scss b/test/scss/test-use-all.scss index 5465a3b31..5cdf3b4cb 100644 --- a/test/scss/test-use-all.scss +++ b/test/scss/test-use-all.scss @@ -143,6 +143,21 @@ ), $icon-sizes: ('s': 10rem), $skip-link-color: #f00, + $switcher-size-variants: ( + 'no-mq': ( + '': ( + 'spacing': var(--bscpn-content-padding-base), + ), + 'large': ( + 'spacing': var(--bscpn-content-padding-l1), + ), + ), + 'm': ( + '': ( + 'spacing': var(--bscpn-content-padding-l1), + ), + ) + ), $topbar-vertical-padding: 10rem, // organisms $modal-padding: 10rem, diff --git a/test/scss/test-use-each.scss b/test/scss/test-use-each.scss index cf76d26e6..986c49fa5 100644 --- a/test/scss/test-use-each.scss +++ b/test/scss/test-use-each.scss @@ -234,6 +234,23 @@ ); @use '../../scss/bitstyles/atoms/link'; @use '../../scss/bitstyles/atoms/skip-link' with($color: #f00); +@use '../../scss/bitstyles/atoms/switcher' with ( + $size-variants: ( + 'no-mq': ( + '': ( + 'spacing': var(--bscpn-content-padding-base), + ), + 'large': ( + 'spacing': var(--bscpn-content-padding-l1), + ), + ), + 'm': ( + '': ( + 'spacing': var(--bscpn-content-padding-l1), + ), + ), + ) +); @use '../../scss/bitstyles/atoms/topbar' with ( $vertical-padding: 10rem ); diff --git a/test/scss/test-use-layers.scss b/test/scss/test-use-layers.scss index 868d2196e..987c0eacf 100644 --- a/test/scss/test-use-layers.scss +++ b/test/scss/test-use-layers.scss @@ -157,6 +157,21 @@ 's': 10rem, ), $skip-link-color: #f00, + $switcher-size-variants: ( + 'no-mq': ( + '': ( + 'spacing': var(--bscpn-content-padding-base), + ), + 'large': ( + 'spacing': var(--bscpn-content-padding-l1), + ), + ), + 'm': ( + '': ( + 'spacing': var(--bscpn-content-padding-l1), + ), + ), + ), $topbar-vertical-padding: 10rem ); @use '../../scss/bitstyles/organisms' with (