Skip to content

Commit

Permalink
feat(sbb-alert): add close fade-out animation (#2943)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `animation` default value changes from `open` to `all` (the close animation is enabled by default)
  • Loading branch information
TomMenga authored Jul 22, 2024
1 parent d29d777 commit 581b95c
Show file tree
Hide file tree
Showing 11 changed files with 152 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ snapshots["sbb-alert-group renders DOM"] =
role="status"
>
<sbb-alert
animation="open"
animation="all"
data-state="opening"
href="https://www.sbb.ch"
size="m"
Expand Down Expand Up @@ -42,7 +42,7 @@ snapshots["sbb-alert-group renders with slotted DOM"] =
Interruptions
</span>
<sbb-alert
animation="open"
animation="all"
data-state="opening"
href="https://www.sbb.ch"
size="m"
Expand Down
8 changes: 5 additions & 3 deletions src/elements/alert/alert-group/alert-group.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect } from '@open-wc/testing';
import { sendMouse } from '@web/test-runner-commands';
import { html } from 'lit/static-html.js';

import type { SbbTransparentButtonElement } from '../../button.js';
Expand Down Expand Up @@ -44,7 +45,6 @@ describe(`sbb-alert-group`, () => {
.shadowRoot!.querySelector<SbbTransparentButtonElement>(
'.sbb-alert__close-button-wrapper sbb-transparent-button',
)!;

closeButton.focus();
closeButton.click();
await waitForLitRender(element);
Expand Down Expand Up @@ -73,6 +73,8 @@ describe(`sbb-alert-group`, () => {

// Then the alert should be removed from sbb-alert-group, tabindex should be set to 0,
// focus should be on sbb-alert-group, accessibility title should be removed and empty event should be fired.
await waitForCondition(() => didDismissAlertSpy.events.length === 2);
expect(didDismissAlertSpy.count).to.be.equal(2);
expect(element.querySelectorAll('sbb-alert').length).to.be.equal(0);
expect(element.tabIndex).to.be.equal(0);
expect(document.activeElement!.id).to.be.equal(alertGroupId);
Expand All @@ -81,8 +83,8 @@ describe(`sbb-alert-group`, () => {
expect(didDismissAlertSpy.count).to.be.equal(2);
expect(emptySpy.count).to.be.greaterThan(0);

// When clicking away (simulated by blur event)
element.dispatchEvent(new CustomEvent('blur'));
// When clicking away
await sendMouse({ type: 'click', position: [0, 0] });
await waitForLitRender(element);

// Then the active element id should be unset and tabindex should be removed
Expand Down
27 changes: 16 additions & 11 deletions src/elements/alert/alert-group/alert-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,24 @@ export class SbbAlertGroupElement extends SbbHydrationMixin(LitElement) {

private _abort = new SbbConnectedAbortController(this);

private _removeAlert(event: Event): void {
public override connectedCallback(): void {
super.connectedCallback();
const signal = this._abort.signal;
this.addEventListener(
SbbAlertElement.events.dismissalRequested,
(e) => (e.target as SbbAlertElement).close(),
{
signal,
},
);
this.addEventListener(SbbAlertElement.events.didClose, (e) => this._alertClosed(e), {
signal,
});
}

private _alertClosed(event: Event): void {
const target = event.target as SbbAlertElement;
const hasFocusInsideAlertGroup = document.activeElement === target;

target.parentNode?.removeChild(target);
this._didDismissAlert.emit(target);

// Restore focus
Expand All @@ -77,14 +90,6 @@ export class SbbAlertGroupElement extends SbbHydrationMixin(LitElement) {
}
}

public override connectedCallback(): void {
super.connectedCallback();
const signal = this._abort.signal;
this.addEventListener(SbbAlertElement.events.dismissalRequested, (e) => this._removeAlert(e), {
signal,
});
}

private _slotChanged(event: Event): void {
const hadAlerts = this._hasAlerts;
this._hasAlerts =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export const snapshots = {};

snapshots["sbb-alert should render default properties DOM"] =
`<sbb-alert
animation="open"
animation="all"
data-state="opening"
size="m"
title-content="Interruption"
Expand Down Expand Up @@ -79,7 +79,7 @@ snapshots["sbb-alert should render default properties Shadow DOM"] =
snapshots["sbb-alert should render customized properties DOM"] =
`<sbb-alert
accessibility-label="label"
animation="open"
animation="all"
data-state="opening"
href="https://www.sbb.ch"
icon-name="disruption"
Expand Down
42 changes: 39 additions & 3 deletions src/elements/alert/alert/alert.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ $open-anim-opacity-to: 1;
--sbb-disable-animation-zero-time,
var(--sbb-animation-duration-6x)
);
--sbb-alert-timing-function: ease-in;

@include sbb.mq($from: medium) {
--sbb-alert-icon-size: #{sbb.px-to-rem-build(28)};
Expand All @@ -38,7 +39,12 @@ $open-anim-opacity-to: 1;
display: block;
}

:host([animation='none']) {
// By default, the open animation is disabled
:host([data-state='opening']:not([animation='open'], [animation='all'])) {
@include sbb.disable-animation;
}

:host([data-state='closing']:not([animation='close'], [animation='all'])) {
@include sbb.disable-animation;
}

Expand All @@ -60,7 +66,7 @@ $open-anim-opacity-to: 1;
grid-template-rows: #{$open-anim-rows-from};
opacity: #{$open-anim-opacity-from};

:host([data-state='opened']) & {
:host(:is([data-state='opened'], [data-state='closing'])) & {
grid-template-rows: #{$open-anim-rows-to};
opacity: #{$open-anim-opacity-to};
}
Expand All @@ -69,9 +75,19 @@ $open-anim-opacity-to: 1;
animation-name: open, open-opacity;
animation-fill-mode: forwards;
animation-duration: var(--sbb-alert-animation-duration);
animation-timing-function: ease-in;
animation-timing-function: var(--sbb-alert-timing-function);
animation-delay: 0s, var(--sbb-alert-animation-duration);
}

:host([data-state='closing']) & {
animation: {
name: close-opacity, close;
fill-mode: forwards;
duration: var(--sbb-alert-animation-duration);
timing-function: var(--sbb-alert-timing-function);
delay: 0s, var(--sbb-disable-animation-zero-time, var(--sbb-animation-duration-2x));
}
}
}

.sbb-alert__transition-sub-wrapper {
Expand Down Expand Up @@ -172,3 +188,23 @@ $open-anim-opacity-to: 1;
opacity: #{$open-anim-opacity-to};
}
}

@keyframes close {
from {
grid-template-rows: #{$open-anim-rows-to};
}

to {
grid-template-rows: #{$open-anim-rows-from};
}
}

@keyframes close-opacity {
from {
opacity: #{$open-anim-opacity-to};
}

to {
opacity: #{$open-anim-opacity-from};
}
}
16 changes: 15 additions & 1 deletion src/elements/alert/alert/alert.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,27 @@ describe(`sbb-alert`, () => {
it('should fire animation events', async () => {
const willOpenSpy = new EventSpy(SbbAlertElement.events.willOpen);
const didOpenSpy = new EventSpy(SbbAlertElement.events.didOpen);
const willCloseSpy = new EventSpy(SbbAlertElement.events.willClose);
const didCloseSpy = new EventSpy(SbbAlertElement.events.didClose);
const dismissalSpy = new EventSpy(SbbAlertElement.events.dismissalRequested);

await fixture(html`<sbb-alert title-content="disruption">Interruption</sbb-alert>`);
const alert: SbbAlertElement = await fixture(
html`<sbb-alert title-content="disruption">Interruption</sbb-alert>`,
);

await waitForCondition(() => willOpenSpy.events.length === 1);
expect(willOpenSpy.count).to.be.equal(1);
await waitForCondition(() => didOpenSpy.events.length === 1);
expect(didOpenSpy.count).to.be.equal(1);

alert.requestDismissal();
expect(dismissalSpy.count).to.be.equal(1);

alert.close();

await waitForCondition(() => didCloseSpy.events.length === 1);
expect(willCloseSpy.count).to.be.equal(1);
expect(didCloseSpy.count).to.be.equal(1);
});

it('should hide close button in readonly mode', async () => {
Expand Down
8 changes: 6 additions & 2 deletions src/elements/alert/alert/alert.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { SbbAlertElement } from './alert.js';
import readme from './readme.md?raw';

const Default = ({ 'content-slot-text': contentSlotText, ...args }: Args): TemplateResult => html`
<sbb-alert ${sbbSpread(args)}>${contentSlotText}</sbb-alert>
<sbb-alert
${sbbSpread(args)}
@dismissalRequested=${(e: Event) => (e.target! as SbbAlertElement).close()}
>${contentSlotText}</sbb-alert
>
`;

const DefaultWithOtherContent = (args: Args): TemplateResult => {
Expand Down Expand Up @@ -134,7 +138,7 @@ const animation: InputType = {
control: {
type: 'inline-radio',
},
options: ['open', 'none'],
options: ['all', 'open', 'close', 'none'],
};

const defaultArgTypes: ArgTypes = {
Expand Down
84 changes: 44 additions & 40 deletions src/elements/alert/alert/alert.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import {
type CSSResultGroup,
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { type CSSResultGroup, html, nothing, type PropertyValues, type TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js';

import type { LinkTargetType } from '../../core/base-elements.js';
import { type LinkTargetType, SbbOpenCloseBaseElement } from '../../core/base-elements.js';
import { SbbLanguageController } from '../../core/controllers.js';
import { EventEmitter } from '../../core/eventing.js';
import { i18nCloseAlert, i18nFindOutMore } from '../../core/i18n.js';
import type { SbbOpenedClosedState } from '../../core/interfaces.js';
import { SbbIconNameMixin } from '../../icon.js';
import type { SbbTitleLevel } from '../../title.js';

Expand All @@ -23,24 +15,26 @@ import '../../divider.js';
import '../../link.js';
import '../../title.js';

type SbbAlertState = Exclude<SbbOpenedClosedState, 'closing'>;

/**
* It displays messages which require user's attention.
*
* @slot - Use the unnamed slot to add content to the `sbb-alert`.
* @slot icon - Should be a `sbb-icon` which is displayed next to the title. Styling is optimized for icons of type HIM-CUS.
* @slot title - Title content.
* @event {CustomEvent<void>} willOpen - Emits when the fade in animation starts.
* @event {CustomEvent<void>} didOpen - Emits when the fade in animation ends and the button is displayed.
* @event {CustomEvent<void>} willOpen - Emits when the opening animation starts.
* @event {CustomEvent<void>} didOpen - Emits when the opening animation ends.
* @event {CustomEvent<void>} willClose - Emits when the closing animation starts. Can be canceled.
* @event {CustomEvent<void>} didClose - Emits when the closing animation ends.
* @event {CustomEvent<void>} dismissalRequested - Emits when dismissal of an alert was requested.
*/
@customElement('sbb-alert')
export class SbbAlertElement extends SbbIconNameMixin(LitElement) {
export class SbbAlertElement extends SbbIconNameMixin(SbbOpenCloseBaseElement) {
public static override styles: CSSResultGroup = style;
public static readonly events = {
public static override readonly events = {
willOpen: 'willOpen',
didOpen: 'didOpen',
willClose: 'willClose',
didClose: 'didClose',
dismissalRequested: 'dismissalRequested',
} as const;

Expand Down Expand Up @@ -82,23 +76,12 @@ export class SbbAlertElement extends SbbIconNameMixin(LitElement) {
@property({ attribute: 'accessibility-label' }) public accessibilityLabel: string | undefined;

/** The enabled animations. */
@property({ reflect: true }) public animation: 'open' | 'none' = 'open';

/** The state of the alert. */
private get _state(): SbbAlertState {
return (this.getAttribute('data-state') as SbbAlertState | null) ?? 'closed';
}
private set _state(value: SbbAlertState) {
this.setAttribute('data-state', value);
}
@property({ reflect: true }) public animation: 'open' | 'close' | 'all' | 'none' = 'all';

/** Emits when the fade in animation starts. */
private _willOpen: EventEmitter<void> = new EventEmitter(this, SbbAlertElement.events.willOpen);

/** Emits when the fade in animation ends and the button is displayed. */
private _didOpen: EventEmitter<void> = new EventEmitter(this, SbbAlertElement.events.didOpen);

/** Emits when dismissal of an alert was requested. */
/**
* Emits when dismissal of an alert was requested.
* @deprecated
*/
private _dismissalRequested: EventEmitter<void> = new EventEmitter(
this,
SbbAlertElement.events.dismissalRequested,
Expand All @@ -109,27 +92,48 @@ export class SbbAlertElement extends SbbIconNameMixin(LitElement) {
protected override async firstUpdated(changedProperties: PropertyValues<this>): Promise<void> {
super.firstUpdated(changedProperties);

this._open();
this.open();
}

/** Requests dismissal of the alert. */
/** Requests dismissal of the alert.
* @deprecated in favour of 'willClose' and 'didClose' events
*/
public requestDismissal(): void {
this._dismissalRequested.emit();
}

/** Open the alert. */
private _open(): void {
this._state = 'opening';
this._willOpen.emit();
public open(): void {
this.willOpen.emit();
this.state = 'opening';
}

/** Close the alert. */
public close(): void {
if (this.willClose.emit()) {
this.state = 'closing';
}
}

private _onAnimationEnd(event: AnimationEvent): void {
if (this._state === 'opening' && event.animationName === 'open-opacity') {
this._state = 'opened';
this._didOpen.emit();
if (this.state === 'opening' && event.animationName === 'open-opacity') {
this._handleOpening();
} else if (this.state === 'closing' && event.animationName === 'close') {
this._handleClosing();
}
}

private _handleOpening(): void {
this.state = 'opened';
this.didOpen.emit();
}

private _handleClosing(): void {
this.state = 'closed';
this.didClose.emit();
setTimeout(() => this.remove());
}

protected override render(): TemplateResult {
return html`
<div class="sbb-alert__transition-wrapper" @animationend=${this._onAnimationEnd}>
Expand Down
Loading

0 comments on commit 581b95c

Please sign in to comment.