From 79601d2f28dbfff571cbe38b33d3c875ab3308a8 Mon Sep 17 00:00:00 2001 From: Tommaso Menga Date: Thu, 15 Aug 2024 18:15:35 +0200 Subject: [PATCH] feat(sbb-teaser-product): initial implementation (#2976) --------- Co-authored-by: Davide Mininni Co-authored-by: Jeremias Peier --- src/elements/teaser-product.ts | 3 + src/elements/teaser-product/common.ts | 3 + .../common/teaser-product-common.scss | 157 ++++++++++++++++++ .../common/teaser-product-common.ts | 50 ++++++ .../teaser-product/teaser-product-static.ts | 1 + ...easer-product-static.snapshot.spec.snap.js | 90 ++++++++++ .../teaser-product-static/readme.md | 87 ++++++++++ .../teaser-product-static.snapshot.spec.ts | 36 ++++ .../teaser-product-static.spec.ts | 28 ++++ .../teaser-product-static.ssr.spec.ts | 34 ++++ .../teaser-product-static.stories.ts | 139 ++++++++++++++++ .../teaser-product-static.ts | 28 ++++ .../teaser-product-static.visual.spec.ts | 128 ++++++++++++++ src/elements/teaser-product/teaser-product.ts | 1 + .../teaser-product.snapshot.spec.snap.js | 88 ++++++++++ .../teaser-product/teaser-product/readme.md | 94 +++++++++++ .../teaser-product/teaser-product.scss | 43 +++++ .../teaser-product.snapshot.spec.ts | 36 ++++ .../teaser-product/teaser-product.spec.ts | 32 ++++ .../teaser-product/teaser-product.ssr.spec.ts | 34 ++++ .../teaser-product/teaser-product.stories.ts | 156 +++++++++++++++++ .../teaser-product/teaser-product.ts | 30 ++++ .../teaser-product.visual.spec.ts | 128 ++++++++++++++ src/elements/teaser/readme.md | 2 +- 24 files changed, 1427 insertions(+), 1 deletion(-) create mode 100644 src/elements/teaser-product.ts create mode 100644 src/elements/teaser-product/common.ts create mode 100644 src/elements/teaser-product/common/teaser-product-common.scss create mode 100644 src/elements/teaser-product/common/teaser-product-common.ts create mode 100644 src/elements/teaser-product/teaser-product-static.ts create mode 100644 src/elements/teaser-product/teaser-product-static/__snapshots__/teaser-product-static.snapshot.spec.snap.js create mode 100644 src/elements/teaser-product/teaser-product-static/readme.md create mode 100644 src/elements/teaser-product/teaser-product-static/teaser-product-static.snapshot.spec.ts create mode 100644 src/elements/teaser-product/teaser-product-static/teaser-product-static.spec.ts create mode 100644 src/elements/teaser-product/teaser-product-static/teaser-product-static.ssr.spec.ts create mode 100644 src/elements/teaser-product/teaser-product-static/teaser-product-static.stories.ts create mode 100644 src/elements/teaser-product/teaser-product-static/teaser-product-static.ts create mode 100644 src/elements/teaser-product/teaser-product-static/teaser-product-static.visual.spec.ts create mode 100644 src/elements/teaser-product/teaser-product.ts create mode 100644 src/elements/teaser-product/teaser-product/__snapshots__/teaser-product.snapshot.spec.snap.js create mode 100644 src/elements/teaser-product/teaser-product/readme.md create mode 100644 src/elements/teaser-product/teaser-product/teaser-product.scss create mode 100644 src/elements/teaser-product/teaser-product/teaser-product.snapshot.spec.ts create mode 100644 src/elements/teaser-product/teaser-product/teaser-product.spec.ts create mode 100644 src/elements/teaser-product/teaser-product/teaser-product.ssr.spec.ts create mode 100644 src/elements/teaser-product/teaser-product/teaser-product.stories.ts create mode 100644 src/elements/teaser-product/teaser-product/teaser-product.ts create mode 100644 src/elements/teaser-product/teaser-product/teaser-product.visual.spec.ts diff --git a/src/elements/teaser-product.ts b/src/elements/teaser-product.ts new file mode 100644 index 0000000000..a755584cde --- /dev/null +++ b/src/elements/teaser-product.ts @@ -0,0 +1,3 @@ +export * from './teaser-product/teaser-product.js'; +export * from './teaser-product/teaser-product-static.js'; +export * from './teaser-product/common.js'; diff --git a/src/elements/teaser-product/common.ts b/src/elements/teaser-product/common.ts new file mode 100644 index 0000000000..dcd7a0fed2 --- /dev/null +++ b/src/elements/teaser-product/common.ts @@ -0,0 +1,3 @@ +export * from './common/teaser-product-common.js'; + +export { default as teaserProductCommonStyle } from './common/teaser-product-common.scss?lit&inline'; diff --git a/src/elements/teaser-product/common/teaser-product-common.scss b/src/elements/teaser-product/common/teaser-product-common.scss new file mode 100644 index 0000000000..a0755aa13e --- /dev/null +++ b/src/elements/teaser-product/common/teaser-product-common.scss @@ -0,0 +1,157 @@ +@use '../../core/styles/index' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + display: block; + + --sbb-teaser-product-background-color: var(--sbb-color-cloud); + --sbb-teaser-product-background-gradient-direction: to right; + --sbb-teaser-product-background: var(--sbb-teaser-product-background-color); + --sbb-teaser-product-border-radius: var(--sbb-border-radius-4x); + --sbb-teaser-product-content-color: var(--sbb-color-iron); + --sbb-teaser-product-footer-color: var(--sbb-color-anthracite); + --sbb-teaser-product-container-padding-block: var(--sbb-spacing-responsive-l); + --sbb-teaser-product-min-height: #{sbb.px-to-rem-build(600)}; + --sbb-teaser-product-background-gradient-start: 25%; + --sbb-teaser-product-background-gradient-end: 75%; + + @include sbb.mq($from: large) { + --sbb-teaser-product-background: linear-gradient( + var(--sbb-teaser-product-background-gradient-direction), + var(--sbb-teaser-product-background-color) var(--sbb-teaser-product-background-gradient-start), + transparent var(--sbb-teaser-product-background-gradient-end) + ); + } +} + +:host([negative]) { + --sbb-teaser-product-background-color: var(--sbb-color-midnight); + --sbb-teaser-product-content-color: var(--sbb-color-cloud); + --sbb-teaser-product-footer-color: var(--sbb-color-cloud); + --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); + --sbb-title-text-color-normal-override: var(--sbb-color-milk); +} + +:host([image-alignment='before']) { + --sbb-teaser-product-background-gradient-direction: to left; +} + +.sbb-teaser-product__image-container { + display: block; + overflow: hidden; + + // We have to remove the image bottom border-radius when stacked + border-radius: var(--sbb-teaser-product-border-radius) var(--sbb-teaser-product-border-radius) 0 0; + + @include sbb.mq($from: large) { + position: absolute; + inset: 0; + border-radius: var(--sbb-teaser-product-border-radius); + } +} + +::slotted(img) { + display: flex; + width: 100%; + height: 100%; + object-fit: cover; + aspect-ratio: 16 / 9; +} + +// Reset sbb-image border radius in order to control it from teaser product. +::slotted(sbb-image) { + --sbb-image-border-radius: 0; + + height: 100%; +} + +::slotted(p.sbb-teaser-product--spacing) { + margin: 0; +} + +::slotted(sbb-title.sbb-teaser-product--spacing) { + --sbb-title-margin-block-start: 0; +} + +::slotted(:is(sbb-action-group, [data-action]).sbb-teaser-product--spacing) { + margin-block-start: var(--sbb-spacing-responsive-xxs); +} + +.sbb-action-base { + display: block; + position: relative; + text-decoration: none; + + @include sbb.if-forced-colors { + // Apply a visual border for forced color mode + &::after { + content: ''; + position: absolute; + display: block; + inset: 0; + pointer-events: none; + border: var(--sbb-border-width-2x) solid CanvasText; + border-radius: var(--sbb-teaser-product-border-radius); + } + } +} + +.sbb-teaser-product__container { + display: block; + background: var(--sbb-teaser-product-background); + border-radius: 0 0 var(--sbb-teaser-product-border-radius) var(--sbb-teaser-product-border-radius); + padding: var(--sbb-spacing-responsive-s); + + @include sbb.mq($from: large) { + display: grid; + grid: + 'content .' 1fr + 'footnote .' auto / 1fr 1fr; + column-gap: var(--sbb-spacing-responsive-xxl); + background: var(--sbb-teaser-product-background); + border-radius: var(--sbb-teaser-product-border-radius); + padding-block: var(--sbb-teaser-product-container-padding-block) 0; + padding-inline: var(--sbb-spacing-responsive-xl); + position: relative; + + :host([image-alignment='before']) & { + grid-template-areas: + '. content' + '. footnote'; + } + } +} + +.sbb-teaser-product__content { + grid-area: content; + align-self: center; + margin: 0; + color: var(--sbb-teaser-product-content-color); + + @include sbb.mq($from: large) { + align-content: center; + min-height: calc( + var(--sbb-teaser-product-min-height) - 2 * var(--sbb-teaser-product-container-padding-block) + ); + } +} + +.sbb-teaser-product__footnote { + grid-area: footnote; + display: block; + padding-block-start: var(--sbb-spacing-responsive-s); + color: var(--sbb-teaser-product-footer-color); + + @include sbb.text-xxs--regular; + + :host(:not([data-slot-names~='footnote'])) & { + padding-block-start: 0; + } + + @include sbb.mq($from: large) { + min-height: var(--sbb-teaser-product-container-padding-block); + padding-block: var(--sbb-spacing-responsive-xs); + } +} diff --git a/src/elements/teaser-product/common/teaser-product-common.ts b/src/elements/teaser-product/common/teaser-product-common.ts new file mode 100644 index 0000000000..c63ffde38f --- /dev/null +++ b/src/elements/teaser-product/common/teaser-product-common.ts @@ -0,0 +1,50 @@ +import { html, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { SbbActionBaseElement } from '../../core/base-elements.js'; +import { slotState } from '../../core/decorators.js'; +import { + SbbNegativeMixin, + type SbbNegativeMixinType, + type AbstractConstructor, +} from '../../core/mixins.js'; + +export declare class SbbTeaserProductCommonElementMixinType extends SbbNegativeMixinType { + public imageAlignment?: 'after' | 'before'; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const SbbTeaserProductCommonElementMixin = < + T extends AbstractConstructor, +>( + superClass: T, +): AbstractConstructor & T => { + @slotState() + abstract class SbbTeaserProductCommonElement + extends SbbNegativeMixin(superClass) + implements SbbTeaserProductCommonElementMixinType + { + /** + * Whether the fully visible part of the image is aligned 'before' or 'after' the content. + * Only relevant starting from large breakpoint. + */ + @property({ attribute: 'image-alignment', reflect: true }) + public imageAlignment: 'after' | 'before' = 'after'; + + protected override renderTemplate(): TemplateResult { + return html` + + + + + + + + + + `; + } + } + return SbbTeaserProductCommonElement as AbstractConstructor & + T; +}; diff --git a/src/elements/teaser-product/teaser-product-static.ts b/src/elements/teaser-product/teaser-product-static.ts new file mode 100644 index 0000000000..e06c2fa01c --- /dev/null +++ b/src/elements/teaser-product/teaser-product-static.ts @@ -0,0 +1 @@ +export * from './teaser-product-static/teaser-product-static.js'; diff --git a/src/elements/teaser-product/teaser-product-static/__snapshots__/teaser-product-static.snapshot.spec.snap.js b/src/elements/teaser-product/teaser-product-static/__snapshots__/teaser-product-static.snapshot.spec.snap.js new file mode 100644 index 0000000000..26b3dcd212 --- /dev/null +++ b/src/elements/teaser-product/teaser-product-static/__snapshots__/teaser-product-static.snapshot.spec.snap.js @@ -0,0 +1,90 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-teaser-product-static renders DOM"] = +` + + +

+ Content +

+

+ Footnote +

+
+`; +/* end snapshot sbb-teaser-product-static renders DOM */ + +snapshots["sbb-teaser-product-static renders Shadow DOM"] = +` + + + + + + + + + + + + + + + +`; +/* end snapshot sbb-teaser-product-static renders Shadow DOM */ + +snapshots["sbb-teaser-product-static renders A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "text", + "name": "Content" + }, + { + "role": "text", + "name": "Footnote" + } + ] +} +

+`; +/* end snapshot sbb-teaser-product-static renders A11y tree Chrome */ + +snapshots["sbb-teaser-product-static renders A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "text leaf", + "name": "Content" + }, + { + "role": "text leaf", + "name": "Footnote" + } + ] +} +

+`; +/* end snapshot sbb-teaser-product-static renders A11y tree Firefox */ + diff --git a/src/elements/teaser-product/teaser-product-static/readme.md b/src/elements/teaser-product/teaser-product-static/readme.md new file mode 100644 index 0000000000..554f896ea4 --- /dev/null +++ b/src/elements/teaser-product/teaser-product-static/readme.md @@ -0,0 +1,87 @@ +The `sbb-teaser-product-static` is a component that can display a text and a footnote, +combined with an image as background, to tease a product. +It should be used if there is more than one interactive action, +otherwise, see [sbb-teaser-product](/docs/elements-sbb-teaser-sbb-teaser-product--docs). + +```html + + + +

Content ...

+ +

...

+
+``` + +## Slots + +Use the `image` slot to pass a `sbb-image` or an `img` that will be used as a background, +and use the optional `footnote` slot to add a text anchored to the bottom-end of the component. + +The default slot is reserved for the main content: it could be a simple text or a text combined with more elements, +like a `sbb-title` or some interactive elements, like buttons or links within the `sbb-action-group` component. + +```html + + +

Content ...

+
+``` + +If paragraphs, title and/or button are used, consumers can apply the helper class `sbb-teaser-product--spacing` +to display the components with the correct spacings. + +```html + + + + Benefit from up to 70% discount + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit amet felis + viverra lacinia. Donec et enim mi. Aliquam erat volutpat. Proin ut odio tellus. +

+ + Label + Label + +
+``` + +## Style + +Use the `image-alignment` attribute to anchor the content `after` (on the left) or `before` (on the right). + +```html + ... +``` + +Add the `negative` attribute to enable the negative variant. + +```html + ... +``` + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------------- | ----------------- | ------- | --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `imageAlignment` | `image-alignment` | public | `'after' \| 'before'` | `'after'` | Whether the fully visible part of the image is aligned 'before' or 'after' the content. Only relevant starting from large breakpoint. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | + +## CSS Properties + +| Name | Default | Description | +| ------------------------------------------------ | ------- | -------------------------------------------------------------------- | +| `--sbb-teaser-product-background-gradient-end` | `75%` | At which percentage the background should be fully transparent. | +| `--sbb-teaser-product-background-gradient-start` | `25%` | At which percentage the background should start getting transparent. | + +## Slots + +| Name | Description | +| ---------- | ------------------------------------------------------------------- | +| | Use this slot to provide the main content. | +| `footnote` | Use this slot to provide a footnote. | +| `image` | Use this slot to provide an image or a `sbb-image` as a background. | diff --git a/src/elements/teaser-product/teaser-product-static/teaser-product-static.snapshot.spec.ts b/src/elements/teaser-product/teaser-product-static/teaser-product-static.snapshot.spec.ts new file mode 100644 index 0000000000..b86cf7fad8 --- /dev/null +++ b/src/elements/teaser-product/teaser-product-static/teaser-product-static.snapshot.spec.ts @@ -0,0 +1,36 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbTeaserProductStaticElement } from './teaser-product-static.js'; +import './teaser-product-static.js'; +import '../../image.js'; + +const imageUrl = import.meta.resolve('../../core/testing/assets/placeholder-image.png'); + +describe(`sbb-teaser-product-static`, () => { + describe('renders', () => { + let element: SbbTeaserProductStaticElement; + + beforeEach(async () => { + element = await fixture(html` + + +

Content

+

Footnote

+
+ `); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); +}); diff --git a/src/elements/teaser-product/teaser-product-static/teaser-product-static.spec.ts b/src/elements/teaser-product/teaser-product-static/teaser-product-static.spec.ts new file mode 100644 index 0000000000..95fc01af24 --- /dev/null +++ b/src/elements/teaser-product/teaser-product-static/teaser-product-static.spec.ts @@ -0,0 +1,28 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbTeaserProductStaticElement } from './teaser-product-static.js'; + +import '../../image.js'; + +const imageUrl = import.meta.resolve('../../core/testing/assets/placeholder-image.png'); + +describe('sbb-teaser-product-static', () => { + let element: SbbTeaserProductStaticElement; + + beforeEach(async () => { + element = await fixture(html` + + +

Content

+

Footnote

+
+ `); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbTeaserProductStaticElement); + }); +}); diff --git a/src/elements/teaser-product/teaser-product-static/teaser-product-static.ssr.spec.ts b/src/elements/teaser-product/teaser-product-static/teaser-product-static.ssr.spec.ts new file mode 100644 index 0000000000..9e943fdd5b --- /dev/null +++ b/src/elements/teaser-product/teaser-product-static/teaser-product-static.ssr.spec.ts @@ -0,0 +1,34 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { ssrHydratedFixture } from '../../core/testing/private.js'; + +import { SbbTeaserProductStaticElement } from './teaser-product-static.js'; +import '../../image.js'; + +const imageUrl = import.meta.resolve('../../core/testing/assets/placeholder-image.png'); + +describe(`sbb-teaser-product-static ssr`, () => { + describe('renders', () => { + let root: SbbTeaserProductStaticElement; + + beforeEach(async () => { + root = await ssrHydratedFixture( + html` + + +

Content

+

Footnote

+
+ `, + { + modules: ['./teaser-product-static.js', '../../image.js'], + }, + ); + }); + + it('renders', () => { + assert.instanceOf(root, SbbTeaserProductStaticElement); + }); + }); +}); diff --git a/src/elements/teaser-product/teaser-product-static/teaser-product-static.stories.ts b/src/elements/teaser-product/teaser-product-static/teaser-product-static.stories.ts new file mode 100644 index 0000000000..d16eb4b64f --- /dev/null +++ b/src/elements/teaser-product/teaser-product-static/teaser-product-static.stories.ts @@ -0,0 +1,139 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { + Args, + ArgTypes, + Decorator, + Meta, + StoryContext, + StoryObj, +} from '@storybook/web-components'; +import { nothing, type TemplateResult } from 'lit'; +import { html } from 'lit'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; +import sampleImages from '../../core/images.js'; + +import readme from './readme.md?raw'; +import './teaser-product-static.js'; +import '../../action-group.js'; +import '../../button/button.js'; +import '../../button/secondary-button.js'; +import '../../image.js'; +import '../../title.js'; + +const imageAlignment: InputType = { + control: { + type: 'inline-radio', + }, + options: ['after', 'before'], +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const withFooter: InputType = { + control: { + type: 'boolean', + }, +}; + +const slottedImg: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + 'image-alignment': imageAlignment, + negative, + withFooter, + slottedImg, +}; + +const defaultArgs: Args = { + 'image-alignment': imageAlignment.options![0], + negative: false, + withFooter: true, + slottedImg: false, +}; + +const content = (): TemplateResult => html` + + Benefit from up to 70% discount + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit amet felis + viverra lacinia. Donec et enim mi. Aliquam erat volutpat. Proin ut odio tellus. Donec tempor mi + vel dapibus lobortis. Sed at ex sit amet leo suscipit fermentum. Donec consequat hendrerit + tortor, ut laoreet velit congue in. +

+ + Label + Label + +`; + +const footer = (): TemplateResult => html` +

+ Footnote Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit + amet felis viverra lacinia. Donec et enim mi. Aliquam erat volutpat. Proin ut odio tellus. Donec + tempor mi vel dapibus lobortis. +

+`; + +const Template = ({ withFooter, slottedImg, ...args }: Args): TemplateResult => html` + + ${slottedImg + ? html`` + : html``} + ${content()} ${withFooter ? footer() : nothing} + +`; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Negative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true }, +}; + +export const ImageBefore: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'image-alignment': imageAlignment.options![1] }, +}; + +export const NoFooter: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, withFooter: false }, +}; + +export const SlottedImg: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, slottedImg: true }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-teaser/sbb-teaser-product-static', +}; + +export default meta; diff --git a/src/elements/teaser-product/teaser-product-static/teaser-product-static.ts b/src/elements/teaser-product/teaser-product-static/teaser-product-static.ts new file mode 100644 index 0000000000..8e294013a1 --- /dev/null +++ b/src/elements/teaser-product/teaser-product-static/teaser-product-static.ts @@ -0,0 +1,28 @@ +import type { CSSResultGroup } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { SbbActionBaseElement } from '../../core/base-elements.js'; +import { SbbTeaserProductCommonElementMixin, teaserProductCommonStyle } from '../common.js'; + +/** + * Displays a text and a footnote, combined with an image, to tease a product. + * + * @slot - Use this slot to provide the main content. + * @slot image - Use this slot to provide an image or a `sbb-image` as a background. + * @slot footnote - Use this slot to provide a footnote. + * @cssprop [--sbb-teaser-product-background-gradient-start=25%] - At which percentage the background should start getting transparent. + * @cssprop [--sbb-teaser-product-background-gradient-end=75%] - At which percentage the background should be fully transparent. + */ +@customElement('sbb-teaser-product-static') +export class SbbTeaserProductStaticElement extends SbbTeaserProductCommonElementMixin( + SbbActionBaseElement, +) { + public static override styles: CSSResultGroup = [teaserProductCommonStyle]; +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-teaser-product-static': SbbTeaserProductStaticElement; + } +} diff --git a/src/elements/teaser-product/teaser-product-static/teaser-product-static.visual.spec.ts b/src/elements/teaser-product/teaser-product-static/teaser-product-static.visual.spec.ts new file mode 100644 index 0000000000..3172c79db7 --- /dev/null +++ b/src/elements/teaser-product/teaser-product-static/teaser-product-static.visual.spec.ts @@ -0,0 +1,128 @@ +import { html, nothing, type TemplateResult } from 'lit'; + +import { + describeViewports, + loadAssetAsBase64, + visualDiffDefault, + visualDiffFocus, +} from '../../core/testing/private.js'; +import { waitForImageReady } from '../../core/testing/wait-for-image-ready.js'; + +import './teaser-product-static.js'; +import '../../action-group.js'; +import '../../button/button.js'; +import '../../button/secondary-button.js'; +import '../../image.js'; +import '../../title.js'; + +const imageUrl = import.meta.resolve('../../core/testing/assets/placeholder-image.png'); +const imageBase64 = await loadAssetAsBase64(imageUrl); + +const content = (longContent = false): TemplateResult => html` + + Benefit from up to 70% discount + +

+ ${new Array(longContent ? 6 : 1) + .fill('') + .map( + () => + html`Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit + amet felis viverra lacinia.`, + )} +

+ + Label + Label + +`; + +const footer = (): TemplateResult => html` +

+ Footnote Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit + amet felis viverra lacinia. +

+`; + +const template = ({ + negative, + imageAlignment, + showFooter, + slottedImg, + longContent, +}: { + negative?: boolean; + imageAlignment?: string; + showFooter?: boolean; + slottedImg?: boolean; + longContent?: boolean; +} = {}): TemplateResult => html` + + ${slottedImg + ? html`` + : html``} + ${content(longContent)} ${showFooter ? footer() : nothing} + +`; + +describe('sbb-teaser-product-static', () => { + describeViewports({ viewports: ['zero', 'medium', 'large'] }, () => { + for (const slottedImg of [false, true]) { + describe(`slottedImg=${slottedImg}`, () => { + for (const negative of [false, true]) { + describe(`negative=${negative}`, () => { + for (const visualState of [visualDiffDefault, visualDiffFocus]) { + it( + visualState.name, + visualState.with(async (setup) => { + await setup.withFixture(template({ negative, showFooter: true, slottedImg }), { + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }); + await waitForImageReady( + setup.snapshotElement.querySelector(slottedImg ? 'img' : 'sbb-image')!, + ); + }), + ); + } + }); + } + + it( + `imageAlignment=before`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ imageAlignment: 'before', showFooter: true, slottedImg }), + ); + await waitForImageReady( + setup.snapshotElement.querySelector(slottedImg ? 'img' : 'sbb-image')!, + ); + }), + ); + }); + } + + it( + 'no footer', + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template()); + await waitForImageReady(setup.snapshotElement.querySelector('sbb-image')!); + }), + ); + + it( + 'long content', + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ longContent: true, showFooter: true })); + await waitForImageReady(setup.snapshotElement.querySelector('sbb-image')!); + }), + ); + + it( + 'forcedColors=true', + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ showFooter: true }), { forcedColors: true }); + await waitForImageReady(setup.snapshotElement.querySelector('sbb-image')!); + }), + ); + }); +}); diff --git a/src/elements/teaser-product/teaser-product.ts b/src/elements/teaser-product/teaser-product.ts new file mode 100644 index 0000000000..72a08265c6 --- /dev/null +++ b/src/elements/teaser-product/teaser-product.ts @@ -0,0 +1 @@ +export * from './teaser-product/teaser-product.js'; diff --git a/src/elements/teaser-product/teaser-product/__snapshots__/teaser-product.snapshot.spec.snap.js b/src/elements/teaser-product/teaser-product/__snapshots__/teaser-product.snapshot.spec.snap.js new file mode 100644 index 0000000000..a4eda8ede7 --- /dev/null +++ b/src/elements/teaser-product/teaser-product/__snapshots__/teaser-product.snapshot.spec.snap.js @@ -0,0 +1,88 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-teaser-product renders DOM"] = +` + + +

+ Content +

+

+ Footnote +

+
+`; +/* end snapshot sbb-teaser-product renders DOM */ + +snapshots["sbb-teaser-product renders Shadow DOM"] = +` + + + + + + + + + + + + + + + +`; +/* end snapshot sbb-teaser-product renders Shadow DOM */ + +snapshots["sbb-teaser-product renders A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "link", + "name": "Content Footnote", + "value": "https://www.sbb.ch/" + } + ] +} +

+`; +/* end snapshot sbb-teaser-product renders A11y tree Firefox */ + +snapshots["sbb-teaser-product renders A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "link", + "name": "Content Footnote" + } + ] +} +

+`; +/* end snapshot sbb-teaser-product renders A11y tree Chrome */ + diff --git a/src/elements/teaser-product/teaser-product/readme.md b/src/elements/teaser-product/teaser-product/readme.md new file mode 100644 index 0000000000..a9ee3561b8 --- /dev/null +++ b/src/elements/teaser-product/teaser-product/readme.md @@ -0,0 +1,94 @@ +The `sbb-teaser-product` is a component that can display a text and a footnote, combined with an image as background, to tease a product. +The whole component behaves like a link, and it is clickable; on small screens, the content follows the image. + +The component can have at most a single interactive element in its static version (e.g. `sbb-button-static`). +If it has to include more than one interactive element, use the [sbb-teaser-product-static](docs/elements-sbb-teaser-sbb-teaser-product-static) instead. + +```html + + + +

Content ...

+ +

...

+
+``` + +## Slots + +Use the `image` slot to pass a `sbb-image` or an `img` that will be used as a background, +and use the optional `footnote` slot to add a text anchored to the bottom-end of the component. + +The default slot is reserved for the main content: it could be a simple text or a text combined with more elements, +like the `sbb-title` or an interactive element, like a button or a link (needs to be in static variant!). + +```html + + +

Content ...

+
+``` + +If paragraphs, title and/or button are used, consumers can apply the helper class `sbb-teaser-product--spacing` +to display the components with the correct spacings. + +```html + + + + Benefit from up to 70% discount + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit amet felis + viverra lacinia. Donec et enim mi. Aliquam erat volutpat. Proin ut odio tellus. +

+ Label +
+``` + +## Style + +Use the `image-alignment` attribute to anchor the content `after` (on the left) or `before` (on the right). + +```html + ... +``` + +Add the `negative` attribute to enable the negative variant. + +```html + ... +``` + +## Accessibility + +It's important to set the `accessibilityLabel` on the ``, which describes the link for screen-reader users. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| -------------------- | --------------------- | ------- | --------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `accessibilityLabel` | `accessibility-label` | public | `string \| undefined` | | This will be forwarded as aria-label to the inner anchor element. | +| `download` | `download` | public | `boolean \| undefined` | | Whether the browser will show the download dialog on click. | +| `href` | `href` | public | `string \| undefined` | | The href value you want to link to. | +| `imageAlignment` | `image-alignment` | public | `'after' \| 'before'` | `'after'` | Whether the fully visible part of the image is aligned 'before' or 'after' the content. Only relevant starting from large breakpoint. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `rel` | `rel` | public | `string \| undefined` | | The relationship of the linked URL as space-separated link types. | +| `target` | `target` | public | `LinkTargetType \| string \| undefined` | | Where to display the linked URL. | + +## CSS Properties + +| Name | Default | Description | +| ------------------------------------------------ | ------- | -------------------------------------------------------------------- | +| `--sbb-teaser-product-background-gradient-end` | `75%` | At which percentage the background should be fully transparent. | +| `--sbb-teaser-product-background-gradient-start` | `25%` | At which percentage the background should start getting transparent. | + +## Slots + +| Name | Description | +| ---------- | ------------------------------------------------------------------- | +| | Use this slot to provide the main content. | +| `footnote` | Use this slot to provide a footnote. | +| `image` | Use this slot to provide an image or a `sbb-image` as a background. | diff --git a/src/elements/teaser-product/teaser-product/teaser-product.scss b/src/elements/teaser-product/teaser-product/teaser-product.scss new file mode 100644 index 0000000000..cef59ac7fe --- /dev/null +++ b/src/elements/teaser-product/teaser-product/teaser-product.scss @@ -0,0 +1,43 @@ +@use '../../core/styles/index' as sbb; + +:host { + --sbb-teaser-product-brightness-hover: 1.075; + --sbb-teaser-product-animation-duration: var( + --sbb-disable-animation-zero-time, + var(--sbb-animation-duration-4x) + ); + --sbb-teaser-product-animation-easing: var(--sbb-animation-easing); + --sbb-teaser-product-border-radius: var(--sbb-border-radius-4x); +} + +:host(:hover) { + @include sbb.hover-mq($hover: true) { + --sbb-teaser-product-brightness: var(--sbb-teaser-product-brightness-hover); + } +} + +.sbb-teaser-product { + &:focus-visible { + :host(:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) & { + @include sbb.focus-outline; + + border-radius: var(--sbb-teaser-product-border-radius); + } + } + + @include sbb.if-forced-colors { + &::after { + :host(:hover) & { + border-color: Highlight; + } + } + } +} + +::slotted(:is(img, sbb-image)) { + will-change: filter; + transition-property: filter; + transition-duration: var(--sbb-teaser-product-animation-duration); + transition-timing-function: var(--sbb-animation-easing); + filter: brightness(var(--sbb-teaser-product-brightness, 1)); +} diff --git a/src/elements/teaser-product/teaser-product/teaser-product.snapshot.spec.ts b/src/elements/teaser-product/teaser-product/teaser-product.snapshot.spec.ts new file mode 100644 index 0000000000..a82c1b83e8 --- /dev/null +++ b/src/elements/teaser-product/teaser-product/teaser-product.snapshot.spec.ts @@ -0,0 +1,36 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbTeaserProductElement } from './teaser-product.js'; +import './teaser-product.js'; +import '../../image.js'; + +const imageUrl = import.meta.resolve('../../core/testing/assets/placeholder-image.png'); + +describe(`sbb-teaser-product`, () => { + describe('renders', () => { + let element: SbbTeaserProductElement; + + beforeEach(async () => { + element = await fixture(html` + + +

Content

+

Footnote

+
+ `); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); +}); diff --git a/src/elements/teaser-product/teaser-product/teaser-product.spec.ts b/src/elements/teaser-product/teaser-product/teaser-product.spec.ts new file mode 100644 index 0000000000..d2504d5d82 --- /dev/null +++ b/src/elements/teaser-product/teaser-product/teaser-product.spec.ts @@ -0,0 +1,32 @@ +import { assert, expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbTeaserProductElement } from './teaser-product.js'; +import '../../image.js'; + +const imageUrl = import.meta.resolve('../../core/testing/assets/placeholder-image.png'); + +describe('sbb-teaser-product', () => { + let element: SbbTeaserProductElement; + + beforeEach(async () => { + element = await fixture(html` + + +

Content

+

Footnote

+
+ `); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbTeaserProductElement); + }); + + it('should receive focus', async () => { + element.focus(); + expect(document.activeElement!.localName).to.be.equal('sbb-teaser-product'); + }); +}); diff --git a/src/elements/teaser-product/teaser-product/teaser-product.ssr.spec.ts b/src/elements/teaser-product/teaser-product/teaser-product.ssr.spec.ts new file mode 100644 index 0000000000..ff856d0b06 --- /dev/null +++ b/src/elements/teaser-product/teaser-product/teaser-product.ssr.spec.ts @@ -0,0 +1,34 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { ssrHydratedFixture } from '../../core/testing/private.js'; + +import { SbbTeaserProductElement } from './teaser-product.js'; +import '../../image.js'; + +const imageUrl = import.meta.resolve('../../core/testing/assets/placeholder-image.png'); + +describe(`sbb-teaser-product ssr`, () => { + describe('renders', () => { + let root: SbbTeaserProductElement; + + beforeEach(async () => { + root = await ssrHydratedFixture( + html` + + +

Content

+

Footnote

+
+ `, + { + modules: ['./teaser-product.js', '../../image.js'], + }, + ); + }); + + it('renders', () => { + assert.instanceOf(root, SbbTeaserProductElement); + }); + }); +}); diff --git a/src/elements/teaser-product/teaser-product/teaser-product.stories.ts b/src/elements/teaser-product/teaser-product/teaser-product.stories.ts new file mode 100644 index 0000000000..b17659f9c1 --- /dev/null +++ b/src/elements/teaser-product/teaser-product/teaser-product.stories.ts @@ -0,0 +1,156 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { + Args, + ArgTypes, + Decorator, + Meta, + StoryContext, + StoryObj, +} from '@storybook/web-components'; +import { nothing, type TemplateResult } from 'lit'; +import { html } from 'lit'; + +import { sbbSpread } from '../../../storybook/helpers/spread.js'; +import sampleImages from '../../core/images.js'; + +import readme from './readme.md?raw'; +import './teaser-product.js'; +import '../../button/button-static.js'; +import '../../image.js'; +import '../../title.js'; + +const imageAlignment: InputType = { + control: { + type: 'inline-radio', + }, + options: ['after', 'before'], +}; + +const negative: InputType = { + control: { + type: 'boolean', + }, +}; + +const withFooter: InputType = { + control: { + type: 'boolean', + }, +}; + +const slottedImg: InputType = { + control: { + type: 'boolean', + }, +}; + +const href: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Link', + }, +}; + +const accessibilityLabel: InputType = { + control: { + type: 'text', + }, + table: { + category: 'Link', + }, +}; + +const defaultArgTypes: ArgTypes = { + 'image-alignment': imageAlignment, + negative, + withFooter, + slottedImg, + href, + 'accessibility-label': accessibilityLabel, +}; + +const defaultArgs: Args = { + 'image-alignment': imageAlignment.options![0], + negative: false, + withFooter: true, + slottedImg: false, + href: 'https://www.sbb.ch', + 'accessibility-label': undefined, +}; + +const content = (): TemplateResult => html` + + Benefit from up to 70% discount + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit amet felis + viverra lacinia. Donec et enim mi. Aliquam erat volutpat. Proin ut odio tellus. Donec tempor mi + vel dapibus lobortis. Sed at ex sit amet leo suscipit fermentum. Donec consequat hendrerit + tortor, ut laoreet velit congue in. +

+ Label +`; + +const footer = (): TemplateResult => html` +

+ Footnote Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit + amet felis viverra lacinia. Donec et enim mi. Aliquam erat volutpat. Proin ut odio tellus. Donec + tempor mi vel dapibus lobortis. +

+`; + +const Template = ({ withFooter, slottedImg, ...args }: Args): TemplateResult => html` + + ${slottedImg + ? html`` + : html``} + ${content()} ${withFooter ? footer() : nothing} + +`; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Negative: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, negative: true }, +}; + +export const ImageBefore: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'image-alignment': imageAlignment.options![1] }, +}; + +export const NoFooter: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, withFooter: false }, +}; + +export const SlottedImg: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, slottedImg: true }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-teaser/sbb-teaser-product', +}; + +export default meta; diff --git a/src/elements/teaser-product/teaser-product/teaser-product.ts b/src/elements/teaser-product/teaser-product/teaser-product.ts new file mode 100644 index 0000000000..0d2973e1e9 --- /dev/null +++ b/src/elements/teaser-product/teaser-product/teaser-product.ts @@ -0,0 +1,30 @@ +import type { CSSResultGroup } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { SbbLinkBaseElement } from '../../core/base-elements.js'; +import { SbbTeaserProductCommonElementMixin, teaserProductCommonStyle } from '../common.js'; + +import style from './teaser-product.scss?lit&inline'; + +/** + * Displays a text and a footnote, combined with an image, to tease a product + * + * @slot - Use this slot to provide the main content. + * @slot image - Use this slot to provide an image or a `sbb-image` as a background. + * @slot footnote - Use this slot to provide a footnote. + * @cssprop [--sbb-teaser-product-background-gradient-start=25%] - At which percentage the background should start getting transparent. + * @cssprop [--sbb-teaser-product-background-gradient-end=75%] - At which percentage the background should be fully transparent. + */ +@customElement('sbb-teaser-product') +export class SbbTeaserProductElement extends SbbTeaserProductCommonElementMixin( + SbbLinkBaseElement, +) { + public static override styles: CSSResultGroup = [teaserProductCommonStyle, style]; +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-teaser-product': SbbTeaserProductElement; + } +} diff --git a/src/elements/teaser-product/teaser-product/teaser-product.visual.spec.ts b/src/elements/teaser-product/teaser-product/teaser-product.visual.spec.ts new file mode 100644 index 0000000000..7ddec5d1f4 --- /dev/null +++ b/src/elements/teaser-product/teaser-product/teaser-product.visual.spec.ts @@ -0,0 +1,128 @@ +import { html, nothing, type TemplateResult } from 'lit'; + +import { + describeViewports, + loadAssetAsBase64, + visualDiffDefault, + visualDiffFocus, + visualDiffHover, +} from '../../core/testing/private.js'; +import { waitForImageReady } from '../../core/testing/wait-for-image-ready.js'; + +import './teaser-product.js'; +import '../../button/button-static.js'; +import '../../image.js'; +import '../../title.js'; + +const imageUrl = import.meta.resolve('../../core/testing/assets/placeholder-image.png'); +const imageBase64 = await loadAssetAsBase64(imageUrl); + +const content = (longContent = false): TemplateResult => html` + + Benefit from up to 70% discount + +

+ ${new Array(longContent ? 6 : 1) + .fill('') + .map( + () => + html`Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit + amet felis viverra lacinia.`, + )} +

+ Label +`; + +const footer = (): TemplateResult => html` +

+ Footnote Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pretium felis sit + amet felis viverra lacinia. +

+`; + +const template = ({ + negative, + imageAlignment, + showFooter, + slottedImg, + longContent, +}: { + negative?: boolean; + imageAlignment?: string; + showFooter?: boolean; + slottedImg?: boolean; + longContent?: boolean; +} = {}): TemplateResult => html` + + ${slottedImg + ? html`` + : html``} + ${content(longContent)} ${showFooter ? footer() : nothing} + +`; + +describe('sbb-teaser-product', () => { + describeViewports({ viewports: ['zero', 'medium', 'large'] }, () => { + for (const slottedImg of [false, true]) { + describe(`slottedImg=${slottedImg}`, () => { + for (const negative of [false, true]) { + describe(`negative=${negative}`, () => { + for (const visualState of [visualDiffDefault, visualDiffHover, visualDiffFocus]) { + it( + visualState.name, + visualState.with(async (setup) => { + await setup.withFixture(template({ negative, showFooter: true, slottedImg }), { + backgroundColor: negative ? 'var(--sbb-color-black)' : undefined, + }); + await waitForImageReady( + setup.snapshotElement.querySelector(slottedImg ? 'img' : 'sbb-image')!, + ); + }), + ); + } + }); + } + + it( + `imageAlignment=before`, + visualDiffDefault.with(async (setup) => { + await setup.withFixture( + template({ imageAlignment: 'before', showFooter: true, slottedImg }), + ); + await waitForImageReady( + setup.snapshotElement.querySelector(slottedImg ? 'img' : 'sbb-image')!, + ); + }), + ); + }); + } + + it( + 'no footer', + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template()); + await waitForImageReady(setup.snapshotElement.querySelector('sbb-image')!); + }), + ); + + it( + 'long content', + visualDiffDefault.with(async (setup) => { + await setup.withFixture(template({ longContent: true, showFooter: true })); + await waitForImageReady(setup.snapshotElement.querySelector('sbb-image')!); + }), + ); + + describe('forcedColors=true', () => { + for (const visualState of [visualDiffDefault, visualDiffHover, visualDiffFocus]) { + it( + visualState.name, + visualState.with(async (setup) => { + await setup.withFixture(template({ showFooter: true }), { forcedColors: true }); + await waitForImageReady(setup.snapshotElement.querySelector('sbb-image')!); + }), + ); + } + }); + }); +}); diff --git a/src/elements/teaser/readme.md b/src/elements/teaser/readme.md index 6fbd280b8d..d81da225bb 100644 --- a/src/elements/teaser/readme.md +++ b/src/elements/teaser/readme.md @@ -42,7 +42,7 @@ Default values are `300px` and `4/3`. Consumers can change these values on their ## Accessibility -It's important to set the `aria-label` on the ``, which describes the `sbb-teaser` for screen-reader users. +It's important to set the `accessibilityLabel` on the ``, which describes the `sbb-teaser` for screen-reader users. The description text is wrapped into an `

` element to guarantee the semantic meaning.