From ba5f86c44e27b55256f8ff4e064edeb379b226a6 Mon Sep 17 00:00:00 2001 From: Federico Iseppon <100614752+federicoisepponfincons@users.noreply.github.com> Date: Thu, 18 Jan 2024 17:53:38 +0100 Subject: [PATCH] feat(sbb-teaser): redesign (#2211) BREAKING CHANGE: The property `isStacked` has been removed in favor of `alignment`. Please see the documentation for further info. The `description` is not clamped to two lines anymore (responsibility of consumer). The slotted `image` has now a default width of `300px`. The slot, formerly named `description`, has been replaced by the unnamed slot. Support of nested `p` elements dropped (invalid html). Closes #1897 --------- Co-authored-by: Jeremias Peier --- .../teaser/__snapshots__/teaser.spec.snap.js | 224 ++++++++++++++++++ src/components/teaser/readme.md | 65 +++-- src/components/teaser/teaser.e2e.ts | 36 ++- src/components/teaser/teaser.scss | 36 +-- src/components/teaser/teaser.spec.ts | 140 ++++++----- src/components/teaser/teaser.stories.ts | 183 ++++++++++---- src/components/teaser/teaser.ts | 45 ++-- 7 files changed, 557 insertions(+), 172 deletions(-) create mode 100644 src/components/teaser/__snapshots__/teaser.spec.snap.js diff --git a/src/components/teaser/__snapshots__/teaser.spec.snap.js b/src/components/teaser/__snapshots__/teaser.spec.snap.js new file mode 100644 index 0000000000..b911da5ed4 --- /dev/null +++ b/src/components/teaser/__snapshots__/teaser.spec.snap.js @@ -0,0 +1,224 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-teaser renders after centered - DOM"] = +` + +`; +/* end snapshot sbb-teaser renders after centered - DOM */ + +snapshots["sbb-teaser renders after centered - ShadowDOM"] = +` + + + + + + + + + + + + + + + + + + + + + +`; +/* end snapshot sbb-teaser renders after centered - ShadowDOM */ + +snapshots["sbb-teaser renders after with title level set - DOM"] = +` + +`; +/* end snapshot sbb-teaser renders after with title level set - DOM */ + +snapshots["sbb-teaser renders after with title level set - ShadowDOM"] = +` + + + + + + + + + + + + + + + + + + + + + +`; +/* end snapshot sbb-teaser renders after with title level set - ShadowDOM */ + +snapshots["sbb-teaser renders below with projected content - DOM"] = +` + 400x300 + + Chip + + + TITLE + + description + +`; +/* end snapshot sbb-teaser renders below with projected content - DOM */ + +snapshots["sbb-teaser renders below with projected content - ShadowDOM"] = +` + + + + + + + + + + + + + + + + + + + + + +`; +/* end snapshot sbb-teaser renders below with projected content - ShadowDOM */ + +snapshots["sbb-teaser renders static - DOM"] = +` + +`; +/* end snapshot sbb-teaser renders static - DOM */ + +snapshots["sbb-teaser renders static - ShadowDOM"] = +` + + + + + + + + + + + + + + + + + + + + + +`; +/* end snapshot sbb-teaser renders static - ShadowDOM */ + diff --git a/src/components/teaser/readme.md b/src/components/teaser/readme.md index d3738dbb2c..991401489b 100644 --- a/src/components/teaser/readme.md +++ b/src/components/teaser/readme.md @@ -1,22 +1,48 @@ The `sbb-teaser` is a component which can display an image with a caption, and it behaves like a link on user interaction. +Simple teaser example: + +```html + + 400x300 + A brief description. + +``` + ## Slots -The component displays the `image`, the `title` and the `description` in the self-named slots. +The default slot is reserved for the description. The component displays the `image` and the `title` with the self-named slots. +It's also possible to display a [sbb-chip](/docs/components-sbb-chip--docs) using the `chip` slot. ```html - + 400x300 - Title - A brief description. + Chip label + Title + A brief description. ``` -The title level can be set by the consumer using the `titleLevel` property. +## Style + +Using the `alignment` property, it is possible to change the text position respect to the image. +Possible values are `after-centered` (default), `after` and `below`. + +```html + ... +``` + +By default, the image dimensions are set using the width and the aspect ratio. +Default values are `300px` and `4/3`. Consumers can change these values on their slotted image element. ## Accessibility -It's important to set the `accessibilityLabel` property, which describes the `sbb-teaser` for screen-reader users. +It's important to set the `aria-label` on the ``, which describes the `sbb-teaser` for screen-reader users. The description text is wrapped into an `

` element to guarantee the semantic meaning. @@ -26,18 +52,21 @@ Avoid slotting block elements (e.g. `

`) as this violates semantic rules and ## Properties -| Name | Attribute | Privacy | Type | Default | Description | -| ------------ | ------------- | ------- | ---------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `isStacked` | `is-stacked` | public | `boolean` | | Teaser variant - when this is true the text-content will be under the image otherwise it will be displayed next to the image. | -| `titleLevel` | `title-level` | public | `TitleLevel` | `'5'` | Heading level of the sbb-title element (e.g. h1-h6). | -| `href` | `href` | public | `string \| undefined` | | The href value you want to link to. | -| `target` | `target` | public | `LinkTargetType \| string \| undefined \| undefined` | | Where to display the linked URL. | -| `rel` | `rel` | public | `string \| undefined \| undefined` | | The relationship of the linked URL as space-separated link types. | +| Name | Attribute | Privacy | Type | Default | Description | +| -------------- | --------------- | ------- | ---------------------------------------------------- | ------------------ | ------------------------------------------------------------------------- | +| `alignment` | `alignment` | public | `'after-centered' \| 'after' \| 'below'` | `'after-centered'` | Teaser variant - define the position and the alignment of the text block. | +| `titleLevel` | `title-level` | public | `TitleLevel` | `'5'` | Heading level of the sbb-title element (e.g. h1-h6). | +| `titleContent` | `title-content` | public | `string \| undefined` | | Content of title. | +| `chipContent` | `chip-content` | public | `string \| undefined` | | Content of chip. | +| `href` | `href` | public | `string \| undefined` | | The href value you want to link to. | +| `target` | `target` | public | `LinkTargetType \| string \| undefined \| undefined` | | Where to display the linked URL. | +| `rel` | `rel` | public | `string \| undefined \| undefined` | | The relationship of the linked URL as space-separated link types. | ## Slots -| Name | Description | -| ------------- | ----------------------------------- | -| `image` | Slot used to render the image | -| `title` | Slot used to render the title | -| `description` | Slot used to render the description | +| Name | Description | +| ------- | ----------------------------------------------- | +| `image` | Slot used to render the image. | +| `chip` | Slot used to render the sbb-chip label. | +| `title` | Slot used to render the title. | +| | Use the unnamed slot to render the description. | diff --git a/src/components/teaser/teaser.e2e.ts b/src/components/teaser/teaser.e2e.ts index c6542fa8d0..8004dfc9a7 100644 --- a/src/components/teaser/teaser.e2e.ts +++ b/src/components/teaser/teaser.e2e.ts @@ -1,20 +1,42 @@ -import { expect, fixture } from '@open-wc/testing'; +import { assert, expect, fixture } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; -import { waitForLitRender } from '../core/testing'; +import { EventSpy, waitForLitRender } from '../core/testing'; -import type { SbbTeaserElement } from './teaser'; -import '.'; +import { SbbTeaserElement } from './teaser'; describe('sbb-teaser', () => { let element: SbbTeaserElement; - it('should receive focus', async () => { - element = await fixture(html`Hero content`); + beforeEach(async () => { + element = await fixture(html`Content`); + await waitForLitRender(element); + }); + + it('should render', async () => { + assert.instanceOf(element, SbbTeaserElement); + }); + it('should receive focus', async () => { element.focus(); await waitForLitRender(element); - expect(document.activeElement.id).to.be.equal('focus-id'); }); + + it('dispatches event on click', async () => { + const clickSpy = new EventSpy('click'); + + element.click(); + expect(clickSpy.count).to.be.equal(1); + }); + + it('should dispatch event on click if is-static', async () => { + element.setAttribute('is-static', 'true'); + + const clickSpy = new EventSpy('click'); + await waitForLitRender(element); + + element.click(); + expect(clickSpy.count).to.be.equal(1); + }); }); diff --git a/src/components/teaser/teaser.scss b/src/components/teaser/teaser.scss index 3183293833..8443392279 100644 --- a/src/components/teaser/teaser.scss +++ b/src/components/teaser/teaser.scss @@ -21,7 +21,11 @@ --sbb-teaser-brightness-hover: 1.075; } -:host([is-stacked]) { +:host([alignment='after']) { + --sbb-teaser-align-items: start; +} + +:host([alignment='below']) { --sbb-teaser-flex-direction: column; --sbb-teaser-align-items: baseline; --sbb-teaser-gap: var(--sbb-spacing-fixed-3x); @@ -57,17 +61,16 @@ flex-flow: var(--sbb-teaser-flex-direction) nowrap; align-items: var(--sbb-teaser-align-items); gap: var(--sbb-teaser-gap); - height: var(--sbb-teaser-container-height, auto); } ::slotted([slot='image']) { will-change: transform; display: block; object-fit: cover; - width: 100%; + width: sbb.px-to-rem-build(300); filter: brightness(var(--sbb-teaser-brightness, 1)); transition: var(--sbb-animation-duration-4x) var(--sbb-animation-easing); - aspect-ratio: 4 / 3; + aspect-ratio: 4/3; @include sbb.hover-mq($hover: true) { .sbb-teaser:hover & { @@ -79,7 +82,7 @@ } .sbb-teaser__image-wrapper { - min-width: var(--sbb-teaser-width); + flex-shrink: 0; overflow: hidden; border-radius: var(--sbb-teaser-border-radius); transition: var(--sbb-animation-duration-4x) var(--sbb-animation-easing); @@ -91,29 +94,28 @@ } } -.sbb-teaser__lead { - @include sbb.clamp-lines($lines: 2); +.sbb-teaser__chip { + display: block; + max-width: fit-content; + margin-block-end: var(--sbb-spacing-fixed-1x); + + :host(:not([data-slot-names~='chip'], [chip-content])) & { + display: none; + } +} +.sbb-teaser__lead { // Overwrite sbb-title default margin margin: 0; } .sbb-teaser__description { - // Reset paragraph styles - display: inline; - margin: 0; - padding: 0; - @include sbb.text-s--regular; - @include sbb.clamp-lines($lines: 2); + display: inline-block; color: var(--sbb-teaser-description-color); } -::slotted([slot='description']) { - margin-block: 0; -} - .sbb-teaser__opens-in-new-window { @include sbb.screen-reader-only; } diff --git a/src/components/teaser/teaser.spec.ts b/src/components/teaser/teaser.spec.ts index 747a6a3602..df0bc1a165 100644 --- a/src/components/teaser/teaser.spec.ts +++ b/src/components/teaser/teaser.spec.ts @@ -1,71 +1,89 @@ import { expect, fixture } from '@open-wc/testing'; +import type { TemplateResult } from 'lit'; import { html } from 'lit/static-html.js'; + +import { sbbSpread } from '../core/dom'; +import images from '../core/images'; + +import type { SbbTeaserElement } from './teaser'; import './teaser'; -import '../title'; describe('sbb-teaser', () => { - describe('sbb-teaser is stacked', () => { - it('renders', async () => { - const root = await fixture( - html``, - ); + const createTeaser = (args: Record): TemplateResult => { + return html``; + }; + + const argsAfterCentered = { + href: 'https://github.com/lyne-design-system/lyne-components', + alignment: 'after-centered', + 'aria-label': 'SBB teaser', + }; + + const argsAfter = { + ...argsAfterCentered, + alignment: 'after', + 'title-level': '2', + }; + + it('renders after centered - DOM', async () => { + const root: SbbTeaserElement = await fixture(createTeaser(argsAfterCentered)); + await expect(root).dom.to.equalSnapshot(); + }); + + it('renders after centered - ShadowDOM', async () => { + const root: SbbTeaserElement = await fixture(createTeaser(argsAfterCentered)); + await expect(root).shadowDom.to.equalSnapshot(); + }); + + it('renders after with title level set - DOM', async () => { + const root: SbbTeaserElement = await fixture(createTeaser(argsAfter)); + await expect(root).dom.to.equalSnapshot(); + }); + + it('renders after with title level set - ShadowDOM', async () => { + const root: SbbTeaserElement = await fixture(createTeaser(argsAfter)); + await expect(root).shadowDom.to.equalSnapshot(); + }); + + it('renders below with projected content - DOM', async () => { + const root: SbbTeaserElement = await fixture(html` + + 400x300 + Chip + TITLE + description + + `); + await expect(root).dom.to.equalSnapshot(); + }); + + it('renders below with projected content - ShadowDOM', async () => { + const root: SbbTeaserElement = await fixture(html` + + 400x300 + Chip + TITLE + description + + `); + await expect(root).shadowDom.to.equalSnapshot(); + }); - expect(root).dom.to.be.equal( - ` - - - `, - ); - expect(root).shadowDom.to.be.equal( - ` - - - - - - - -

-
-
- `, - ); - }); + it('renders static - DOM', async () => { + const root: SbbTeaserElement = await fixture(createTeaser({ alignment: 'after-centered' })); + await expect(root).dom.to.equalSnapshot(); }); - describe('sbb-teaser is not stacked', () => { - it('renders', async () => { - const root = await fixture( - html``, - ); - expect(root).dom.to.be.equal( - ` - - - `, - ); - expect(root).shadowDom.to.be.equal( - ` - - - - - - - -

-
-
-
- `, - ); - }); + it('renders static - ShadowDOM', async () => { + const root: SbbTeaserElement = await fixture(createTeaser({ alignment: 'after-centered' })); + await expect(root).shadowDom.to.equalSnapshot(); }); }); diff --git a/src/components/teaser/teaser.stories.ts b/src/components/teaser/teaser.stories.ts index 1d9f6e9fec..6f70d03eab 100644 --- a/src/components/teaser/teaser.stories.ts +++ b/src/components/teaser/teaser.stories.ts @@ -15,37 +15,23 @@ const loremIpsum: string = `Lorem ipsum dolor sit amet, consetetur sadipscing el invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.`; -const title: InputType = { +const titleContent: InputType = { control: { type: 'text', }, - table: { - category: 'General', - }, -}; - -const description: InputType = { - control: { - type: 'text', - }, - table: { - category: 'General', - }, }; -const ariaLabel: InputType = { +const chipContent: InputType = { control: { type: 'text', }, - table: { - category: 'General', - }, }; -const isStacked: InputType = { +const alignment: InputType = { control: { - type: 'boolean', + type: 'select', }, + options: ['after-centered', 'after', 'below'], }; const hrefs: string[] = [ @@ -67,74 +53,167 @@ const href: InputType = { }, }; +const description: InputType = { + control: { + type: 'text', + }, +}; + +const ariaLabel: InputType = { + control: { + type: 'text', + }, +}; + const defaultArgTypes: ArgTypes = { - title, + 'title-content': titleContent, + 'chip-content': chipContent, + alignment, + href, description, 'aria-label': ariaLabel, - 'is-stacked': isStacked, - href, }; const defaultArgs: Args = { - title: 'This is a title', - description: 'This is a paragraph', - 'aria-label': `The text which gets exposed to screen reader users. The text should reflect all the information which gets passed - into the components slots and which is visible in the Teaser, either through text or iconography`, - 'is-stacked': true, + 'title-content': 'This is a title', + 'chip-content': undefined, + alignment: 'after-centered', href: href.options[1], + description: 'This is a paragraph', + 'aria-label': + 'The text which gets exposed to screen reader users. The text should reflect all the information which gets passed into the components slots and which is visible in the Teaser, either through text or iconography', }; -const TemplateDefaultTeaser = ({ title, description, ...remainingArgs }: Args): TemplateResult => { +const TemplateDefault = ({ description, ...remainingArgs }: Args): TemplateResult => { return html` 400x300 - ${title} -

${description}

+ ${description}
`; }; -const TemplateTeaserList = (args: Args): TemplateResult => html` -
    - ${repeat(new Array(6), () => html`
  • ${TemplateDefaultTeaser(args)}
  • `)} -
-`; +const TemplateCustom = ({ description, ...remainingArgs }: Args): TemplateResult => { + return html` + + 200x100 + ${description} + + `; +}; -const TemplateTeaserListIsStacked = (args: Args): TemplateResult => html` -
    - ${repeat(new Array(4), () => html`
  • ${TemplateDefaultTeaser(args)}
  • `)} +const TemplateSlots = ({ + 'title-content': titleContent, + 'chip-content': chipContent, + description, + ...remainingArgs +}: Args): TemplateResult => { + return html` + + 400x300 + ${chipContent} + ${titleContent} + ${description} + + `; +}; + +const TemplateList = (args: Args): TemplateResult => html` +
      + ${repeat( + new Array(6), + () => html`
    • ${TemplateDefault(args)}
    • `, + )}
    `; -export const defaultTeaser: StoryObj = { - render: TemplateDefaultTeaser, +export const AfterCentered: StoryObj = { + render: TemplateDefault, argTypes: defaultArgTypes, args: { ...defaultArgs }, }; -export const TeaserWithLongText: StoryObj = { - render: TemplateDefaultTeaser, + +export const After: StoryObj = { + render: TemplateDefault, + argTypes: defaultArgTypes, + args: { ...defaultArgs, alignment: 'after' }, +}; + +export const Below: StoryObj = { + render: TemplateDefault, + argTypes: defaultArgTypes, + args: { ...defaultArgs, alignment: 'below' }, +}; + +export const AfterCenteredChip: StoryObj = { + render: TemplateDefault, + argTypes: defaultArgTypes, + args: { ...defaultArgs, alignment: 'after-centered', 'chip-content': 'This is a chip.' }, +}; + +export const AfterChip: StoryObj = { + render: TemplateDefault, + argTypes: defaultArgTypes, + args: { ...defaultArgs, alignment: 'after', 'chip-content': 'This is a chip.' }, +}; + +export const BelowChip: StoryObj = { + render: TemplateDefault, + argTypes: defaultArgTypes, + args: { ...defaultArgs, alignment: 'below', 'chip-content': 'This is a chip.' }, +}; + +export const WithLongTextCentered: StoryObj = { + render: TemplateDefault, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'title-content': loremIpsum, description: loremIpsum }, +}; + +export const WithLongTextAfter: StoryObj = { + render: TemplateDefault, argTypes: defaultArgTypes, args: { ...defaultArgs, - title: loremIpsum, + 'title-content': loremIpsum, description: loremIpsum, + alignment: 'after', }, }; -export const teaserList: StoryObj = { - render: TemplateTeaserList, + +export const WithLongTextBelow: StoryObj = { + render: TemplateDefault, argTypes: defaultArgTypes, - args: { ...defaultArgs, 'is-stacked': false }, + args: { + ...defaultArgs, + 'title-content': loremIpsum, + description: loremIpsum, + alignment: 'below', + }, }; -export const teaserListIsStacked: StoryObj = { - render: TemplateTeaserListIsStacked, + +export const WithCustomWidthAndAspectRatio: StoryObj = { + render: TemplateCustom, argTypes: defaultArgTypes, args: { ...defaultArgs }, }; +export const List: StoryObj = { + render: TemplateList, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const WithSlots: StoryObj = { + render: TemplateSlots, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'chip-content': 'Chip content' }, +}; + const meta: Meta = { decorators: [ (story) => html`
    ${story()}
    `, diff --git a/src/components/teaser/teaser.ts b/src/components/teaser/teaser.ts index 5498abdeb8..af2afaf9d1 100644 --- a/src/components/teaser/teaser.ts +++ b/src/components/teaser/teaser.ts @@ -4,7 +4,7 @@ import { LitElement, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { html, unsafeStatic } from 'lit/static-html.js'; -import { LanguageController } from '../core/common-behaviors'; +import { LanguageController, NamedSlotStateController } from '../core/common-behaviors'; import { setAttributes } from '../core/dom'; import { HandlerRepository, linkHandlerAspect } from '../core/eventing'; import { i18nTargetOpensInNewWindow } from '../core/i18n'; @@ -12,32 +12,35 @@ import type { LinkProperties, LinkTargetType } from '../core/interfaces'; import { resolveLinkOrStaticRenderVariables, targetsNewWindow } from '../core/interfaces'; import type { TitleLevel } from '../title'; import '../title'; +import '../chip'; import style from './teaser.scss?lit&inline'; /** * It displays an interactive image with caption. * - * @slot image - Slot used to render the image - * @slot title - Slot used to render the title - * @slot description - Slot used to render the description + * @slot image - Slot used to render the image. + * @slot chip - Slot used to render the sbb-chip label. + * @slot title - Slot used to render the title. + * @slot - Use the unnamed slot to render the description. */ @customElement('sbb-teaser') export class SbbTeaserElement extends LitElement implements LinkProperties { public static override styles: CSSResultGroup = style; - /** - * Teaser variant - - * when this is true the text-content will be under the image - * otherwise it will be displayed next to the image. - */ - @property({ attribute: 'is-stacked', reflect: true, type: Boolean }) public isStacked: boolean; + /** Teaser variant - define the position and the alignment of the text block. */ + @property({ reflect: true }) public alignment: 'after-centered' | 'after' | 'below' = + 'after-centered'; - /** - * Heading level of the sbb-title element (e.g. h1-h6). - */ + /** Heading level of the sbb-title element (e.g. h1-h6). */ @property({ attribute: 'title-level' }) public titleLevel: TitleLevel = '5'; + /** Content of title. */ + @property({ attribute: 'title-content' }) public titleContent?: string; + + /** Content of chip. */ + @property({ attribute: 'chip-content', reflect: true }) public chipContent?: string; + /** The href value you want to link to. */ @property() public href: string | undefined; @@ -50,6 +53,11 @@ export class SbbTeaserElement extends LitElement implements LinkProperties { private _language = new LanguageController(this); private _handlerRepository = new HandlerRepository(this, linkHandlerAspect); + public constructor() { + super(); + new NamedSlotStateController(this); + } + public override connectedCallback(): void { super.connectedCallback(); this._handlerRepository.connect(); @@ -77,12 +85,15 @@ export class SbbTeaserElement extends LitElement implements LinkProperties { + + ${this.chipContent} + - + ${this.titleContent} -

    - -

    + + + ${ targetsNewWindow(this) ? html`