From 47570b05971d28e6bf0abc535d4a9c96f0b8f0c2 Mon Sep 17 00:00:00 2001 From: Alona Zherdetska <138328641+alionazherdetska@users.noreply.github.com> Date: Mon, 25 Nov 2024 17:51:24 +0100 Subject: [PATCH] feat(components): segmented-button (#3879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Zherdetska Alona, IT21.1 Co-authored-by: Oliver Schürch Co-authored-by: Philipp Gfeller <1659006+gfellerph@users.noreply.github.com> Co-authored-by: Philipp Gfeller --- .changeset/big-hats-clap.md | 6 + .../e2e/components/segmented-button.cy.ts | 45 +++++ .../components/segmented-button.snapshot.ts | 7 + .../segmented-button.docs.mdx | 34 ++++ .../segmented-button.snapshot.stories.ts | 48 +++++ .../segmented-button.stories.ts | 81 ++++++++ packages/styles/src/elements/_index.scss | 1 + .../styles/src/elements/segmented-button.scss | 186 ++++++++++++++++++ 8 files changed, 408 insertions(+) create mode 100644 .changeset/big-hats-clap.md create mode 100644 packages/documentation/cypress/e2e/components/segmented-button.cy.ts create mode 100644 packages/documentation/cypress/snapshots/components/segmented-button.snapshot.ts create mode 100644 packages/documentation/src/stories/components/segmented-button/segmented-button.docs.mdx create mode 100644 packages/documentation/src/stories/components/segmented-button/segmented-button.snapshot.stories.ts create mode 100644 packages/documentation/src/stories/components/segmented-button/segmented-button.stories.ts create mode 100644 packages/styles/src/elements/segmented-button.scss diff --git a/.changeset/big-hats-clap.md b/.changeset/big-hats-clap.md new file mode 100644 index 0000000000..4ebd6d7ba6 --- /dev/null +++ b/.changeset/big-hats-clap.md @@ -0,0 +1,6 @@ +--- +'@swisspost/design-system-documentation': minor +'@swisspost/design-system-styles': minor +--- + +Added a new `segmented-button` component, which allows users to toggle between two or more content sections within the same area on the screen. diff --git a/packages/documentation/cypress/e2e/components/segmented-button.cy.ts b/packages/documentation/cypress/e2e/components/segmented-button.cy.ts new file mode 100644 index 0000000000..8782575862 --- /dev/null +++ b/packages/documentation/cypress/e2e/components/segmented-button.cy.ts @@ -0,0 +1,45 @@ +describe('Segmented Button', () => { + describe('Accessibility', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=snapshots--segmented-button'); + cy.get('.segmented-button', { timeout: 30000 }).should('be.visible'); + cy.injectAxe(); + }); + + it('Has no detectable a11y violations on load for all variants', () => { + cy.checkA11y('#root-inner'); + }); + }); + + describe('Responsiveness', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=snapshots--segmented-button'); + cy.get('.segmented-button', { timeout: 30000 }).should('be.visible'); + }); + + it('Displays vertical layout when viewport is narrower than 600px', () => { + cy.viewport(500, 600); + cy.get('.segmented-button') + .should('have.css', 'flex-direction', 'column'); + }); + }); + + describe('Input Selection', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=snapshots--segmented-button'); + cy.get('.segmented-button', { timeout: 30000 }).should('be.visible'); + }); + + it('Allows selecting an input and updates the state', () => { + cy.get('.segmented-button label').first().click(); + + cy.get('.segmented-button label input').first().should('be.checked'); + + cy.get('.segmented-button label').eq(1).click(); + + cy.get('.segmented-button label input').eq(1).should('be.checked'); + + cy.get('.segmented-button label input').first().should('not.be.checked'); + }); + }); +}); diff --git a/packages/documentation/cypress/snapshots/components/segmented-button.snapshot.ts b/packages/documentation/cypress/snapshots/components/segmented-button.snapshot.ts new file mode 100644 index 0000000000..f7224a3f6b --- /dev/null +++ b/packages/documentation/cypress/snapshots/components/segmented-button.snapshot.ts @@ -0,0 +1,7 @@ +describe('Segmented-button', () => { + it('default', () => { + cy.visit('/iframe.html?id=snapshots--segmented-button'); + cy.get('.segmented-button', { timeout: 30000 }).should('be.visible'); + cy.percySnapshot('Segmented-button', { widths: [1440] }); + }); +}); diff --git a/packages/documentation/src/stories/components/segmented-button/segmented-button.docs.mdx b/packages/documentation/src/stories/components/segmented-button/segmented-button.docs.mdx new file mode 100644 index 0000000000..73aaaebc93 --- /dev/null +++ b/packages/documentation/src/stories/components/segmented-button/segmented-button.docs.mdx @@ -0,0 +1,34 @@ +import { Meta, Canvas, Controls } from '@storybook/blocks'; +import * as SegmentedButtonStories from './segmented-button.stories'; +import StylesPackageImport from '@/shared/styles-package-import.mdx'; + + + +
+ # Segmented Button + + +
+ +The segmented button is a single-select component. +It allows users to toggle between two or more content sections within the same area on the screen. + + + + + + +## Segmented icon button + + + + + diff --git a/packages/documentation/src/stories/components/segmented-button/segmented-button.snapshot.stories.ts b/packages/documentation/src/stories/components/segmented-button/segmented-button.snapshot.stories.ts new file mode 100644 index 0000000000..81ec733eff --- /dev/null +++ b/packages/documentation/src/stories/components/segmented-button/segmented-button.snapshot.stories.ts @@ -0,0 +1,48 @@ +import type { StoryObj } from '@storybook/web-components'; +import meta from './segmented-button.stories'; +import { html } from 'lit'; + +const { id, ...metaWithoutId } = meta; + +export default { + ...metaWithoutId, + title: 'Snapshots', +}; + +type Story = StoryObj; + +export const SegmentedButton: Story = { + render: () => { + const labelCounts = [2, 4, 6, 8]; + const themes = ['bg-light', 'bg-dark']; + + return html` +
+ ${themes.map( + (theme) => html` +
+ ${labelCounts.map((count) => { + const labels = Array.from({ length: count }, (_, i) => `Label ${i + 1}`); + + return html` +
+
+ ${labels.map( + (label) => html` + + ` + )} +
+
+ `; + })} +
+ ` + )} +
+ `; + }, +}; diff --git a/packages/documentation/src/stories/components/segmented-button/segmented-button.stories.ts b/packages/documentation/src/stories/components/segmented-button/segmented-button.stories.ts new file mode 100644 index 0000000000..f14bc7e54d --- /dev/null +++ b/packages/documentation/src/stories/components/segmented-button/segmented-button.stories.ts @@ -0,0 +1,81 @@ +import { Args, StoryObj } from '@storybook/web-components'; +import { html, nothing } from 'lit'; +import { MetaComponent } from '@root/types'; + +const MAX_LABELS = 8; + +const meta: MetaComponent = { + id: '78509712-d45e-462c-bde3-405cfaff5421', + title: 'Components/Segmented button', + tags: ['package:HTML'], + parameters: { + badges: [], + design: { + type: 'figma', + url: 'https://www.figma.com/design/JIT5AdGYqv6bDRpfBPV8XR/Foundations-%26-Components-Next-Level?node-id=2864-83396&node-type=instance&m=dev', + }, + }, + args: { + labelCount: 4, + }, + argTypes: { + labelCount: { + name: 'Number of segments', + description: `Defines the number of segments for the segmented button. The maximum number of supported segments is 8. If you need more options, please refer to the select component.`, + control: { type: 'number', min: 1, max: MAX_LABELS }, + table: { category: 'Content' }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const TextExample: Story = { + render: (args: Args) => { + const labelCount = Math.min(args.labelCount || 0, MAX_LABELS); + const labelsArray = Array.from({ length: labelCount }, (_, i) => `Label ${i + 1}`); + const name = `segmented-button-${Math.random().toString(36).slice(-6)}`; + + return html` +
+
+ Choose one of the options + ${labelsArray.map( + (label, index) => html` + + `, + )} +
+
+ `; + }, +}; + +export const IconExample: Story = { + render: (args: Args) => { + const labelCount = Math.min(args.labelCount || 0, MAX_LABELS); + const name = `segmented-button-${Math.random().toString(36).slice(-6)}`; + + return html` +
+
+ Choose one of the options + ${Array.from( + { length: labelCount }, + (_undefined, index) => html` + + `, + )} +
+
+ `; + }, +}; diff --git a/packages/styles/src/elements/_index.scss b/packages/styles/src/elements/_index.scss index 99e605d3a3..788f17adaa 100644 --- a/packages/styles/src/elements/_index.scss +++ b/packages/styles/src/elements/_index.scss @@ -5,5 +5,6 @@ @use 'list-bullet'; @use 'paragraph'; @use 'fieldset-legend'; +@use 'segmented-button'; @use 'list'; @use 'heading'; diff --git a/packages/styles/src/elements/segmented-button.scss b/packages/styles/src/elements/segmented-button.scss new file mode 100644 index 0000000000..8eeceee911 --- /dev/null +++ b/packages/styles/src/elements/segmented-button.scss @@ -0,0 +1,186 @@ +@use '../functions/tokens'; +@use '../tokens/components'; +@use '../mixins/utilities'; +@use '../core' as post; + +tokens.$default-map: components.$post-segmented-button; + +$post-segmented-button-max-count: 8; + +.segmented-button-container { + container-name: post-segmented-container; + container-type: inline-size; +} + +.segmented-button > legend { + @include post.visually-hidden(); +} + +.segmented-button { + display: flex; + gap: tokens.get('button-segmented-gap-inline'); + align-items: stretch; + outline: tokens.get('button-segmented-border-width') solid + tokens.get('button-segmented-enabled-border'); + outline-offset: calc(tokens.get('button-segmented-border-width') * -1); + background-color: tokens.get('button-segmented-enabled-bg'); + border-radius: tokens.get('button-segmented-horizontal-border-radius'); + box-shadow: tokens.get('button-segmented-elevation'); + + label { + flex: 0 1 100%; + display: flex; + justify-content: center; + align-items: center; + position: relative; + z-index: 2; + padding-inline: tokens.get('button-segmented-padding-inline'); + height: tokens.get('button-segmented-elements-height'); + border-radius: inherit; + cursor: pointer; + font-weight: tokens.get('button-segmented-font-weight'); + color: tokens.get('button-segmented-enabled-fg'); + text-align: center; + line-height: 1.2; + overflow-wrap: anywhere; + + input { + appearance: none !important; + user-select: none; + pointer-events: none; + position: absolute; + inset: 0; + margin: 0; + padding: 0; + border: tokens.get('button-segmented-border-width') solid transparent; + border-radius: inherit; + @include utilities.focus-style(); + } + + &:last-of-type { + z-index: 1; + } + + &:not(:last-of-type) { + &:hover { + z-index: 3; + } + } + + &:hover { + color: tokens.get('button-segmented-hover-fg'); + + input { + border-color: tokens.get('button-segmented-hover-border'); + } + } + + &:has(input:checked) { + color: tokens.get('button-segmented-selected-fg'); + + ~ :last-of-type, + &:last-of-type { + &::after { + display: block; + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background-color: tokens.get('button-segmented-selected-bg'); + border: tokens.get('button-segmented-border-width') solid + tokens.get('button-segmented-selected-border'); + border-radius: inherit; + transition: transform 0.4s cubic-bezier(0.25, 1.4, 0.5, 0.9); + } + } + + &:hover { + input { + border-color: tokens.get('button-segmented-selected-border'); + } + } + } + + @for $i from 1 through $post-segmented-button-max-count { + &:nth-last-of-type(#{$i + 1}):has(input:checked) ~ label:last-of-type::after { + transform: translateX(calc($i * -100% - $i * tokens.get('button-segmented-gap-inline'))); + } + } + } +} + +@container post-segmented-container (max-width: 600px) { + .segmented-button { + flex-direction: column; + border-radius: tokens.get('button-segmented-vertical-border-radius'); + + label { + flex: 1 0 auto; + border-radius: 0; + + &:first-of-type { + border-top-left-radius: inherit; + border-top-right-radius: inherit; + + &:has(input:checked) ~ :last-of-type::after { + border-top-left-radius: tokens.get('button-segmented-vertical-border-radius'); + border-top-right-radius: tokens.get('button-segmented-vertical-border-radius'); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + + &:last-of-type { + border-bottom-left-radius: inherit; + border-bottom-right-radius: inherit; + + &::after { + border-radius: 0; + } + + &:has(input:checked)::after { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: tokens.get('button-segmented-vertical-border-radius'); + border-bottom-right-radius: tokens.get('button-segmented-vertical-border-radius'); + } + } + + &:not(:first-of-type, :last-of-type):has(input:checked) ~ :last-of-type::after { + border-radius: 0; + } + + input { + border-top-color: tokens.get('button-segmented-enabled-border'); + } + + &:not(:first-of-type) { + input { + top: calc(tokens.get('button-segmented-border-width') * -1); + } + + &:has(input:checked) input { + top: calc(tokens.get('button-segmented-border-width') * -0.5); + } + } + + &:not(:last-of-type) { + input { + bottom: calc(tokens.get('button-segmented-gap-inline') * -1); + } + } + + &:hover { + input { + border-color: tokens.get('button-segmented-hover-border'); + } + } + + @for $i from 1 through $post-segmented-button-max-count { + &:nth-last-of-type(#{$i + 1}):has(input:checked) ~ label:last-of-type::after { + transform: translateY(calc($i * -100% - $i * tokens.get('button-segmented-gap-inline'))); + } + } + } + } +}