diff --git a/src/elements/container/sticky-bar/__snapshots__/sticky-bar.snapshot.spec.snap.js b/src/elements/container/sticky-bar/__snapshots__/sticky-bar.snapshot.spec.snap.js index e883d85bab..1e2f363dbb 100644 --- a/src/elements/container/sticky-bar/__snapshots__/sticky-bar.snapshot.spec.snap.js +++ b/src/elements/container/sticky-bar/__snapshots__/sticky-bar.snapshot.spec.snap.js @@ -2,7 +2,11 @@ export const snapshots = {}; snapshots["sbb-sticky-bar renders DOM"] = -` +` `; /* end snapshot sbb-sticky-bar renders DOM */ diff --git a/src/elements/container/sticky-bar/readme.md b/src/elements/container/sticky-bar/readme.md index c96b9cb93f..a5ab85ff4b 100644 --- a/src/elements/container/sticky-bar/readme.md +++ b/src/elements/container/sticky-bar/readme.md @@ -9,6 +9,19 @@ It is displayed with sticky positioning at the bottom of the container that cont ``` +## Animate from sticky to normal content flow and vice versa + +By default, the sticky bar is set to `position: sticky`. In certain cases, the consumer needs +to control the sliding out (or sliding in) of the sticky bar. +By calling the `stick()` or `unstick()` methods, the position property is toggled +between `position: sticky` and `position: relative` by displaying a slide animation. When the sticky bar is `unstick`, +the `sbb-sticky-bar` will behave like a normal container without any sticky behavior. +Whenever the sticky bar is currently not sticky (e.g. scrolled down), +calling `stick()` or `unstick()` won't have any visual effect. + +An example use case is to call `unstick()`, which visually slides out the sticky bar, and +then the consumer can remove it from the DOM by listening to the `didUnstick` event. + ## Slots The `sbb-sticky-bar` content is provided via an unnamed slot. @@ -26,6 +39,22 @@ Optionally the user can set the `color` property on the `sbb-sticky-bar` in orde | ------- | --------- | ------- | --------------------------- | ------- | ---------------------------------------------------- | | `color` | `color` | public | `'white' \| 'milk' \| null` | `null` | Color of the container, like transparent, white etc. | +## Methods + +| Name | Privacy | Description | Parameters | Return | Inherited From | +| --------- | ------- | ----------------------------------------------------------------- | ---------- | ------ | -------------- | +| `stick` | public | Animates from normal content flow position to `position: sticky`. | | `void` | | +| `unstick` | public | Animates `position: sticky` to normal content flow position. | | `void` | | + +## Events + +| Name | Type | Description | Inherited From | +| ------------- | ------------------- | ------------------------------------------------------------------------------------------------ | -------------- | +| `didStick` | `CustomEvent` | Emits when the animation from normal content flow to `position: sticky` ends. | | +| `didUnstick` | `CustomEvent` | Emits when the animation from `position: sticky` to normal content flow ends. | | +| `willStick` | `CustomEvent` | Emits when the animation from normal content flow to `position: sticky` starts. Can be canceled. | | +| `willUnstick` | `CustomEvent` | Emits when the animation from `position: sticky` to normal content flow starts. Can be canceled. | | + ## CSS Properties | Name | Default | Description | diff --git a/src/elements/container/sticky-bar/sticky-bar.scss b/src/elements/container/sticky-bar/sticky-bar.scss index b5d561e850..5dd46b1e6e 100644 --- a/src/elements/container/sticky-bar/sticky-bar.scss +++ b/src/elements/container/sticky-bar/sticky-bar.scss @@ -5,8 +5,16 @@ $intersector-overlapping: 1px; +// `data-sticking` v.s. `data-state='sticky'`: +// While `data-sticking` is representing whether the sticky bar is currently sticking (due scrolling), +// the `data-state` holds information whether the `position: sticky` is applied or not +// but not considering the real sticking state. + :host { + --sbb-sticky-bar-position: sticky; --sbb-sticky-bar-padding-block: var(--sbb-spacing-responsive-xs); + --sbb-sticky-bar-border-radius: var(--sbb-border-radius-8x); + --sbb-sticky-bar-animation-easing: var(--sbb-animation-easing); --sbb-sticky-bar-fade-in-animation-duration: var( --sbb-disable-animation-zero-duration, var(--sbb-animation-duration-5x) @@ -15,34 +23,90 @@ $intersector-overlapping: 1px; --sbb-disable-animation-zero-duration, var(--sbb-animation-duration-2x) ); - --sbb-sticky-bar-animation-easing: var(--sbb-animation-easing); - --sbb-sticky-bar-border-radius: var(--sbb-border-radius-8x); + --sbb-sticky-bar-slide-vertically-animation-duration: var( + --sbb-disable-animation-zero-duration, + var(--sbb-animation-duration-4x) + ); + --sbb-sticky-bar-slide-vertically-animation-easing: ease-out; + --sbb-sticky-bar-slide-vertically-animation-delay: 0s; + --sbb-sticky-bar-slide-vertically-animation-name: unset; + --_sbb-sticky-bar-background-animation-duration: var( + --sbb-sticky-bar-fade-out-animation-duration + ); + --_sbb-sticky-bar-intersector-background-color: transparent; + --_sbb-sticky-bar-forced-colors-border: none; // Display contents needed to get the sticky bar sticky. display: contents; } -:host([data-sticking]) { +:host([data-sticking]:not([data-state='unsticky'])) { --sbb-sticky-bar-sticky-background-color: var( --sbb-container-background-color, var(--sbb-color-white) ); + --_sbb-sticky-bar-intersector-background-color: var(--sbb-sticky-bar-sticky-background-color); + --_sbb-sticky-bar-background-animation-duration: var(--sbb-sticky-bar-fade-in-animation-duration); + + @include sbb.if-forced-colors { + --_sbb-sticky-bar-forced-colors-border: var(--sbb-border-width-1x) solid CanvasText; + } } -:host([data-sticking][color='white']) { +:host([data-sticking]:not([data-state='unsticky'])[color='white']) { --sbb-sticky-bar-sticky-background-color: var(--sbb-color-white); } -:host([data-sticking][color='milk']) { +:host([data-sticking]:not([data-state='unsticky'])[color='milk']) { --sbb-sticky-bar-sticky-background-color: var(--sbb-color-milk); } +:host( + :is( + [data-sticking]:is( + [data-slide-vertically], + [data-state='sticking'], + [data-state='unsticking'] + ), + [data-state='unsticky'] + ) + ) { + --_sbb-sticky-bar-background-animation-duration: 0s; +} + +:host( + [data-sticking]:is( + [data-slide-vertically]:not([data-state='unsticky'], [data-state='unsticking']), + [data-state='sticking'] + ) + ) { + --sbb-sticky-bar-slide-vertically-animation-name: slide-in; +} + +:host([data-sticking][data-state='unsticking']) { + --sbb-sticky-bar-slide-vertically-animation-name: slide-out; +} + +:host(:is(:not([data-initialized]), [data-state='unsticky'])) { + --sbb-sticky-bar-position: relative; +} + .sbb-sticky-bar__wrapper { - position: sticky; + position: var(--sbb-sticky-bar-position); inset-block-end: 0; display: block; z-index: var(--sbb-sticky-bar-z-index); + animation: { + name: var(--sbb-sticky-bar-slide-vertically-animation-name); + duration: var(--sbb-sticky-bar-slide-vertically-animation-duration); + timing-function: var(--sbb-sticky-bar-slide-vertically-animation-easing); + delay: var(--sbb-sticky-bar-slide-vertically-animation-delay); + + // Fill mode needed to enable delay + fill-mode: backwards; + } + &::after, &::before { content: ''; @@ -68,20 +132,13 @@ $intersector-overlapping: 1px; // Color and border radius when sticky &::after { background-color: var(--sbb-sticky-bar-sticky-background-color, transparent); - transition: background-color var(--sbb-sticky-bar-fade-out-animation-duration) - var(--sbb-sticky-bar-animation-easing); border-start-start-radius: var(--sbb-sticky-bar-border-radius); border-start-end-radius: var(--sbb-sticky-bar-border-radius); + transition: background-color var(--_sbb-sticky-bar-background-animation-duration) + var(--sbb-sticky-bar-animation-easing); // Display a border on high contrast mode. - :host([data-sticking]) & { - transition-duration: var(--sbb-sticky-bar-fade-in-animation-duration); - - @include sbb.if-forced-colors { - border-block-start: var(--sbb-border-width-1x) solid CanvasText; - border-radius: 0; - } - } + border: var(--_sbb-sticky-bar-forced-colors-border); } } @@ -100,7 +157,7 @@ $intersector-overlapping: 1px; z-index: -1; border-start-start-radius: var(--sbb-sticky-bar-border-radius); border-start-end-radius: var(--sbb-sticky-bar-border-radius); - transition: box-shadow var(--sbb-sticky-bar-fade-out-animation-duration) + transition: box-shadow var(--_sbb-sticky-bar-background-animation-duration) var(--sbb-sticky-bar-animation-easing); clip-path: polygon( -50% calc(-1 * var(--sbb-shadow-elevation-level-11-shadow-1-blur)), @@ -109,10 +166,8 @@ $intersector-overlapping: 1px; -50% 50% ); - :host([data-sticking]) & { + :host([data-sticking]:not([data-state='unsticky'])) & { @include sbb.shadow-level-11-soft; - - transition-duration: var(--sbb-sticky-bar-fade-in-animation-duration); } } @@ -135,14 +190,29 @@ $intersector-overlapping: 1px; position: absolute; width: 100%; height: calc(var(--sbb-sticky-bar-bottom-overlapping-height, 0) + $intersector-overlapping); - background-color: transparent; + background-color: var(--_sbb-sticky-bar-intersector-background-color); pointer-events: none; - transition: background-color var(--sbb-sticky-bar-fade-out-animation-duration) + transition: background-color var(--_sbb-sticky-bar-background-animation-duration) var(--sbb-sticky-bar-animation-easing); + } +} - :host([data-sticking]) & { - transition-duration: var(--sbb-sticky-bar-fade-in-animation-duration); - background-color: var(--sbb-sticky-bar-sticky-background-color); - } +@keyframes slide-in { + from { + transform: translateY(100%); + } + + to { + transform: translateY(0%); + } +} + +@keyframes slide-out { + from { + transform: translateY(0%); + } + + to { + transform: translateY(100%); } } diff --git a/src/elements/container/sticky-bar/sticky-bar.snapshot.spec.ts b/src/elements/container/sticky-bar/sticky-bar.snapshot.spec.ts index 573f5e25b9..41e71fd3af 100644 --- a/src/elements/container/sticky-bar/sticky-bar.snapshot.spec.ts +++ b/src/elements/container/sticky-bar/sticky-bar.snapshot.spec.ts @@ -11,7 +11,7 @@ describe(`sbb-sticky-bar`, () => { let element: SbbStickyBarElement; beforeEach(async () => { - element = await fixture(html` `); + element = await fixture(html``); }); it('DOM', async () => { diff --git a/src/elements/container/sticky-bar/sticky-bar.spec.ts b/src/elements/container/sticky-bar/sticky-bar.spec.ts index e9b09c1be1..a88d86fac0 100644 --- a/src/elements/container/sticky-bar/sticky-bar.spec.ts +++ b/src/elements/container/sticky-bar/sticky-bar.spec.ts @@ -3,7 +3,7 @@ import { setViewport } from '@web/test-runner-commands'; import { html } from 'lit'; import { fixture } from '../../core/testing/private.js'; -import { waitForCondition, waitForLitRender } from '../../core/testing.js'; +import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing.js'; import type { SbbContainerElement } from '../container.js'; import { SbbStickyBarElement } from './sticky-bar.js'; @@ -13,45 +13,132 @@ import '../container.js'; describe(`sbb-sticky-bar`, () => { let container: SbbContainerElement; let stickyBar: SbbStickyBarElement; - const getIsSticking = (): boolean => { - return stickyBar.hasAttribute('data-sticking'); - }; + let willStickSpy: EventSpy; + let didStickSpy: EventSpy; + let willUnstickSpy: EventSpy; + let didUnstickSpy: EventSpy; + + const isSticking = (): boolean => stickyBar.hasAttribute('data-sticking'); + + describe('sticky', () => { + beforeEach(async () => { + await setViewport({ width: 320, height: 500 }); + + container = await fixture(html` + +

Situation 1

+

Situation 2

+

Situation 3

+

Situation 4

+

Situation 5

+

Situation 6

+

Situation 7

+

Situation 8

+

Situation 9

+

Situation 10

+

Situation 11

+

Situation 12

+ +
+ `); + stickyBar = container.querySelector('sbb-sticky-bar')!; + willStickSpy = new EventSpy(SbbStickyBarElement.events.willStick, stickyBar); + didStickSpy = new EventSpy(SbbStickyBarElement.events.didStick, stickyBar); + willUnstickSpy = new EventSpy(SbbStickyBarElement.events.willUnstick, stickyBar); + didUnstickSpy = new EventSpy(SbbStickyBarElement.events.didUnstick, stickyBar); + }); + + it('renders', async () => { + assert.instanceOf(stickyBar, SbbStickyBarElement); + }); + + it('stops sticking when scrolling to bottom', async () => { + expect(isSticking()).to.equal(true); + expect(stickyBar).to.have.attribute('data-slide-vertically'); + + window.scrollTo(0, 400); + + await waitForCondition(async () => !isSticking()); + await waitForLitRender(container); + + expect(isSticking()).to.equal(false); + expect(stickyBar).not.to.have.attribute('data-slide-vertically'); + }); + + it('expands the sticky-bar when container is expanded', async () => { + container.expanded = true; + + await waitForLitRender(container); + + expect(stickyBar).to.have.attribute('data-expanded'); + }); + + it('gets unsticky when calling unstick()', async () => { + stickyBar.unstick(); + + await willUnstickSpy.calledOnce(); + await didUnstickSpy.calledOnce(); + + expect(willUnstickSpy.count).to.be.equal(1); + expect(didUnstickSpy.count).to.be.equal(1); + }); + + it('doesnt get unsticky when prevented', async () => { + stickyBar.addEventListener( + SbbStickyBarElement.events.willUnstick, + (e) => e.preventDefault(), + { once: true }, + ); + stickyBar.unstick(); + + await willUnstickSpy.calledOnce(); + + expect(stickyBar).to.have.attribute('data-state', 'sticky'); + expect(willUnstickSpy.count).to.be.equal(1); + expect(didUnstickSpy.count).to.be.equal(0); + }); + + it('send events when sticky but not currently sticking and calling unstick()', async () => { + window.scrollTo(0, 400); + await waitForCondition(async () => !isSticking()); + + stickyBar.unstick(); + + await willUnstickSpy.calledOnce(); + await didUnstickSpy.calledOnce(); + + expect(willUnstickSpy.count).to.be.equal(1); + expect(didUnstickSpy.count).to.be.equal(1); + }); + + it('gets sticky when calling stick()', async () => { + stickyBar.unstick(); + await didUnstickSpy.calledOnce(); + + stickyBar.stick(); + + await willStickSpy.calledOnce(); + await didStickSpy.calledOnce(); + + expect(willStickSpy.count).to.be.equal(1); + expect(didStickSpy.count).to.be.equal(1); + }); + + it('doesnt get sticky when prevented', async () => { + stickyBar.unstick(); + await didUnstickSpy.calledOnce(); + + stickyBar.addEventListener(SbbStickyBarElement.events.willStick, (e) => e.preventDefault(), { + once: true, + }); + stickyBar.stick(); - beforeEach(async () => { - await setViewport({ width: 320, height: 500 }); - container = await fixture(html` - -

Situation 1

-

Situation 2

-

Situation 3

-

Situation 4

-

Situation 5

-

Situation 6

-

Situation 7

-

Situation 8

-

Situation 9

-

Situation 10

-

Situation 11

-

Situation 12

- -
- `); - stickyBar = container.querySelector('sbb-sticky-bar')!; - }); + await willStickSpy.calledOnce(); - it('renders', async () => { - assert.instanceOf(stickyBar, SbbStickyBarElement); - }); - - it('stops sticking when scrolling to bottom', async () => { - await waitForCondition(async () => getIsSticking()); - expect(getIsSticking()).to.equal(true); - - window.scrollTo(0, 400); - - await waitForCondition(async () => !getIsSticking()); - - expect(getIsSticking()).to.equal(false); + expect(stickyBar).to.have.attribute('data-state', 'unsticky'); + expect(willStickSpy.count).to.be.equal(1); + expect(didStickSpy.count).to.be.equal(0); + }); }); it('is settled when content is not long enough', async () => { @@ -66,9 +153,7 @@ describe(`sbb-sticky-bar`, () => { `); stickyBar = container.querySelector('sbb-sticky-bar')!; - await waitForCondition(async () => !getIsSticking()); - - expect(getIsSticking()).to.equal(false); + expect(isSticking()).to.equal(false); }); it('renders with expanded layout', async () => { @@ -84,12 +169,4 @@ describe(`sbb-sticky-bar`, () => { expect(stickyBar).to.have.attribute('data-expanded'); }); - - it('expands the sticky-bar when container is expanded', async () => { - container.expanded = true; - - await waitForLitRender(container); - - expect(stickyBar).to.have.attribute('data-expanded'); - }); }); diff --git a/src/elements/container/sticky-bar/sticky-bar.stories.ts b/src/elements/container/sticky-bar/sticky-bar.stories.ts index 717c1a648b..b03008d908 100644 --- a/src/elements/container/sticky-bar/sticky-bar.stories.ts +++ b/src/elements/container/sticky-bar/sticky-bar.stories.ts @@ -1,8 +1,11 @@ +import { withActions } from '@storybook/addon-actions/decorator'; import type { InputType } from '@storybook/types'; import type { Args, ArgTypes, Meta, StoryObj } from '@storybook/web-components'; import { html, nothing, type TemplateResult } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; import readme from './readme.md?raw'; +import { SbbStickyBarElement } from './sticky-bar.js'; import '../../action-group.js'; import '../../button/button.js'; @@ -10,7 +13,6 @@ import '../../button/secondary-button.js'; import '../../link.js'; import '../../title.js'; import '../container.js'; -import './sticky-bar.js'; const containerColor: InputType = { name: 'color', @@ -238,13 +240,66 @@ export const MilkContainerBackgroundExpanded: StoryObj = { args: { ...defaultArgs, containerColor: color.options![2], containerBackgroundExpanded: true }, }; +export const ControlStickyState: StoryObj = { + render: WithContentAfterTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, containerColor: 'milk', color: 'white' }, + decorators: [ + (story) => + html`
+ Control whether the sticky bar has \`position: sticky\`. + { + console.log(e); + (e.target as HTMLElement)?.parentElement?.parentElement + ?.querySelector('sbb-sticky-bar') + ?.stick(); + }} + > + Stick + + { + (e.target as HTMLElement)?.parentElement?.parentElement + ?.querySelector('sbb-sticky-bar') + ?.unstick(); + }} + > + Unstick + +
+ ${story()}`, + ], +}; + const meta: Meta = { parameters: { + actions: { + handles: [ + SbbStickyBarElement.events.willStick, + SbbStickyBarElement.events.didStick, + SbbStickyBarElement.events.willUnstick, + SbbStickyBarElement.events.didUnstick, + ], + }, docs: { extractComponentDescription: () => readme, }, layout: 'fullscreen', }, + decorators: [withActions], title: 'elements/sbb-container/sbb-sticky-bar', }; diff --git a/src/elements/container/sticky-bar/sticky-bar.ts b/src/elements/container/sticky-bar/sticky-bar.ts index e0652eba93..eeee047574 100644 --- a/src/elements/container/sticky-bar/sticky-bar.ts +++ b/src/elements/container/sticky-bar/sticky-bar.ts @@ -9,13 +9,21 @@ import { import { customElement, property } from 'lit/decorators.js'; import { hostAttributes } from '../../core/decorators.js'; +import { EventEmitter } from '../../core/eventing.js'; +import { SbbUpdateSchedulerMixin } from '../../core/mixins.js'; import style from './sticky-bar.scss?lit&inline'; +type StickyState = 'sticking' | 'sticky' | 'unsticking' | 'unsticky'; + /** * A container that sticks to the bottom of the page if slotted into `sbb-container`. * * @slot - Use the unnamed slot to add content to the sticky bar. + * @event {CustomEvent} willStick - Emits when the animation from normal content flow to `position: sticky` starts. Can be canceled. + * @event {CustomEvent} didStick - Emits when the animation from normal content flow to `position: sticky` ends. + * @event {CustomEvent} willUnstick - Emits when the animation from `position: sticky` to normal content flow starts. Can be canceled. + * @event {CustomEvent} didUnstick - Emits when the animation from `position: sticky` to normal content flow ends. * @cssprop [--sbb-sticky-bar-padding-block=var(--sbb-spacing-responsive-xs)] - Block padding of the sticky bar. * @cssprop [--sbb-sticky-bar-bottom-overlapping-height=0px] - Define an additional area where * the sticky bar overlaps the following content on the bottom. @@ -28,22 +36,49 @@ export @hostAttributes({ slot: 'sticky-bar', }) -class SbbStickyBarElement extends LitElement { +class SbbStickyBarElement extends SbbUpdateSchedulerMixin(LitElement) { public static override styles: CSSResultGroup = style; + public static readonly events = { + willStick: 'willStick', + didStick: 'didStick', + willUnstick: 'willUnstick', + didUnstick: 'didUnstick', + } as const; + /** Color of the container, like transparent, white etc. */ @property({ reflect: true }) public accessor color: 'white' | 'milk' | null = null; + /** The state of the component. */ + private set _state(state: StickyState) { + this.setAttribute('data-state', state); + } + private get _state(): StickyState { + return this.getAttribute('data-state') as StickyState; + } + + private _willStick: EventEmitter = new EventEmitter(this, SbbStickyBarElement.events.willStick); + private _didStick: EventEmitter = new EventEmitter(this, SbbStickyBarElement.events.didStick); + private _willUnstick: EventEmitter = new EventEmitter( + this, + SbbStickyBarElement.events.willUnstick, + ); + private _didUnstick: EventEmitter = new EventEmitter(this, SbbStickyBarElement.events.didUnstick); + private _intersector?: HTMLSpanElement; private _observer = new IntersectionController(this, { // Although `this` is observed, we have to postpone observing // into firstUpdated() to achieve a correct initial state. target: null, - callback: (entries) => this._toggleShadowVisibility(entries[0]), + callback: (entries) => this._detectStickyState(entries[0]), }); public override connectedCallback(): void { super.connectedCallback(); + this._state = 'sticky'; + + // Sticky bar needs to be hidden until first observer callback + this.startUpdate(); const container = this.closest('sbb-container'); if (container) { @@ -54,6 +89,12 @@ class SbbStickyBarElement extends LitElement { } } + public override disconnectedCallback(): void { + super.disconnectedCallback(); + + this.toggleAttribute('data-initialized', false); + } + protected override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); @@ -64,16 +105,86 @@ class SbbStickyBarElement extends LitElement { this._observer.observe(this); } - private _toggleShadowVisibility(entry: IntersectionObserverEntry): void { + private _detectStickyState(entry: IntersectionObserverEntry): void { + this.toggleAttribute('data-initialized', true); + + const isSticky = !entry.isIntersecting && entry.boundingClientRect.top > 0; + + // To optimize the visual perception of the sticky bar, we have certain cases (e.g. on page load) + // where we want the sticky bar to slide in from the bottom. + // To decide whether to slide in from the bottom up, + // we check how far away the sticky bar is from the intersector element. When scrolling fast, the + // difference can vary slightly. To account for this we add a height tolerance. + // This value was found by trial and error. + const intersectorRect = this._intersector?.getBoundingClientRect(); + const stickyBarRect = this.shadowRoot!.querySelector( + '.sbb-sticky-bar__wrapper', + )!.getBoundingClientRect(); + const HEIGHT_TOLERANCE = 30; + this.toggleAttribute( - 'data-sticking', - !entry.isIntersecting && entry.boundingClientRect.top > 0, + 'data-slide-vertically', + isSticky && + this._intersector && + Math.abs(intersectorRect!.bottom - stickyBarRect.bottom) > HEIGHT_TOLERANCE, ); + + // Toggling data-sticking has to be after data-slide-vertically (prevents background color transition) + this.toggleAttribute('data-sticking', isSticky); + + // Sticky bar needs to be hidden until first observer callback + this.completeUpdate(); + } + + /** Animates from normal content flow position to `position: sticky`. */ + public stick(): void { + if (this._state !== 'unsticky' || !this._willStick.emit()) { + return; + } + + this._state = 'sticking'; + if (!this.hasAttribute('data-sticking')) { + this._stickyCallback(); + } + } + + /** Animates `position: sticky` to normal content flow position. */ + public unstick(): void { + if (this._state !== 'sticky' || !this._willUnstick.emit()) { + return; + } + + this._state = 'unsticking'; + + if (!this.hasAttribute('data-sticking')) { + this._unstickyCallback(); + } + } + + private _stickyCallback(): void { + this._state = 'sticky'; + this._didStick.emit(); + } + + private _unstickyCallback(): void { + this._didUnstick.emit(); + this._state = 'unsticky'; + } + + private _onAnimationEnd(event: AnimationEvent): void { + if ( + (this._state === 'sticking' || this._state === 'sticky') && + event.animationName === 'slide-in' + ) { + this._stickyCallback(); + } else if (this._state === 'unsticking' && event.animationName === 'slide-out') { + this._unstickyCallback(); + } } protected override render(): TemplateResult { return html` -
+
diff --git a/src/elements/container/sticky-bar/sticky-bar.visual.spec.ts b/src/elements/container/sticky-bar/sticky-bar.visual.spec.ts index 5937924afd..d15342cc93 100644 --- a/src/elements/container/sticky-bar/sticky-bar.visual.spec.ts +++ b/src/elements/container/sticky-bar/sticky-bar.visual.spec.ts @@ -10,6 +10,8 @@ import { } from '../../core/testing/private.js'; import { waitForLitRender } from '../../core/testing.js'; +import type { SbbStickyBarElement } from './sticky-bar.js'; + import './sticky-bar.js'; import '../container.js'; import '../../action-group.js'; @@ -29,9 +31,9 @@ describe(`sbb-sticky-bar`, () => { const containerContent = (): TemplateResult => html` Example title

The container component will give its content the correct spacing.

- See more + + See more + `; const actionGroup = (): TemplateResult => html` @@ -83,7 +85,7 @@ describe(`sbb-sticky-bar`, () => { `viewport=medium_short content`, visualDiffDefault.with(async (setup) => { await setup.withFixture( - html` + html` ${containerContent()} ${actionGroup()} `, @@ -92,4 +94,17 @@ describe(`sbb-sticky-bar`, () => { await setViewport({ width: SbbBreakpointMediumMin, height: 400 }); }), ); + + it( + `unstick`, + visualDiffDefault.with((setup) => { + setup.withSnapshotElement(root); + setup.withPostSetupAction(async () => { + root.scrollTop = root.scrollHeight; + + root.querySelector('sbb-sticky-bar')!.unstick(); + await waitForLitRender(root); + }); + }), + ); });