diff --git a/src/elements/alert/alert-group/__snapshots__/alert-group.snapshot.spec.snap.js b/src/elements/alert/alert-group/__snapshots__/alert-group.snapshot.spec.snap.js index aa506dc2e5..1602b0d3a6 100644 --- a/src/elements/alert/alert-group/__snapshots__/alert-group.snapshot.spec.snap.js +++ b/src/elements/alert/alert-group/__snapshots__/alert-group.snapshot.spec.snap.js @@ -9,7 +9,7 @@ snapshots["sbb-alert-group renders DOM"] = > @@ -42,7 +42,7 @@ snapshots["sbb-alert-group renders with slotted DOM"] = diff --git a/src/elements/alert/alert-group/alert-group.spec.ts b/src/elements/alert/alert-group/alert-group.spec.ts index 59297a1430..8f800b5e83 100644 --- a/src/elements/alert/alert-group/alert-group.spec.ts +++ b/src/elements/alert/alert-group/alert-group.spec.ts @@ -17,6 +17,10 @@ describe(`sbb-alert-group`, () => { const accessibilityTitle = 'Disruptions'; const accessibilityTitleLevel = '3'; + const alertOpenedEventSpy = new EventSpy(SbbAlertElement.events.didOpen, null, { + capture: true, + }); + // Given sbb-alert-group with two alerts element = await fixture(html` { Second `); + const emptySpy = new EventSpy(SbbAlertGroupElement.events.empty); const alert1 = element.querySelector('sbb-alert#alert1')!; const alert2 = element.querySelector('sbb-alert#alert2')!; const alert1ClosedEventSpy = new EventSpy(SbbAlertElement.events.didClose, alert1); const alert2ClosedEventSpy = new EventSpy(SbbAlertElement.events.didClose, alert2); - await new EventSpy(SbbAlertElement.events.didOpen, alert1).calledOnce(); - await new EventSpy(SbbAlertElement.events.didOpen, alert2).calledOnce(); + // Wait until both alerts are opened + await alertOpenedEventSpy.calledTimes(2); // Then two alerts should be rendered and accessibility title should be displayed expect(element.querySelectorAll('sbb-alert').length).to.be.equal(2); diff --git a/src/elements/alert/alert/__snapshots__/alert.snapshot.spec.snap.js b/src/elements/alert/alert/__snapshots__/alert.snapshot.spec.snap.js index f894df87bd..48b21e0dae 100644 --- a/src/elements/alert/alert/__snapshots__/alert.snapshot.spec.snap.js +++ b/src/elements/alert/alert/__snapshots__/alert.snapshot.spec.snap.js @@ -4,7 +4,7 @@ export const snapshots = {}; snapshots["sbb-alert should render default properties DOM"] = ` @@ -77,7 +77,7 @@ snapshots["sbb-alert should render default properties Shadow DOM"] = snapshots["sbb-alert should render customized properties DOM"] = ` { expect(didCloseSpy.count).to.be.equal(1); }); + it('should fire animation events with non-zero animation duration', async () => { + const didOpenSpy = new EventSpy(SbbAlertElement.events.didOpen, null, { capture: true }); + const didCloseSpy = new EventSpy(SbbAlertElement.events.didClose, null, { capture: true }); + + const alert: SbbAlertElement = await fixture( + html` + Interruption + `, + ); + + await didOpenSpy.calledOnce(); + + alert.close(); + + await didCloseSpy.calledOnce(); + expect(didCloseSpy.count).to.be.equal(1); + }); + it('should respect canceled willClose event', async () => { const didOpenSpy = new EventSpy(SbbAlertElement.events.didOpen, null, { capture: true }); const willCloseSpy = new EventSpy(SbbAlertElement.events.willClose, null, { capture: true }); diff --git a/src/elements/alert/alert/alert.ts b/src/elements/alert/alert/alert.ts index 2efd00cf37..5115d1bf9a 100644 --- a/src/elements/alert/alert/alert.ts +++ b/src/elements/alert/alert/alert.ts @@ -4,7 +4,7 @@ import { customElement, property } from 'lit/decorators.js'; import { SbbOpenCloseBaseElement } from '../../core/base-elements.js'; import { SbbLanguageController } from '../../core/controllers.js'; import { forceType } from '../../core/decorators.js'; -import { isLean } from '../../core/dom.js'; +import { isLean, isZeroAnimationDuration } from '../../core/dom.js'; import { i18nCloseAlert } from '../../core/i18n.js'; import { SbbIconNameMixin } from '../../icon.js'; import type { SbbTitleLevel } from '../../title.js'; @@ -75,14 +75,26 @@ class SbbAlertElement extends SbbIconNameMixin(SbbOpenCloseBaseElement) { /** Open the alert. */ public open(): void { - this.willOpen.emit(); this.state = 'opening'; + this.willOpen.emit(); + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `opened` state. + if (this._isZeroAnimationDuration()) { + this._handleOpening(); + } } /** Close the alert. */ public close(): void { if (this.state === 'opened' && this.willClose.emit()) { this.state = 'closing'; + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `closed` state. + if (this._isZeroAnimationDuration()) { + this._handleClosing(); + } } } @@ -92,6 +104,10 @@ class SbbAlertElement extends SbbIconNameMixin(SbbOpenCloseBaseElement) { this.open(); } + private _isZeroAnimationDuration(): boolean { + return isZeroAnimationDuration(this, '--sbb-alert-animation-duration'); + } + private _onAnimationEnd(event: AnimationEvent): void { if (this.state === 'opening' && event.animationName === 'open-opacity') { this._handleOpening(); diff --git a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts index fef7f2791d..1598465362 100644 --- a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts +++ b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.spec.ts @@ -1,4 +1,4 @@ -import { assert, expect } from '@open-wc/testing'; +import { assert, aTimeout, expect } from '@open-wc/testing'; import { sendKeys, sendMouse } from '@web/test-runner-commands'; import { html } from 'lit/static-html.js'; @@ -25,9 +25,9 @@ describe(`sbb-autocomplete-grid`, () => { - Option 1 + + Option 1 + { - Option 2 + + Option 2 + { expect(input).to.have.attribute('role', 'combobox'); expect(input).to.have.attribute('aria-autocomplete', 'list'); expect(input).to.have.attribute('aria-haspopup', 'grid'); - expect(input).to.have.attribute('aria-controls', 'myAutocomplete'); - expect(input).to.have.attribute('aria-owns', 'myAutocomplete'); + expect(input).to.have.attribute('aria-controls', element.id); + expect(input).to.have.attribute('aria-owns', element.id); expect(input).to.have.attribute('aria-expanded', 'false'); }); }); @@ -83,12 +83,14 @@ describe(`sbb-autocomplete-grid`, () => { expect(element).not.to.have.attribute('autocomplete-origin-borderless'); + const id = element.shadowRoot!.querySelector('.sbb-autocomplete__options')!.id; + expect(input).to.have.attribute('autocomplete', 'off'); expect(input).to.have.attribute('role', 'combobox'); expect(input).to.have.attribute('aria-autocomplete', 'list'); expect(input).to.have.attribute('aria-haspopup', 'grid'); - expect(input).to.have.attribute('aria-controls', 'sbb-autocomplete-grid-11'); - expect(input).to.have.attribute('aria-owns', 'sbb-autocomplete-grid-11'); + expect(input).to.have.attribute('aria-controls', id); + expect(input).to.have.attribute('aria-owns', id); expect(input).to.have.attribute('aria-expanded', 'false'); }); }); @@ -99,7 +101,8 @@ describe(`sbb-autocomplete-grid`, () => { const willCloseEventSpy = new EventSpy(SbbAutocompleteGridElement.events.willClose, element); const didCloseEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didClose, element); - input.click(); + input.focus(); + await willOpenEventSpy.calledOnce(); expect(willOpenEventSpy.count).to.be.equal(1); @@ -145,15 +148,34 @@ describe(`sbb-autocomplete-grid`, () => { expect(input).to.have.attribute('aria-expanded', 'false'); }); + it('opens and closes with non-zero animation duration', async () => { + element.style.setProperty('--sbb-options-panel-animation-duration', '1ms'); + const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen, element); + const didCloseEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didClose, element); + + input.focus(); + + await didOpenEventSpy.calledOnce(); + expect(input).to.have.attribute('aria-expanded', 'true'); + + await sendKeys({ press: 'Escape' }); + await didCloseEventSpy.calledOnce(); + + expect(input).to.have.attribute('aria-expanded', 'false'); + }); + it('select by mouse', async () => { const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen, element); const optionSelectedEventSpy = new EventSpy( SbbAutocompleteGridOptionElement.events.optionSelected, ); + const inputEventSpy = new EventSpy('input', input); + const changeEventSpy = new EventSpy('change', input); const optTwo = element.querySelector('#option-2')!; input.focus(); await didOpenEventSpy.calledOnce(); + const positionRect = optTwo.getBoundingClientRect(); await sendMouse({ @@ -165,8 +187,11 @@ describe(`sbb-autocomplete-grid`, () => { }); await waitForLitRender(element); + expect(inputEventSpy.count).to.be.equal(1); + expect(changeEventSpy.count).to.be.equal(1); expect(optionSelectedEventSpy.count).to.be.equal(1); expect(optionSelectedEventSpy.firstEvent!.target).to.have.property('id', 'option-2'); + expect(document.activeElement).to.be.equal(input); }); it('select button and get related option', async () => { @@ -187,7 +212,7 @@ describe(`sbb-autocomplete-grid`, () => { await clickSpy.calledOnce(); expect(clickSpy.count).to.be.equal(1); expect( - (clickSpy.firstEvent!.target as SbbAutocompleteGridButtonElement).option!.textContent, + (clickSpy.firstEvent!.target as SbbAutocompleteGridButtonElement).option!.textContent!.trim(), ).to.be.equal('Option 1'); expect( (clickSpy.firstEvent!.target as SbbAutocompleteGridButtonElement).option!.value, @@ -244,8 +269,10 @@ describe(`sbb-autocomplete-grid`, () => { const optionSelectedEventSpy = new EventSpy( SbbAutocompleteGridOptionElement.events.optionSelected, ); - const optOne = element.querySelector('#option-1'); - const optTwo = element.querySelector('#option-2'); + const inputEventSpy = new EventSpy('input', input); + const changeEventSpy = new EventSpy('change', input); + const optOne = element.querySelector('#option-1'); + const optTwo = element.querySelector('#option-2'); const keydownSpy = new EventSpy('keydown', input); input.focus(); @@ -269,11 +296,27 @@ describe(`sbb-autocomplete-grid`, () => { expect(optTwo).not.to.have.attribute('data-active'); expect(optTwo).to.have.attribute('selected'); + expect(inputEventSpy.count).to.be.equal(1); + expect(changeEventSpy.count).to.be.equal(1); expect(optionSelectedEventSpy.count).to.be.equal(1); expect(input).to.have.attribute('aria-expanded', 'false'); expect(input).not.to.have.attribute('aria-activedescendant'); }); + it('should not close on disabled option click', async () => { + const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen, element); + const optOne = element.querySelector('#option-1')!; + optOne.disabled = true; + + input.focus(); + await didOpenEventSpy.calledOnce(); + + optOne.click(); + + await aTimeout(0); + expect(element).to.have.attribute('data-state', 'opened'); + }); + it('opens and select button with keyboard', async () => { const didOpenEventSpy = new EventSpy(SbbAutocompleteGridElement.events.didOpen, element); const clickSpy = new EventSpy('click'); @@ -307,10 +350,11 @@ describe(`sbb-autocomplete-grid`, () => { await sendKeys({ press: 'Enter' }); await clickSpy.calledTimes(2); expect(clickSpy.count).to.be.equal(2); + expect(element).to.have.attribute('data-state', 'opened'); }); it('should stay closed when disabled', async () => { - input.setAttribute('disabled', ''); + input.toggleAttribute('disabled', true); input.focus(); await waitForLitRender(element); @@ -326,7 +370,7 @@ describe(`sbb-autocomplete-grid`, () => { }); it('should stay closed when readonly', async () => { - input.setAttribute('readonly', ''); + input.toggleAttribute('readonly', true); input.focus(); await waitForLitRender(element); diff --git a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts index 249bc26c8f..2fdfff6298 100644 --- a/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts +++ b/src/elements/autocomplete-grid/autocomplete-grid/autocomplete-grid.ts @@ -6,7 +6,7 @@ import { hostAttributes } from '../../core/decorators.js'; import { isSafari } from '../../core/dom.js'; import { setAriaComboBoxAttributes } from '../../core/overlay.js'; import type { SbbDividerElement } from '../../divider.js'; -import type { SbbOptGroupElement, SbbOptionElement } from '../../option.js'; +import type { SbbOptGroupElement } from '../../option.js'; import type { SbbAutocompleteGridButtonElement } from '../autocomplete-grid-button.js'; import { SbbAutocompleteGridOptionElement } from '../autocomplete-grid-option.js'; import type { SbbAutocompleteGridRowElement } from '../autocomplete-grid-row.js'; @@ -54,16 +54,6 @@ class SbbAutocompleteGridElement extends SbbAutocompleteBaseElement { ); } - protected onOptionClick(event: MouseEvent): void { - if ( - (event.target as Element).localName !== 'sbb-autocomplete-grid-option' || - (event.target as SbbOptionElement).disabled - ) { - return; - } - this.close(); - } - public override connectedCallback(): void { super.connectedCallback(); const signal = this.abort.signal; diff --git a/src/elements/autocomplete/autocomplete-base-element.ts b/src/elements/autocomplete/autocomplete-base-element.ts index 613efb10a2..07f456bb37 100644 --- a/src/elements/autocomplete/autocomplete-base-element.ts +++ b/src/elements/autocomplete/autocomplete-base-element.ts @@ -12,7 +12,7 @@ import { ref } from 'lit/directives/ref.js'; import { SbbOpenCloseBaseElement } from '../core/base-elements.js'; import { SbbConnectedAbortController } from '../core/controllers.js'; import { forceType } from '../core/decorators.js'; -import { findReferencedElement, isSafari } from '../core/dom.js'; +import { findReferencedElement, isSafari, isZeroAnimationDuration } from '../core/dom.js'; import { SbbNegativeMixin, SbbHydrationMixin } from '../core/mixins.js'; import { isEventOnElement, @@ -85,7 +85,6 @@ export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( protected abstract selectByKeyboard(event: KeyboardEvent): void; protected abstract setNextActiveOption(event: KeyboardEvent): void; protected abstract resetActiveElement(): void; - protected abstract onOptionClick(event: MouseEvent): void; /** Opens the autocomplete. */ public open(): void { @@ -103,6 +102,12 @@ export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( this.state = 'opening'; this._setOverlayPosition(); + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `opened` state. + if (this._isZeroAnimationDuration()) { + this._handleOpening(); + } } /** Closes the autocomplete. */ @@ -116,6 +121,16 @@ export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( this.state = 'closing'; this._openPanelEventsController.abort(); + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `closed` state. + if (this._isZeroAnimationDuration()) { + this._handleClosing(); + } + } + + private _isZeroAnimationDuration(): boolean { + return isZeroAnimationDuration(this, '--sbb-options-panel-animation-duration'); } public override connectedCallback(): void { @@ -123,7 +138,6 @@ export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( if (ariaRoleOnHost) { this.id ||= this.overlayId; } - const signal = this.abort.signal; const formField = this.closest('sbb-form-field') ?? this.closest('[data-form-field]'); if (formField) { @@ -134,8 +148,6 @@ export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( this._componentSetup(); } this.syncNegative(); - - this.addEventListener('click', (e: MouseEvent) => this.onOptionClick(e), { signal }); } protected override willUpdate(changedProperties: PropertyValues): void { @@ -187,6 +199,7 @@ export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( // Manually trigger the change events this.triggerElement.dispatchEvent(new Event('change', { bubbles: true })); this.triggerElement.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + this.triggerElement.focus(); } this.close(); @@ -309,26 +322,27 @@ export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin( ); } - /** On open/close animation end. - * In rare cases it can be that the animationEnd event is triggered twice. - * To avoid entering a corrupt state, exit when state is not expected. + /** + * On open/close animation end. + * In rare cases it can be that the animationEnd event is triggered twice. + * To avoid entering a corrupt state, exit when state is not expected. */ private _onAnimationEnd(event: AnimationEvent): void { if (event.animationName === 'open' && this.state === 'opening') { - this._onOpenAnimationEnd(); + this._handleOpening(); } else if (event.animationName === 'close' && this.state === 'closing') { - this._onCloseAnimationEnd(); + this._handleClosing(); } } - private _onOpenAnimationEnd(): void { + private _handleOpening(): void { this.state = 'opened'; this._attachOpenPanelEvents(); this.triggerElement?.setAttribute('aria-expanded', 'true'); this.didOpen.emit(); } - private _onCloseAnimationEnd(): void { + private _handleClosing(): void { this.state = 'closed'; this.triggerElement?.setAttribute('aria-expanded', 'false'); this.resetActiveElement(); diff --git a/src/elements/autocomplete/autocomplete.spec.ts b/src/elements/autocomplete/autocomplete.spec.ts index 8aa4ae589e..76257118f0 100644 --- a/src/elements/autocomplete/autocomplete.spec.ts +++ b/src/elements/autocomplete/autocomplete.spec.ts @@ -1,4 +1,4 @@ -import { assert, expect } from '@open-wc/testing'; +import { assert, aTimeout, expect } from '@open-wc/testing'; import { sendKeys, sendMouse } from '@web/test-runner-commands'; import { html } from 'lit/static-html.js'; @@ -40,8 +40,8 @@ describe(`sbb-autocomplete`, () => { expect(input).to.have.attribute('role', 'combobox'); expect(input).to.have.attribute('aria-autocomplete', 'list'); expect(input).to.have.attribute('aria-haspopup', 'listbox'); - expect(input).to.have.attribute('aria-controls', 'myAutocomplete'); - expect(input).to.have.attribute('aria-owns', 'myAutocomplete'); + expect(input).to.have.attribute('aria-controls', element.id); + expect(input).to.have.attribute('aria-owns', element.id); expect(input).to.have.attribute('aria-expanded', 'false'); }); }); @@ -53,12 +53,14 @@ describe(`sbb-autocomplete`, () => { expect(element).not.to.have.attribute('autocomplete-origin-borderless'); + const id = element.shadowRoot!.querySelector('.sbb-autocomplete__options')!.id; + expect(input).to.have.attribute('autocomplete', 'off'); expect(input).to.have.attribute('role', 'combobox'); expect(input).to.have.attribute('aria-autocomplete', 'list'); expect(input).to.have.attribute('aria-haspopup', 'listbox'); - expect(input).to.have.attribute('aria-controls', 'sbb-autocomplete-8'); - expect(input).to.have.attribute('aria-owns', 'sbb-autocomplete-8'); + expect(input).to.have.attribute('aria-controls', id); + expect(input).to.have.attribute('aria-owns', id); expect(input).to.have.attribute('aria-expanded', 'false'); }); }); @@ -69,7 +71,8 @@ describe(`sbb-autocomplete`, () => { const willCloseEventSpy = new EventSpy(SbbAutocompleteElement.events.willClose, element); const didCloseEventSpy = new EventSpy(SbbAutocompleteElement.events.didClose, element); - input.click(); + input.focus(); + await willOpenEventSpy.calledOnce(); expect(willOpenEventSpy.count).to.be.equal(1); @@ -115,6 +118,22 @@ describe(`sbb-autocomplete`, () => { expect(input).to.have.attribute('aria-expanded', 'false'); }); + it('opens and closes with non-zero animation duration', async () => { + element.style.setProperty('--sbb-options-panel-animation-duration', '1ms'); + const didOpenEventSpy = new EventSpy(SbbAutocompleteElement.events.didOpen, element); + const didCloseEventSpy = new EventSpy(SbbAutocompleteElement.events.didClose, element); + + input.focus(); + + await didOpenEventSpy.calledOnce(); + expect(input).to.have.attribute('aria-expanded', 'true'); + + await sendKeys({ press: 'Escape' }); + await didCloseEventSpy.calledOnce(); + + expect(input).to.have.attribute('aria-expanded', 'false'); + }); + it('select by mouse', async () => { const didOpenEventSpy = new EventSpy(SbbAutocompleteElement.events.didOpen, element); const optionSelectedEventSpy = new EventSpy(SbbOptionElement.events.optionSelected); @@ -140,6 +159,7 @@ describe(`sbb-autocomplete`, () => { expect(changeEventSpy.count).to.be.equal(1); expect(optionSelectedEventSpy.count).to.be.equal(1); expect(optionSelectedEventSpy.firstEvent!.target).to.have.property('id', 'option-2'); + expect(document.activeElement).to.be.equal(input); }); it('opens and select with keyboard', async () => { @@ -148,8 +168,8 @@ describe(`sbb-autocomplete`, () => { const optionSelectedEventSpy = new EventSpy(SbbOptionElement.events.optionSelected); const inputEventSpy = new EventSpy('input', input); const changeEventSpy = new EventSpy('change', input); - const optOne = element.querySelector('#option-1'); - const optTwo = element.querySelector('#option-2'); + const optOne = element.querySelector('#option-1'); + const optTwo = element.querySelector('#option-2'); const keydownSpy = new EventSpy('keydown', input); input.focus(); @@ -180,6 +200,20 @@ describe(`sbb-autocomplete`, () => { expect(input).not.to.have.attribute('aria-activedescendant'); }); + it('should not close on disabled option click', async () => { + const didOpenEventSpy = new EventSpy(SbbAutocompleteElement.events.didOpen, element); + const optOne = element.querySelector('#option-1')!; + optOne.disabled = true; + + input.focus(); + await didOpenEventSpy.calledOnce(); + + optOne.click(); + + await aTimeout(0); + expect(element).to.have.attribute('data-state', 'opened'); + }); + it('should stay closed when disabled', async () => { input.toggleAttribute('disabled', true); diff --git a/src/elements/autocomplete/autocomplete.ts b/src/elements/autocomplete/autocomplete.ts index 696790057c..27a09dd50a 100644 --- a/src/elements/autocomplete/autocomplete.ts +++ b/src/elements/autocomplete/autocomplete.ts @@ -42,16 +42,6 @@ class SbbAutocompleteElement extends SbbAutocompleteBaseElement { return Array.from(this.querySelectorAll?.('sbb-option') ?? []); } - protected onOptionClick(event: MouseEvent): void { - if ( - (event.target as Element).localName !== 'sbb-option' || - (event.target as SbbOptionElement).disabled - ) { - return; - } - this.close(); - } - public override connectedCallback(): void { super.connectedCallback(); const signal = this.abort.signal; diff --git a/src/elements/button/common/button-common.scss b/src/elements/button/common/button-common.scss index f07885d73a..254bd06e44 100644 --- a/src/elements/button/common/button-common.scss +++ b/src/elements/button/common/button-common.scss @@ -31,7 +31,7 @@ $active: ':active, [data-active]'; --sbb-button-border-radius: var(--sbb-border-radius-infinity); --sbb-button-min-height: var(--sbb-size-element-m); --sbb-button-transition-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-2x) ); --sbb-button-transition-easing-function: var(--sbb-animation-easing); diff --git a/src/elements/calendar/calendar.scss b/src/elements/calendar/calendar.scss index 49fcd37e20..68307924bf 100644 --- a/src/elements/calendar/calendar.scss +++ b/src/elements/calendar/calendar.scss @@ -22,7 +22,7 @@ --sbb-calendar-cell-disabled-height: #{sbb.px-to-rem-build(1.5)}; --sbb-calendar-cell-disabled-width: #{sbb.px-to-rem-build(25.5)}; --sbb-calendar-cell-transition-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-2x) ); --sbb-calendar-cell-transition-easing-function: var(--sbb-animation-easing); diff --git a/src/elements/clock/clock.scss b/src/elements/clock/clock.scss index 88fc7bae08..8c3ba96bf1 100644 --- a/src/elements/clock/clock.scss +++ b/src/elements/clock/clock.scss @@ -36,7 +36,7 @@ } .sbb-clock__hand-minutes { - transition: transform var(--sbb-disable-animation-zero-duration, 0.2s) + transition: transform var(--sbb-disable-animation-duration, 0.2s) cubic-bezier(0.4, 2.08, 0.55, 0.44); @supports not (aspect-ratio: 1 / 1) { diff --git a/src/elements/container/sticky-bar/sticky-bar.scss b/src/elements/container/sticky-bar/sticky-bar.scss index 9053717fe9..be3b7b135b 100644 --- a/src/elements/container/sticky-bar/sticky-bar.scss +++ b/src/elements/container/sticky-bar/sticky-bar.scss @@ -16,15 +16,15 @@ $intersector-overlapping: 1px; --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, + --sbb-disable-animation-duration, var(--sbb-animation-duration-5x) ); --sbb-sticky-bar-fade-out-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-2x) ); --sbb-sticky-bar-slide-vertically-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-4x) ); --sbb-sticky-bar-slide-vertically-animation-easing: ease-out; diff --git a/src/elements/container/sticky-bar/sticky-bar.spec.ts b/src/elements/container/sticky-bar/sticky-bar.spec.ts index a88d86fac0..b092b013e0 100644 --- a/src/elements/container/sticky-bar/sticky-bar.spec.ts +++ b/src/elements/container/sticky-bar/sticky-bar.spec.ts @@ -139,6 +139,21 @@ describe(`sbb-sticky-bar`, () => { expect(willStickSpy.count).to.be.equal(1); expect(didStickSpy.count).to.be.equal(0); }); + + it('works with non-zero animation duration', async () => { + stickyBar.style.setProperty('--sbb-sticky-bar-slide-vertically-animation-duration', '1ms'); + + 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('is settled when content is not long enough', async () => { diff --git a/src/elements/container/sticky-bar/sticky-bar.ts b/src/elements/container/sticky-bar/sticky-bar.ts index 9e26a9e2e3..b87d1743ed 100644 --- a/src/elements/container/sticky-bar/sticky-bar.ts +++ b/src/elements/container/sticky-bar/sticky-bar.ts @@ -9,6 +9,7 @@ import { import { customElement, property } from 'lit/decorators.js'; import { hostAttributes } from '../../core/decorators.js'; +import { isZeroAnimationDuration } from '../../core/dom.js'; import { EventEmitter } from '../../core/eventing.js'; import { SbbUpdateSchedulerMixin } from '../../core/mixins.js'; @@ -114,6 +115,10 @@ class SbbStickyBarElement extends SbbUpdateSchedulerMixin(LitElement) { this._observer.observe(this); } + private _isZeroAnimationDuration(): boolean { + return isZeroAnimationDuration(this, '--sbb-sticky-bar-slide-vertically-animation-duration'); + } + private _detectStickyState(entry: IntersectionObserverEntry): void { this.toggleAttribute('data-initialized', true); @@ -152,7 +157,7 @@ class SbbStickyBarElement extends SbbUpdateSchedulerMixin(LitElement) { } this._state = 'sticking'; - if (!this.hasAttribute('data-sticking')) { + if (!this.hasAttribute('data-sticking') || this._isZeroAnimationDuration()) { this._stickyCallback(); } } @@ -165,7 +170,7 @@ class SbbStickyBarElement extends SbbUpdateSchedulerMixin(LitElement) { this._state = 'unsticking'; - if (!this.hasAttribute('data-sticking')) { + if (!this.hasAttribute('data-sticking') || this._isZeroAnimationDuration()) { this._unstickyCallback(); } } diff --git a/src/elements/core/dom.ts b/src/elements/core/dom.ts index 472d088fa2..b5b1cdc86a 100644 --- a/src/elements/core/dom.ts +++ b/src/elements/core/dom.ts @@ -1,3 +1,4 @@ +export * from './dom/animation.js'; export * from './dom/breakpoint.js'; export * from './dom/find-referenced-element.js'; export * from './dom/host-context.js'; diff --git a/src/elements/core/dom/animation.ts b/src/elements/core/dom/animation.ts new file mode 100644 index 0000000000..dd97e383f6 --- /dev/null +++ b/src/elements/core/dom/animation.ts @@ -0,0 +1,10 @@ +import { isServer } from 'lit'; + +export function isZeroAnimationDuration(element: HTMLElement, cssVariableName: string): boolean { + if (isServer) { + return true; + } + const animationDuration = getComputedStyle(element).getPropertyValue(cssVariableName); + + return parseFloat(animationDuration) === 0; +} diff --git a/src/elements/core/mixins/panel-common.scss b/src/elements/core/mixins/panel-common.scss index fbd819aab2..b160b486cf 100644 --- a/src/elements/core/mixins/panel-common.scss +++ b/src/elements/core/mixins/panel-common.scss @@ -20,7 +20,7 @@ --sbb-selection-panel-input-padding: var(--sbb-spacing-responsive-xs) var(--sbb-spacing-responsive-xxs); --sbb-selection-panel-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-4x) ); --sbb-selection-panel-cursor: pointer; diff --git a/src/elements/core/styles/mixins/animation.scss b/src/elements/core/styles/mixins/animation.scss index 38bff8a200..ba54aa0bc3 100644 --- a/src/elements/core/styles/mixins/animation.scss +++ b/src/elements/core/styles/mixins/animation.scss @@ -3,11 +3,9 @@ // ---------------------------------------------------------------------------------------------------- @mixin disable-animation { - --sbb-disable-animation-duration: 0.1ms; - --sbb-disable-animation-zero-duration: 0s; + --sbb-disable-animation-duration: 0s; } @mixin enable-animation { --sbb-disable-animation-duration: initial; - --sbb-disable-animation-zero-duration: initial; } diff --git a/src/elements/core/styles/mixins/buttons.scss b/src/elements/core/styles/mixins/buttons.scss index 9b7e8a426b..e1af1525df 100644 --- a/src/elements/core/styles/mixins/buttons.scss +++ b/src/elements/core/styles/mixins/buttons.scss @@ -67,7 +67,7 @@ $active: ':active, [data-active]'; --sbb-button-border-disabled-style: dashed; --sbb-button-border-radius: var(--sbb-border-radius-infinity); --sbb-button-transition-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-2x) ); --sbb-button-transition-easing-function: var(--sbb-animation-easing); diff --git a/src/elements/core/styles/mixins/card.scss b/src/elements/core/styles/mixins/card.scss index 11671e4404..805489d131 100644 --- a/src/elements/core/styles/mixins/card.scss +++ b/src/elements/core/styles/mixins/card.scss @@ -13,7 +13,7 @@ --sbb-card-background-color: var(--sbb-color-white); --sbb-card-border-radius: var(--sbb-border-radius-4x); --sbb-card-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-1x) ); --sbb-card-animation-easing: var(--sbb-animation-easing); diff --git a/src/elements/core/styles/mixins/panel.scss b/src/elements/core/styles/mixins/panel.scss index b52efdf09d..b43310896c 100644 --- a/src/elements/core/styles/mixins/panel.scss +++ b/src/elements/core/styles/mixins/panel.scss @@ -19,7 +19,7 @@ --sbb-panel-padding-inline: var(--sbb-spacing-responsive-m); --sbb-panel-gap: var(--sbb-spacing-responsive-xs); --sbb-panel-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-4x) ); --sbb-panel-animation-easing: var(--sbb-animation-easing); diff --git a/src/elements/dialog/dialog/__snapshots__/dialog.snapshot.spec.snap.js b/src/elements/dialog/dialog/__snapshots__/dialog.snapshot.spec.snap.js index ff3ca62d7f..7c120a2cd2 100644 --- a/src/elements/dialog/dialog/__snapshots__/dialog.snapshot.spec.snap.js +++ b/src/elements/dialog/dialog/__snapshots__/dialog.snapshot.spec.snap.js @@ -2,7 +2,7 @@ export const snapshots = {}; snapshots["sbb-dialog renders an open dialog DOM"] = -` +` { expect(element).to.have.attribute('data-state', 'closed'); }); + it('opens and closes the overlay with non-zero animation duration', async () => { + element.style.setProperty('--sbb-dialog-animation-duration', '1ms'); + + const willClose = new EventSpy(SbbDialogElement.events.willClose, element); + const didClose = new EventSpy(SbbDialogElement.events.didClose, element); + + await openDialog(element); + + element.close(); + await waitForLitRender(element); + + await willClose.calledOnce(); + expect(willClose.count).to.be.equal(1); + await waitForLitRender(element); + + await didClose.calledOnce(); + expect(didClose.count).to.be.equal(1); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-state', 'closed'); + }); + it('does not close the dialog on other overlay click', async () => { await setViewport({ width: 900, height: 600 }); element = await fixture(html` diff --git a/src/elements/dialog/dialog/dialog.ts b/src/elements/dialog/dialog/dialog.ts index 166a293d35..a84dd0a41e 100644 --- a/src/elements/dialog/dialog/dialog.ts +++ b/src/elements/dialog/dialog/dialog.ts @@ -4,7 +4,7 @@ import { customElement, property } from 'lit/decorators.js'; import { html } from 'lit/static-html.js'; import { getFirstFocusableElement, setModalityOnNextFocus } from '../../core/a11y.js'; -import { isBreakpoint } from '../../core/dom.js'; +import { isBreakpoint, isZeroAnimationDuration } from '../../core/dom.js'; import { overlayRefs, SbbOverlayBaseElement } from '../../overlay.js'; import type { SbbDialogActionsElement } from '../dialog-actions.js'; import type { SbbDialogTitleElement } from '../dialog-title.js'; @@ -82,6 +82,55 @@ class SbbDialogElement extends SbbOverlayBaseElement { // Disable scrolling for content below the dialog this.scrollHandler.disableScroll(); + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `opened` state. + if (this.isZeroAnimationDuration()) { + this._handleOpening(); + } + } + + protected isZeroAnimationDuration(): boolean { + return isZeroAnimationDuration(this, '--sbb-dialog-animation-duration'); + } + + protected handleClosing(): void { + this._setHideHeaderDataAttribute(false); + this._dialogContentElement?.scrollTo(0, 0); + this.state = 'closed'; + this.inertController.deactivate(); + setModalityOnNextFocus(this.lastFocusedElement); + // Manually focus last focused element + this.lastFocusedElement?.focus(); + this.openOverlayController?.abort(); + this.focusHandler.disconnect(); + if (this._dialogContentElement) { + this._dialogContentResizeObserver.unobserve(this._dialogContentElement); + } + this.removeInstanceFromGlobalCollection(); + // Enable scrolling for content below the dialog if no dialog is open + if (!overlayRefs.length) { + this.scrollHandler.enableScroll(); + } + this.didClose.emit({ + returnValue: this.returnValue, + closeTarget: this.overlayCloseElement, + }); + } + + private _handleOpening(): void { + this.state = 'opened'; + this.didOpen.emit(); + this.inertController.activate(); + this.attachOpenOverlayEvents(); + this.setOverlayFocus(); + // Use timeout to read label after focused element + setTimeout(() => + this.setAriaLiveRefContent( + this.accessibilityLabel || this._dialogTitleElement?.innerText.trim(), + ), + ); + this.focusHandler.trap(this); } public override connectedCallback(): void { @@ -129,40 +178,9 @@ class SbbDialogElement extends SbbOverlayBaseElement { // To avoid entering a corrupt state, exit when state is not expected. protected onOverlayAnimationEnd(event: AnimationEvent): void { if (event.animationName === 'open' && this.state === 'opening') { - this.state = 'opened'; - this.didOpen.emit(); - this.inertController.activate(); - this.attachOpenOverlayEvents(); - this.setOverlayFocus(); - // Use timeout to read label after focused element - setTimeout(() => - this.setAriaLiveRefContent( - this.accessibilityLabel || this._dialogTitleElement?.innerText.trim(), - ), - ); - this.focusHandler.trap(this); + this._handleOpening(); } else if (event.animationName === 'close' && this.state === 'closing') { - this._setHideHeaderDataAttribute(false); - this._dialogContentElement?.scrollTo(0, 0); - this.state = 'closed'; - this.inertController.deactivate(); - setModalityOnNextFocus(this.lastFocusedElement); - // Manually focus last focused element - this.lastFocusedElement?.focus(); - this.openOverlayController?.abort(); - this.focusHandler.disconnect(); - if (this._dialogContentElement) { - this._dialogContentResizeObserver.unobserve(this._dialogContentElement); - } - this.removeInstanceFromGlobalCollection(); - // Enable scrolling for content below the dialog if no dialog is open - if (!overlayRefs.length) { - this.scrollHandler.enableScroll(); - } - this.didClose.emit({ - returnValue: this.returnValue, - closeTarget: this.overlayCloseElement, - }); + this.handleClosing(); } } @@ -285,11 +303,7 @@ class SbbDialogElement extends SbbOverlayBaseElement { protected override render(): TemplateResult { return html`
-
this.onOverlayAnimationEnd(event)} - class="sbb-dialog" - id=${this._dialogId} - > +
this.closeOnSbbOverlayCloseClick(event)} class="sbb-dialog__wrapper" diff --git a/src/elements/expansion-panel/expansion-panel-header/expansion-panel-header.scss b/src/elements/expansion-panel/expansion-panel-header/expansion-panel-header.scss index e610cf5521..99bcac17e7 100644 --- a/src/elements/expansion-panel/expansion-panel-header/expansion-panel-header.scss +++ b/src/elements/expansion-panel/expansion-panel-header/expansion-panel-header.scss @@ -82,7 +82,7 @@ } .sbb-expansion-panel-header__toggle-icon { - transition: transform var(--sbb-disable-animation-zero-duration, var(--sbb-animation-duration-4x)); + transition: transform var(--sbb-disable-animation-duration, var(--sbb-animation-duration-4x)); :host([aria-expanded]:not([aria-expanded='false'])) & { transform: rotate(-180deg); diff --git a/src/elements/expansion-panel/expansion-panel/expansion-panel.scss b/src/elements/expansion-panel/expansion-panel/expansion-panel.scss index ac9c6a633b..66d12f374c 100644 --- a/src/elements/expansion-panel/expansion-panel/expansion-panel.scss +++ b/src/elements/expansion-panel/expansion-panel/expansion-panel.scss @@ -11,7 +11,7 @@ $open-anim-opacity-to: 1; :host { --sbb-expansion-panel-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-4x) ); --sbb-expansion-panel-background-color: var(--sbb-color-white); diff --git a/src/elements/expansion-panel/expansion-panel/expansion-panel.spec.ts b/src/elements/expansion-panel/expansion-panel/expansion-panel.spec.ts index d8bff065b5..bf9feef6e2 100644 --- a/src/elements/expansion-panel/expansion-panel/expansion-panel.spec.ts +++ b/src/elements/expansion-panel/expansion-panel/expansion-panel.spec.ts @@ -129,4 +129,20 @@ describe(`sbb-expansion-panel`, () => { expect(header).to.have.attribute('data-size', 'l'); expect(content).to.have.attribute('data-size', 'l'); }); + + it('should fire animation events with non-zero animation duration', async () => { + element.style.setProperty('--sbb-expansion-panel-animation-duration', '1ms'); + + const didOpenSpy = new EventSpy(SbbExpansionPanelElement.events.didOpen, element); + const didCloseSpy = new EventSpy(SbbExpansionPanelElement.events.didClose, element); + + element.expanded = true; + + await didOpenSpy.calledOnce(); + + element.expanded = false; + + await didCloseSpy.calledOnce(); + expect(didCloseSpy.count).to.be.equal(1); + }); }); diff --git a/src/elements/expansion-panel/expansion-panel/expansion-panel.ts b/src/elements/expansion-panel/expansion-panel/expansion-panel.ts index b1dcc2d5cd..ee0d18f70e 100644 --- a/src/elements/expansion-panel/expansion-panel/expansion-panel.ts +++ b/src/elements/expansion-panel/expansion-panel/expansion-panel.ts @@ -5,7 +5,7 @@ import { html, unsafeStatic } from 'lit/static-html.js'; import { SbbConnectedAbortController } from '../../core/controllers.js'; import { forceType } from '../../core/decorators.js'; -import { isLean } from '../../core/dom.js'; +import { isLean, isZeroAnimationDuration } from '../../core/dom.js'; import { EventEmitter } from '../../core/eventing.js'; import type { SbbOpenedClosedState } from '../../core/interfaces.js'; import { SbbHydrationMixin } from '../../core/mixins.js'; @@ -156,25 +156,46 @@ class SbbExpansionPanelElement extends SbbHydrationMixin(LitElement) { this._contentRef?.setAttribute('aria-hidden', String(!this.expanded)); if (this.expanded) { - this._open(!this._initialized); + this._open(); } else if (this._state === 'opened') { this._close(); } } - private _open(skipAnimation = false): void { + private _open(): void { this._state = 'opening'; this._willOpen.emit(); - if (skipAnimation) { - this._state = 'opened'; - this._didOpen.emit(); + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `opened` state. + if (!this._initialized || this._isZeroAnimationDuration()) { + this._handleOpening(); } } private _close(): void { this._state = 'closing'; this._willClose.emit(); + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `closed` state. + if (this._isZeroAnimationDuration()) { + this._handleClosing(); + } + } + + private _isZeroAnimationDuration(): boolean { + return isZeroAnimationDuration(this, '--sbb-expansion-panel-animation-duration'); + } + + private _handleOpening(): void { + this._state = 'opened'; + this._didOpen.emit(); + } + + private _handleClosing(): void { + this._state = 'closed'; + this._didClose.emit(); } private _updateDisabledOnHeader(newDisabledValue: boolean): void { @@ -220,11 +241,9 @@ class SbbExpansionPanelElement extends SbbHydrationMixin(LitElement) { private _onAnimationEnd(event: AnimationEvent): void { if (event.animationName === 'open-opacity' && this._state === 'opening') { - this._state = 'opened'; - this._didOpen.emit(); + this._handleOpening(); } else if (event.animationName === 'close' && this._state === 'closing') { - this._state = 'closed'; - this._didClose.emit(); + this._handleClosing(); } } diff --git a/src/elements/file-selector/common/file-selector-common.scss b/src/elements/file-selector/common/file-selector-common.scss index 1cb1651112..89fe9ba8c0 100644 --- a/src/elements/file-selector/common/file-selector-common.scss +++ b/src/elements/file-selector/common/file-selector-common.scss @@ -9,7 +9,7 @@ --sbb-file-selector-background-color: var(--sbb-color-white); --sbb-file-selector-border-color: var(--sbb-color-cloud); --sbb-file-selector-transition-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-2x) ); --sbb-file-selector-transition-easing-function: var(--sbb-animation-easing); diff --git a/src/elements/flip-card/flip-card-details/flip-card-details.scss b/src/elements/flip-card/flip-card-details/flip-card-details.scss index 2d8343758e..7ee3d180bb 100644 --- a/src/elements/flip-card/flip-card-details/flip-card-details.scss +++ b/src/elements/flip-card/flip-card-details/flip-card-details.scss @@ -7,7 +7,7 @@ --sbb-flip-card-details-opacity: 0; --sbb-flip-card-details-translate-y: var(--sbb-spacing-fixed-2x); --sbb-flip-card-details-transition-delay: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-5x) ); --sbb-flip-card-details-padding: var(--sbb-spacing-responsive-s); diff --git a/src/elements/flip-card/flip-card-summary/flip-card-summary.scss b/src/elements/flip-card/flip-card-summary/flip-card-summary.scss index 64699826c4..9d4e48486d 100644 --- a/src/elements/flip-card/flip-card-summary/flip-card-summary.scss +++ b/src/elements/flip-card/flip-card-summary/flip-card-summary.scss @@ -56,7 +56,7 @@ ::slotted(*:not([slot='image'])) { transform: translateY(var(--sbb-flip-card-translate-title-y-hover, #{sbb.px-to-rem-build(0)})); - transition: transform var(--sbb-disable-animation-zero-duration, var(--sbb-animation-duration-2x)) + transition: transform var(--sbb-disable-animation-duration, var(--sbb-animation-duration-2x)) var(--sbb-animation-easing); } diff --git a/src/elements/flip-card/flip-card/flip-card.scss b/src/elements/flip-card/flip-card/flip-card.scss index 745afa79fc..878f430a02 100644 --- a/src/elements/flip-card/flip-card/flip-card.scss +++ b/src/elements/flip-card/flip-card/flip-card.scss @@ -9,15 +9,15 @@ --sbb-flip-card-border-radius: var(--sbb-border-radius-4x); --sbb-flip-card-min-height: #{sbb.px-to-rem-build(280)}; --sbb-flip-card-summary-transition-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-5x) ); --sbb-flip-card-summary-transition-delay: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-2x) ); --sbb-flip-card-details-transition-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-4x) ); @@ -39,7 +39,7 @@ :host([data-flipped]) { --sbb-flip-card-toggle-icon-transform: rotate(45deg); --sbb-flip-card-details-transition-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-2x) ); --sbb-flip-card-summary-transition-delay: 0s; @@ -116,8 +116,7 @@ // Use this large border radius to improve the appearance of the expanding dark background. border-radius: #{sbb.px-to-rem-build(256)}; - transition: var(--sbb-disable-animation-zero-duration, var(--sbb-animation-duration-3x)) - ease-out; + transition: var(--sbb-disable-animation-duration, var(--sbb-animation-duration-3x)) ease-out; :host([data-flipped]) & { opacity: 1; @@ -126,10 +125,7 @@ width: 100%; height: 100%; border-radius: var(--sbb-flip-card-border-radius); - transition-duration: var( - --sbb-disable-animation-zero-duration, - var(--sbb-animation-duration-5x) - ); + transition-duration: var(--sbb-disable-animation-duration, var(--sbb-animation-duration-5x)); } } } diff --git a/src/elements/form-field/form-field/form-field.scss b/src/elements/form-field/form-field/form-field.scss index 628ec29c40..c58469fdd1 100644 --- a/src/elements/form-field/form-field/form-field.scss +++ b/src/elements/form-field/form-field/form-field.scss @@ -361,7 +361,7 @@ will-change: transform, font-size; transition: { - duration: var(--sbb-disable-animation-zero-duration, var(--sbb-animation-duration-2x)); + duration: var(--sbb-disable-animation-duration, var(--sbb-animation-duration-2x)); timing-function: var(--sbb-animation-easing); property: transform, font-size; } diff --git a/src/elements/form-field/form-field/form-field.spec.ts b/src/elements/form-field/form-field/form-field.spec.ts index ae11694c55..eca84d83c9 100644 --- a/src/elements/form-field/form-field/form-field.spec.ts +++ b/src/elements/form-field/form-field/form-field.spec.ts @@ -296,7 +296,7 @@ describe(`sbb-form-field`, () => { label.click(); await waitForLitRender(element); - expect(select).to.have.attribute('data-state', 'opening'); + expect(select).to.have.attribute('data-state', 'opened'); }); it('should focus select on form field click readonly', async () => { diff --git a/src/elements/form-field/form-field/form-field.ts b/src/elements/form-field/form-field/form-field.ts index 9f7e2a759a..c1d3762236 100644 --- a/src/elements/form-field/form-field/form-field.ts +++ b/src/elements/form-field/form-field/form-field.ts @@ -50,7 +50,7 @@ class SbbFormFieldElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement) 'sbb-slider', ]; // List of elements that should not focus input on click - private readonly _excludedFocusElements = ['button', 'sbb-popover']; + private readonly _excludedFocusElements = ['button', 'sbb-popover', 'sbb-option']; private readonly _floatingLabelSupportedInputElements = [ 'input', @@ -168,7 +168,10 @@ class SbbFormFieldElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement) return; } - if (this._input?.localName === 'sbb-select') { + if ( + this._input?.localName === 'sbb-select' && + (event.target as HTMLElement).localName !== 'sbb-select' + ) { this._input.click(); this._input.focus(); } else if ((event.target as Element).localName !== 'label') { diff --git a/src/elements/header/common/header-action.scss b/src/elements/header/common/header-action.scss index 6aedcf7a26..9f80ec33d9 100644 --- a/src/elements/header/common/header-action.scss +++ b/src/elements/header/common/header-action.scss @@ -16,7 +16,7 @@ --sbb-header-action-min-height: var(--sbb-size-element-s); --sbb-header-action-min-width: var(--sbb-header-action-min-height); --sbb-header-action-transition-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-2x) ); --sbb-header-action-transition-easing: var(--sbb-animation-easing); diff --git a/src/elements/header/header/header.scss b/src/elements/header/header/header.scss index 444e94c1bf..ad0883ad60 100644 --- a/src/elements/header/header/header.scss +++ b/src/elements/header/header/header.scss @@ -14,7 +14,7 @@ --sbb-signet-height: #{sbb.px-to-rem-build(16)}; --sbb-header-position: fixed; --sbb-header-transition-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-6x) ); --sbb-header-inset-inline-end: 0; diff --git a/src/elements/image/image.scss b/src/elements/image/image.scss index 79d62eb6ec..30b5a40530 100644 --- a/src/elements/image/image.scss +++ b/src/elements/image/image.scss @@ -7,7 +7,7 @@ --sbb-image-border-radius: var(--sbb-border-radius-4x); --sbb-image-aspect-ratio: auto; --sbb-image-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-4x) ); --sbb-image-object-fit: cover; diff --git a/src/elements/loading-indicator-circle/loading-indicator-circle.scss b/src/elements/loading-indicator-circle/loading-indicator-circle.scss index 0f4f57929a..d28e21c7b4 100644 --- a/src/elements/loading-indicator-circle/loading-indicator-circle.scss +++ b/src/elements/loading-indicator-circle/loading-indicator-circle.scss @@ -9,7 +9,7 @@ --sbb-loading-indicator-circle-color: var(--sbb-color-red); --sbb-loading-indicator-circle-padding: #{sbb.px-to-rem-build(2)}; - --sbb-loading-indicator-circle-duration: var(--sbb-disable-animation-zero-duration, 1.5s); + --sbb-loading-indicator-circle-duration: var(--sbb-disable-animation-duration, 1.5s); --sbb-loading-indicator-circle-background-color: var(--sbb-color-white); --sbb-loading-indicator-circle-background: conic-gradient( from 90deg, diff --git a/src/elements/loading-indicator/loading-indicator.scss b/src/elements/loading-indicator/loading-indicator.scss index 0675cb57b2..42d38904ec 100644 --- a/src/elements/loading-indicator/loading-indicator.scss +++ b/src/elements/loading-indicator/loading-indicator.scss @@ -7,7 +7,7 @@ --sbb-loading-indicator-color: var(--sbb-color-red); --sbb-loading-indicator-padding: 0; --sbb-loading-indicator-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-6x) ); --sbb-loading-indicator-window-element-rotation: 55.24deg; diff --git a/src/elements/map-container/map-container.scss b/src/elements/map-container/map-container.scss index 563e2fdd98..42ffce7c34 100644 --- a/src/elements/map-container/map-container.scss +++ b/src/elements/map-container/map-container.scss @@ -12,7 +12,7 @@ --sbb-map-container-sidebar-background-color: var(--sbb-color-white); --sbb-map-container-border-radius: var(--sbb-border-radius-4x); --sbb-map-container-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-4x) ); --sbb-map-container-map-height: calc( diff --git a/src/elements/menu/menu/menu.scss b/src/elements/menu/menu/menu.scss index 72c5cab88b..2652019ba8 100644 --- a/src/elements/menu/menu/menu.scss +++ b/src/elements/menu/menu/menu.scss @@ -12,7 +12,7 @@ --sbb-menu-position-x: 0; --sbb-menu-position-y: 0; --sbb-menu-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-6x) ); --sbb-menu-animation-easing: ease; diff --git a/src/elements/menu/menu/menu.spec.ts b/src/elements/menu/menu/menu.spec.ts index d1a8d41b61..bb40ed12a7 100644 --- a/src/elements/menu/menu/menu.spec.ts +++ b/src/elements/menu/menu/menu.spec.ts @@ -165,6 +165,25 @@ describe(`sbb-menu`, () => { expect(element).to.have.attribute('data-state', 'closed'); }); + it('opens and closes with non-zero animation duration', async () => { + element.style.setProperty('--sbb-menu-animation-duration', '1ms'); + const didOpenEventSpy = new EventSpy(SbbMenuElement.events.didOpen, element); + const didCloseEventSpy = new EventSpy(SbbMenuElement.events.didClose, element); + const menuLink = element.querySelector(':scope > sbb-block-link') as HTMLElement; + + trigger.click(); + await waitForLitRender(element); + + await didOpenEventSpy.calledOnce(); + + menuLink.click(); + await waitForLitRender(element); + + await didCloseEventSpy.calledOnce(); + + expect(element).to.have.attribute('data-state', 'closed'); + }); + it('is correctly positioned on desktop', async () => { const willOpenEventSpy = new EventSpy(SbbMenuElement.events.willOpen, element); const didOpenEventSpy = new EventSpy(SbbMenuElement.events.didOpen, element); diff --git a/src/elements/menu/menu/menu.ts b/src/elements/menu/menu/menu.ts index decb424b34..724ac8e640 100644 --- a/src/elements/menu/menu/menu.ts +++ b/src/elements/menu/menu/menu.ts @@ -18,7 +18,11 @@ import { SbbMediaQueryBreakpointSmallAndBelow, } from '../../core/controllers.js'; import { forceType } from '../../core/decorators.js'; -import { findReferencedElement, SbbScrollHandler } from '../../core/dom.js'; +import { + findReferencedElement, + isZeroAnimationDuration, + SbbScrollHandler, +} from '../../core/dom.js'; import { SbbNamedSlotListMixin } from '../../core/mixins.js'; import { getElementPosition, @@ -128,6 +132,12 @@ class SbbMenuElement extends SbbNamedSlotListMixin< if (this._mediaMatcher.matches(SbbMediaQueryBreakpointSmallAndBelow)) { this._scrollHandler.disableScroll(); } + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `opened` state. + if (this._isZeroAnimationDuration()) { + this._handleOpening(); + } } /** @@ -144,6 +154,45 @@ class SbbMenuElement extends SbbNamedSlotListMixin< this.state = 'closing'; this._triggerElement?.setAttribute('aria-expanded', 'false'); + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `closed` state. + if (this._isZeroAnimationDuration()) { + this._handleClosing(); + } + } + + private _isZeroAnimationDuration(): boolean { + return isZeroAnimationDuration(this, '--sbb-menu-animation-duration'); + } + + private _handleOpening(): void { + this.state = 'opened'; + this.didOpen.emit(); + this._inertController.activate(); + this._setMenuFocus(); + this._focusHandler.trap(this); + this._attachWindowEvents(); + } + + private _handleClosing(): void { + this.state = 'closed'; + this._menu?.firstElementChild?.scrollTo(0, 0); + this._inertController.deactivate(); + setModalityOnNextFocus(this._triggerElement); + // Manually focus last focused element + this._triggerElement?.focus({ + // When inside the sbb-header, we prevent the scroll to avoid the snapping to the top of the page + preventScroll: + this._triggerElement.localName === 'sbb-header-button' || + this._triggerElement.localName === 'sbb-header-link', + }); + this.didClose.emit(); + this._windowEventsController?.abort(); + this._focusHandler.disconnect(); + + // Starting from breakpoint medium, enable scroll + this._scrollHandler.enableScroll(); } /** @@ -314,30 +363,9 @@ class SbbMenuElement extends SbbNamedSlotListMixin< // To avoid entering a corrupt state, exit when state is not expected. private _onMenuAnimationEnd(event: AnimationEvent): void { if (event.animationName === 'open' && this.state === 'opening') { - this.state = 'opened'; - this.didOpen.emit(); - this._inertController.activate(); - this._setMenuFocus(); - this._focusHandler.trap(this); - this._attachWindowEvents(); + this._handleOpening(); } else if (event.animationName === 'close' && this.state === 'closing') { - this.state = 'closed'; - this._menu?.firstElementChild?.scrollTo(0, 0); - this._inertController.deactivate(); - setModalityOnNextFocus(this._triggerElement); - // Manually focus last focused element - this._triggerElement?.focus({ - // When inside the sbb-header, we prevent the scroll to avoid the snapping to the top of the page - preventScroll: - this._triggerElement.localName === 'sbb-header-button' || - this._triggerElement.localName === 'sbb-header-link', - }); - this.didClose.emit(); - this._windowEventsController?.abort(); - this._focusHandler.disconnect(); - - // Starting from breakpoint medium, enable scroll - this._scrollHandler.enableScroll(); + this._handleClosing(); } } @@ -379,7 +407,7 @@ class SbbMenuElement extends SbbNamedSlotListMixin< return html`
this._onMenuAnimationEnd(event)} + @animationend=${this._onMenuAnimationEnd} class="sbb-menu" ${ref((el?: Element) => (this._menu = el as HTMLDivElement))} > diff --git a/src/elements/navigation/common/navigation-action.scss b/src/elements/navigation/common/navigation-action.scss index 70e6947ae9..a69c11eb7c 100644 --- a/src/elements/navigation/common/navigation-action.scss +++ b/src/elements/navigation/common/navigation-action.scss @@ -55,8 +55,7 @@ sbb-icon { display: flex; user-select: none; -webkit-tap-highlight-color: transparent; - transition: color var(--sbb-disable-animation-zero-duration, var(--sbb-animation-duration-3x)) - ease; + transition: color var(--sbb-disable-animation-duration, var(--sbb-animation-duration-3x)) ease; hyphens: auto; text-align: left; color: var(--sbb-navigation-action-color); diff --git a/src/elements/navigation/navigation-marker/navigation-marker.scss b/src/elements/navigation/navigation-marker/navigation-marker.scss index e58a08ad0e..aa15700961 100644 --- a/src/elements/navigation/navigation-marker/navigation-marker.scss +++ b/src/elements/navigation/navigation-marker/navigation-marker.scss @@ -54,7 +54,7 @@ border-block-start: var(--sbb-navigation-marker-border) solid var(--sbb-color-storm); margin-block: var(--sbb-navigation-marker-margin-block); transition: { - duration: var(--sbb-disable-animation-zero-duration, var(--sbb-animation-duration-6x)); + duration: var(--sbb-disable-animation-duration, var(--sbb-animation-duration-6x)); timing-function: ease; property: opacity, inset-block-start; } diff --git a/src/elements/navigation/navigation-section/navigation-section.scss b/src/elements/navigation/navigation-section/navigation-section.scss index 81e1fabc5f..9e673f5dc9 100644 --- a/src/elements/navigation/navigation-section/navigation-section.scss +++ b/src/elements/navigation/navigation-section/navigation-section.scss @@ -9,7 +9,7 @@ --sbb-navigation-section-position: fixed; --sbb-navigation-section-pointer-events: none; --sbb-navigation-section-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-3x) ); --sbb-navigation-section-animation-easing: ease-out; @@ -34,7 +34,7 @@ @include sbb.mq($from: large) { --sbb-navigation-section-column: 5 / 9; --sbb-navigation-section-animation-duration: var( - --sbb-disable-animation-zero-duration, + --sbb-disable-animation-duration, var(--sbb-animation-duration-4x) ); --sbb-navigation-section-padding-block: var(--sbb-spacing-responsive-xl); diff --git a/src/elements/navigation/navigation-section/navigation-section.spec.ts b/src/elements/navigation/navigation-section/navigation-section.spec.ts index 30e274b5c3..d42d538372 100644 --- a/src/elements/navigation/navigation-section/navigation-section.spec.ts +++ b/src/elements/navigation/navigation-section/navigation-section.spec.ts @@ -54,4 +54,19 @@ describe(`sbb-navigation-section`, () => { await waitForCondition(() => element.getAttribute('data-state') === 'closed'); expect(element).to.have.attribute('data-state', 'closed'); }); + + it('opens and closes with non-zero animation duration', async () => { + element.style.setProperty('--sbb-navigation-section-animation-duration', '1ms'); + + element.open(); + await waitForLitRender(element); + await waitForCondition(() => element.getAttribute('data-state') === 'opened'); + expect(element).to.have.attribute('data-state', 'opened'); + + element.close(); + await waitForLitRender(element); + + await waitForCondition(() => element.getAttribute('data-state') === 'closed'); + expect(element).to.have.attribute('data-state', 'closed'); + }); }); diff --git a/src/elements/navigation/navigation-section/navigation-section.ts b/src/elements/navigation/navigation-section/navigation-section.ts index 675a39c969..8de12dba6e 100644 --- a/src/elements/navigation/navigation-section/navigation-section.ts +++ b/src/elements/navigation/navigation-section/navigation-section.ts @@ -9,7 +9,12 @@ import { } from '../../core/a11y.js'; import { SbbLanguageController } from '../../core/controllers.js'; import { forceType, hostAttributes, omitEmptyConverter, slotState } from '../../core/decorators.js'; -import { findReferencedElement, isBreakpoint, setOrRemoveAttribute } from '../../core/dom.js'; +import { + findReferencedElement, + isBreakpoint, + isZeroAnimationDuration, + setOrRemoveAttribute, +} from '../../core/dom.js'; import { i18nGoBack } from '../../core/i18n.js'; import type { SbbOpenedClosedState } from '../../core/interfaces.js'; import { SbbUpdateSchedulerMixin } from '../../core/mixins.js'; @@ -111,6 +116,39 @@ class SbbNavigationSectionElement extends SbbUpdateSchedulerMixin(LitElement) { this.startUpdate(); this.inert = true; this._triggerElement?.setAttribute('aria-expanded', 'true'); + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `opened` state. + if (this._isZeroAnimationDuration()) { + this._handleOpening(); + } + } + + private _isZeroAnimationDuration(): boolean { + return isZeroAnimationDuration(this, '--sbb-navigation-section-animation-duration'); + } + + private _handleOpening(): void { + this._state = 'opened'; + this.inert = false; + this._attachWindowEvents(); + this._setNavigationInert(); + this._setNavigationSectionFocus(); + this._checkActiveAction(); + this.completeUpdate(); + } + + private _handleClosing(): void { + this._state = 'closed'; + this._navigationSectionContainerElement.scrollTo(0, 0); + this._windowEventsController?.abort(); + this._resetLists(); + this._setNavigationInert(); + if (this._isZeroToLargeBreakpoint() && this._triggerElement) { + setModalityOnNextFocus(this._triggerElement); + this._triggerElement.focus(); + } + this.completeUpdate(); } private _setActiveNavigationAction(): void { @@ -133,6 +171,12 @@ class SbbNavigationSectionElement extends SbbUpdateSchedulerMixin(LitElement) { this.startUpdate(); this.inert = true; this._triggerElement?.setAttribute('aria-expanded', 'false'); + + // If the animation duration is zero, the animationend event is not always fired reliably. + // In this case we directly set the `closed` state. + if (this._isZeroAnimationDuration()) { + this._handleClosing(); + } } // Removes trigger click listener on trigger change. @@ -188,24 +232,10 @@ class SbbNavigationSectionElement extends SbbUpdateSchedulerMixin(LitElement) { // To avoid entering a corrupt state, exit when state is not expected. private _onAnimationEnd(event: AnimationEvent): void { if (event.animationName === 'open' && this._state === 'opening') { - this._state = 'opened'; - this.inert = false; - this._attachWindowEvents(); - this._setNavigationInert(); - this._setNavigationSectionFocus(); - this._checkActiveAction(); + this._handleOpening(); } else if (event.animationName === 'close' && this._state === 'closing') { - this._state = 'closed'; - this._navigationSectionContainerElement.scrollTo(0, 0); - this._windowEventsController?.abort(); - this._resetLists(); - this._setNavigationInert(); - if (this._isZeroToLargeBreakpoint() && this._triggerElement) { - setModalityOnNextFocus(this._triggerElement); - this._triggerElement.focus(); - } + this._handleClosing(); } - this.completeUpdate(); } private _resetLists(): void { @@ -336,7 +366,7 @@ class SbbNavigationSectionElement extends SbbUpdateSchedulerMixin(LitElement) { ${ref((el?: Element) => (this._navigationSectionContainerElement = el as HTMLElement))} >