diff --git a/scripts/chromatic-stories-generator.ts b/scripts/chromatic-stories-generator.ts index bb7704c3db..fe57e043e9 100644 --- a/scripts/chromatic-stories-generator.ts +++ b/scripts/chromatic-stories-generator.ts @@ -63,7 +63,7 @@ async function generateChromaticStory( return 'no params found'; } - const { disableSnapshot, ...chromaticParameters } = parameters?.chromatic ?? {}; + const { disableSnapshot, fixedHeight, ...chromaticParameters } = parameters?.chromatic ?? {}; if (!parameters) { return 'no params found'; } else if (disableSnapshot !== undefined) { @@ -74,14 +74,35 @@ async function generateChromaticStory( const relativeImport = basename(storyFile).replace(/\.ts$/, ''); const chromaticImport = relative(dirname(targetStoryFile), chromaticFile).replace(/\.ts$/, ''); + /** + * The `fixedHeight` param forces the height of the snapshot on chromatic. + * It might be useful in cases where some content is cut off at the end of a snapshot + * The max fixedHeight we can use is 17'000 (17k * 1440 =~ 25kk) + * Now, the max snapshot size is 25'000'000px. + * + * Example: + * ``` + * ... + * parameters: { + * chromatic: { fixedHeight: '17000px', ... }, + * ... + * } + * ``` + */ + const fixedHeightStyle = fixedHeight ? `style="min-height: ${fixedHeight}"` : ''; + const chromaticConfig = Object.entries(chromaticParameters) .map(([key, value]) => `${key}: ${JSON.stringify(value)}, `) .join(''); const storyFileContent = `import type { Meta, StoryObj } from '@storybook/web-components'; import config, * as stories from './${relativeImport}'; import { combineStories } from '${chromaticImport}'; +import { html } from 'lit'; const meta: Meta = { + decorators: [ + (story) => html\`
\${story()}
\`, + ], parameters: { backgrounds: { disable: true, diff --git a/src/components/checkbox/checkbox/checkbox.ts b/src/components/checkbox/checkbox/checkbox.ts index d614c5178e..24c11e84d5 100644 --- a/src/components/checkbox/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox/checkbox.ts @@ -111,7 +111,13 @@ export class SbbCheckboxElement extends UpdateScheduler(LitElement) { } private _size: SbbCheckboxSize = 'm'; - /** Whether the input is the main input of a selection panel. */ + /** + * Whether the input is the main input of a selection panel. + * @internal + */ + public get isSelectionPanelInput(): boolean { + return this._isSelectionPanelInput; + } @state() private _isSelectionPanelInput = false; /** The label describing whether the selection panel is expanded (for screen readers only). */ diff --git a/src/components/notification/notification.stories.ts b/src/components/notification/notification.stories.ts index 78e0232bc2..99b950265e 100644 --- a/src/components/notification/notification.stories.ts +++ b/src/components/notification/notification.stories.ts @@ -237,9 +237,7 @@ const meta: Meta = { decorators: [ (story, context) => html`
${trigger(context.args)}
@@ -250,6 +248,7 @@ const meta: Meta = { withActions as Decorator, ], parameters: { + chromatic: { fixedHeight: '7500px' }, actions: { handles: [ SbbNotificationElement.events.didOpen, diff --git a/src/components/radio-button/radio-button/radio-button.ts b/src/components/radio-button/radio-button/radio-button.ts index 6271aa54c1..57223f5090 100644 --- a/src/components/radio-button/radio-button/radio-button.ts +++ b/src/components/radio-button/radio-button/radio-button.ts @@ -115,7 +115,11 @@ export class SbbRadioButtonElement extends UpdateScheduler(LitElement) { /** * Whether the input is the main input of a selection panel. + * @internal */ + public get isSelectionPanelInput(): boolean { + return this._isSelectionPanelInput; + } @state() private _isSelectionPanelInput = false; /** diff --git a/src/components/selection-panel/readme.md b/src/components/selection-panel/readme.md index 22be1f6340..384e926ad2 100644 --- a/src/components/selection-panel/readme.md +++ b/src/components/selection-panel/readme.md @@ -86,12 +86,12 @@ It's also possible to display the `sbb-selection-panel` without border by settin ## Events -| Name | Type | Description | Inherited From | -| ----------- | ------------------------------------------- | ----------------------------------------------------------------- | -------------- | -| `willOpen` | `CustomEvent` | Emits whenever the content section starts the opening transition. | | -| `didOpen` | `CustomEvent` | Emits whenever the content section is opened. | | -| `willClose` | `CustomEvent<{ closeTarget: HTMLElement }>` | Emits whenever the content section begins the closing transition. | | -| `didClose` | `CustomEvent<{ closeTarget: HTMLElement }>` | Emits whenever the content section is closed. | | +| Name | Type | Description | Inherited From | +| ----------- | ------------------- | ----------------------------------------------------------------- | -------------- | +| `willOpen` | `CustomEvent` | Emits whenever the content section starts the opening transition. | | +| `didOpen` | `CustomEvent` | Emits whenever the content section is opened. | | +| `willClose` | `CustomEvent` | Emits whenever the content section begins the closing transition. | | +| `didClose` | `CustomEvent` | Emits whenever the content section is closed. | | ## Slots diff --git a/src/components/selection-panel/selection-panel.e2e.ts b/src/components/selection-panel/selection-panel.e2e.ts index b51b11b369..c30b5bd6c6 100644 --- a/src/components/selection-panel/selection-panel.e2e.ts +++ b/src/components/selection-panel/selection-panel.e2e.ts @@ -60,22 +60,19 @@ describe('sbb-selection-panel', () => { const forceOpenTest = async ( wrapper: SbbRadioButtonGroupElement | SbbCheckboxGroupElement, secondInput: SbbRadioButtonElement | SbbCheckboxElement, - secondContent: HTMLDivElement, ): Promise => { elements.forEach((e) => (e.forceOpen = true)); await waitForLitRender(wrapper); - elements.forEach((e) => { - const panel = e.shadowRoot!.querySelector('.sbb-selection-panel__content--wrapper'); - expect(panel).to.have.attribute('data-expanded', ''); - }); + for (const el of elements) { + await waitForCondition(() => el.getAttribute('data-state') === 'opened'); + expect(el).to.have.attribute('data-state', 'opened'); + } expect(secondInput).not.to.have.attribute('checked'); - expect(secondContent).to.have.attribute('data-expanded'); secondInput.click(); await waitForLitRender(wrapper); expect(secondInput).to.have.attribute('checked'); - expect(secondContent).to.have.attribute('data-expanded'); }; const preservesDisabled = async ( @@ -127,54 +124,62 @@ describe('sbb-selection-panel', () => { let wrapper: SbbRadioButtonGroupElement; let firstPanel: SbbSelectionPanelElement; let firstInput: SbbRadioButtonElement; - let firstContent: HTMLDivElement; let secondPanel: SbbSelectionPanelElement; let secondInput: SbbRadioButtonElement; - let secondContent: HTMLDivElement; let disabledInput: SbbRadioButtonElement; + let willOpenEventSpy: EventSpy; + let didOpenEventSpy: EventSpy; beforeEach(async () => { + willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); + await fixture(getPageContent('radio-button')); elements = Array.from(document.querySelectorAll('sbb-selection-panel')); wrapper = document.querySelector('sbb-radio-button-group')!; firstPanel = document.querySelector('#sbb-selection-panel-1')!; firstInput = document.querySelector('#sbb-input-1')!; - firstContent = firstPanel.shadowRoot!.querySelector( - '.sbb-selection-panel__content--wrapper', - )!; secondPanel = document.querySelector('#sbb-selection-panel-2')!; secondInput = document.querySelector('#sbb-input-2')!; - secondContent = secondPanel.shadowRoot!.querySelector( - '.sbb-selection-panel__content--wrapper', - )!; disabledInput = document.querySelector('#sbb-input-3')!; }); it('renders', () => { elements.forEach((e) => assert.instanceOf(e, SbbSelectionPanelElement)); + assert.instanceOf(firstPanel, SbbSelectionPanelElement); + assert.instanceOf(firstInput, SbbRadioButtonElement); + assert.instanceOf(secondPanel, SbbSelectionPanelElement); + assert.instanceOf(secondInput, SbbRadioButtonElement); }); it('selects input on click and shows related content', async () => { - assert.instanceOf(firstPanel, SbbSelectionPanelElement); - assert.instanceOf(firstInput, SbbRadioButtonElement); + willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); + + await waitForLitRender(wrapper); + expect(firstInput).not.to.have.attribute('checked'); - expect(firstContent).not.to.have.attribute('data-expanded'); + expect(firstPanel).to.have.attribute('data-state', 'closed'); - assert.instanceOf(secondPanel, SbbSelectionPanelElement); - assert.instanceOf(secondInput, SbbRadioButtonElement); expect(secondInput).not.to.have.attribute('checked'); - expect(secondContent).not.to.have.attribute('data-expanded'); + expect(secondPanel).to.have.attribute('data-state', 'closed'); secondInput.click(); + + await waitForCondition(() => willOpenEventSpy.events.length === 1); + await waitForCondition(() => didOpenEventSpy.events.length === 1); await waitForLitRender(wrapper); + + expect(willOpenEventSpy.count).to.be.equal(1); + expect(didOpenEventSpy.count).to.be.equal(1); expect(firstInput).not.to.have.attribute('checked'); - expect(firstContent).not.to.have.attribute('data-expanded'); + expect(firstPanel).to.have.attribute('data-state', 'closed'); expect(secondInput).to.have.attribute('checked'); - expect(secondContent).to.have.attribute('data-expanded', ''); + expect(secondPanel).to.have.attribute('data-state', 'opened'); }); it('always displays related content with forceOpen', async () => { - await forceOpenTest(wrapper, secondInput, secondContent); + await forceOpenTest(wrapper, secondInput); }); it('dispatches event on input change', async () => { @@ -276,6 +281,11 @@ describe('sbb-selection-panel', () => { document.querySelector('#input-no-content-2')!; const fourthInputNoContent: SbbRadioButtonElement = document.querySelector('#input-no-content-4')!; + const firstPanel = document.querySelector('#no-content-1')!; + const secondPanel = document.querySelector('#no-content-2')!; + + expect(firstPanel).to.have.attribute('data-state', 'closed'); + expect(secondPanel).to.have.attribute('data-state', 'closed'); await sendKeys({ down: 'Tab' }); await waitForLitRender(wrapperNoContent); @@ -309,11 +319,22 @@ describe('sbb-selection-panel', () => { describe('with nested radio buttons', () => { let nestedElement: SbbRadioButtonGroupElement; + let panel1: SbbSelectionPanelElement; + let panel2: SbbSelectionPanelElement; + let willOpenEventSpy: EventSpy; + let didOpenEventSpy: EventSpy; + let willCloseEventSpy: EventSpy; + let didCloseEventSpy: EventSpy; beforeEach(async () => { + willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); + willCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.willClose); + didCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.didClose); + nestedElement = await fixture(html` - + Main Option 1 Suboption 1 @@ -321,7 +342,7 @@ describe('sbb-selection-panel', () => { - + Main Option 2 Suboption 3 @@ -330,6 +351,8 @@ describe('sbb-selection-panel', () => { `); + panel1 = nestedElement.querySelector('#panel1')!; + panel2 = nestedElement.querySelector('#panel2')!; await waitForLitRender(nestedElement); }); @@ -350,18 +373,30 @@ describe('sbb-selection-panel', () => { .querySelector("sbb-radio-button[value='sub1']")! .shadowRoot!.querySelector('.sbb-radio-button__expanded-label'); + await waitForCondition(() => didOpenEventSpy.count === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + expect(didOpenEventSpy.count).to.be.equal(1); expect(mainRadioButton1Label.textContent!.trim()).to.be.equal(', expanded'); expect(mainRadioButton2Label.textContent!.trim()).to.be.equal(', collapsed'); expect(subRadioButton1).to.be.null; + expect(panel1).to.have.attribute('data-state', 'opened'); + expect(panel2).to.have.attribute('data-state', 'closed'); // Activate main option 2 mainRadioButton2.click(); - await waitForLitRender(nestedElement); + await waitForCondition(() => didOpenEventSpy.count === 2); + await waitForCondition(() => didCloseEventSpy.count === 1); + expect(willOpenEventSpy.count).to.be.equal(2); + expect(didOpenEventSpy.count).to.be.equal(2); + expect(willCloseEventSpy.count).to.be.equal(1); + expect(didCloseEventSpy.count).to.be.equal(1); expect(mainRadioButton1Label.textContent!.trim()).to.be.equal(', collapsed'); expect(mainRadioButton2Label.textContent!.trim()).to.be.equal(', expanded'); expect(subRadioButton1).to.be.null; + expect(panel1).to.have.attribute('data-state', 'closed'); + expect(panel2).to.have.attribute('data-state', 'opened'); }); it('should mark only outer group children as disabled', async () => { @@ -392,13 +427,27 @@ describe('sbb-selection-panel', () => { 'sbb-radio-button[value="sub1"]', )!; + await waitForCondition(() => didOpenEventSpy.count === 1); + expect(willOpenEventSpy.count).to.be.equal(1); + expect(didOpenEventSpy.count).to.be.equal(1); + expect(panel1).to.have.attribute('data-state', 'opened'); + expect(panel2).to.have.attribute('data-state', 'closed'); expect(main1).to.have.attribute('checked'); expect(main2).not.to.have.attribute('checked'); expect(sub1).to.have.attribute('checked'); main2.checked = true; - await waitForLitRender(nestedElement); + await waitForCondition(() => didOpenEventSpy.count === 2); + await waitForCondition(() => didCloseEventSpy.count === 1); + + expect(willOpenEventSpy.count).to.be.equal(2); + expect(didOpenEventSpy.count).to.be.equal(2); + expect(willCloseEventSpy.count).to.be.equal(1); + expect(didCloseEventSpy.count).to.be.equal(1); + + expect(panel1).to.have.attribute('data-state', 'closed'); + expect(panel2).to.have.attribute('data-state', 'opened'); expect(main1).not.to.have.attribute('checked'); expect(main2).to.have.attribute('checked'); expect(sub1).to.have.attribute('checked'); @@ -456,71 +505,78 @@ describe('sbb-selection-panel', () => { let wrapper: SbbCheckboxGroupElement; let firstPanel: SbbSelectionPanelElement; let firstInput: SbbCheckboxElement; - let firstContent: HTMLDivElement; let secondPanel: SbbSelectionPanelElement; let secondInput: SbbCheckboxElement; - let secondContent: HTMLDivElement; let disabledInput: SbbCheckboxElement; + let willOpenEventSpy: EventSpy; + let didOpenEventSpy: EventSpy; + let willCloseEventSpy: EventSpy; + let didCloseEventSpy: EventSpy; beforeEach(async () => { + willOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.willOpen); + didOpenEventSpy = new EventSpy(SbbSelectionPanelElement.events.didOpen); + willCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.willClose); + didCloseEventSpy = new EventSpy(SbbSelectionPanelElement.events.didClose); + await fixture(getPageContent('checkbox')); elements = Array.from(document.querySelectorAll('sbb-selection-panel')); wrapper = document.querySelector('sbb-checkbox-group')!; firstPanel = document.querySelector('#sbb-selection-panel-1')!; firstInput = document.querySelector('#sbb-input-1')!; - firstContent = firstPanel.shadowRoot!.querySelector( - '.sbb-selection-panel__content--wrapper', - )!; secondPanel = document.querySelector('#sbb-selection-panel-2')!; secondInput = document.querySelector('#sbb-input-2')!; - secondContent = secondPanel.shadowRoot!.querySelector( - '.sbb-selection-panel__content--wrapper', - )!; disabledInput = document.querySelector('#sbb-input-3')!; }); it('renders', () => { elements.forEach((e) => assert.instanceOf(e, SbbSelectionPanelElement)); + + assert.instanceOf(firstPanel, SbbSelectionPanelElement); + assert.instanceOf(firstInput, SbbCheckboxElement); + assert.instanceOf(secondPanel, SbbSelectionPanelElement); + assert.instanceOf(secondInput, SbbCheckboxElement); }); it('selects input on click and shows related content', async () => { await waitForLitRender(wrapper); + await waitForCondition(() => didOpenEventSpy.events.length === 1); - assert.instanceOf(firstPanel, SbbSelectionPanelElement); - assert.instanceOf(firstInput, SbbCheckboxElement); - - // TODO fix: should be 'opened', actual is 'close'. - // we have to rethink the open/close flow to make it work - //expect(firstPanel).to.have.attribute('data-state', 'opened'); + expect(willOpenEventSpy.count).to.be.equal(1); + expect(didOpenEventSpy.count).to.be.equal(1); + expect(firstPanel).to.have.attribute('data-state', 'opened'); expect(firstInput).to.have.attribute('checked'); - expect(firstContent).to.have.attribute('data-expanded', ''); - - assert.instanceOf(secondPanel, SbbSelectionPanelElement); - assert.instanceOf(secondInput, SbbCheckboxElement); - expect(firstPanel).to.have.attribute('data-state', 'closed'); + expect(secondPanel).to.have.attribute('data-state', 'closed'); expect(secondInput).not.to.have.attribute('checked'); - expect(secondContent).not.to.have.attribute('data-expanded'); secondInput.click(); await waitForLitRender(wrapper); + await waitForCondition(() => didOpenEventSpy.events.length === 2); + + expect(willOpenEventSpy.count).to.be.equal(2); + expect(didOpenEventSpy.count).to.be.equal(2); expect(firstInput).to.have.attribute('checked'); - expect(firstContent).to.have.attribute('data-expanded', ''); + expect(firstPanel).to.have.attribute('data-state', 'opened'); expect(secondInput).to.have.attribute('checked'); - expect(secondContent).to.have.attribute('data-expanded', ''); + expect(secondPanel).to.have.attribute('data-state', 'opened'); }); it('deselects input on click and hides related content', async () => { + await waitForCondition(() => firstPanel.getAttribute('data-state') === 'opened'); expect(firstInput).to.have.attribute('checked'); - expect(firstContent).to.have.attribute('data-expanded'); + expect(firstPanel).to.have.attribute('data-state', 'opened'); firstInput.click(); - await waitForLitRender(wrapper); + + await waitForCondition(() => didCloseEventSpy.events.length === 1); + expect(willCloseEventSpy.count).to.be.equal(1); + expect(didCloseEventSpy.count).to.be.equal(1); expect(firstInput).not.to.have.attribute('checked'); - expect(firstContent).not.to.have.attribute('data-expanded'); + expect(firstPanel).to.have.attribute('data-state', 'closed'); }); it('always displays related content with forceOpen', async () => { - await forceOpenTest(wrapper, secondInput, secondContent); + await forceOpenTest(wrapper, secondInput); }); it('dispatches event on input change', async () => { diff --git a/src/components/selection-panel/selection-panel.scss b/src/components/selection-panel/selection-panel.scss index dc0ef39b06..4be10ee24f 100644 --- a/src/components/selection-panel/selection-panel.scss +++ b/src/components/selection-panel/selection-panel.scss @@ -4,6 +4,12 @@ // travel the shadow boundary are defined through this mixin @include sbb.host-component-properties; +// Open/Close animation vars +$open-anim-rows-from: 0fr; +$open-anim-rows-to: 1fr; +$open-anim-opacity-from: 0; +$open-anim-opacity-to: 1; + :host { --sbb-selection-panel-cursor: pointer; --sbb-selection-panel-background: var(--sbb-color-white-default); @@ -14,12 +20,7 @@ --sbb-selection-panel-input-padding: var(--sbb-spacing-responsive-xs) var(--sbb-spacing-responsive-xxs); --sbb-selection-panel-content-visibility: hidden; - --sbb-selection-panel-content-grid-template-rows: 0fr; - --sbb-selection-panel-content-opacity: 0; --sbb-selection-panel-content-padding-inline: var(--sbb-spacing-responsive-xxs); - --sbb-selection-panel-content-transition: grid-template-rows - var(--sbb-selection-panel-animation-duration) var(--sbb-animation-easing), - opacity var(--sbb-selection-panel-animation-duration) var(--sbb-animation-easing); // As the selection panel has always a white/milk background, we have to fix the focus outline color // to default color for cases where the selection panel is used in a negative context. @@ -53,8 +54,7 @@ --sbb-selection-panel-animation-duration: 0.1ms; } -:host([data-slot-names~='content'][force-open]), -:host([data-slot-names~='content'][data-checked]) { +:host([data-slot-names~='content']:where([data-state='opening'], [data-state='opened'])) { --sbb-selection-panel-input-padding: var(--sbb-spacing-responsive-xs) var(--sbb-spacing-responsive-xxs) var(--sbb-spacing-responsive-xxs) var(--sbb-spacing-responsive-xxs); @@ -93,10 +93,28 @@ .sbb-selection-panel__content--wrapper { display: grid; - grid-template-rows: var(--sbb-selection-panel-content-grid-template-rows); visibility: var(--sbb-selection-panel-content-visibility); - opacity: var(--sbb-selection-panel-content-opacity); - transition: var(--sbb-selection-panel-content-transition); + grid-template-rows: #{$open-anim-rows-from}; + opacity: #{$open-anim-opacity-from}; + + :host([data-state='opened']) & { + grid-template-rows: #{$open-anim-rows-to}; + opacity: #{$open-anim-opacity-to}; + } + + :host([data-state='opening']) & { + animation-name: open, open-opacity; + animation-fill-mode: forwards; + animation-duration: var(--sbb-selection-panel-animation-duration); + animation-timing-function: var(--sbb-animation-easing); + animation-delay: 0s, var(--sbb-selection-panel-animation-duration); + } + + :host([data-state='closing']) & { + animation-name: close; + animation-duration: var(--sbb-selection-panel-animation-duration); + animation-timing-function: var(--sbb-animation-easing); + } :host(:not([data-slot-names~='content'])) & { display: none; @@ -126,3 +144,35 @@ sbb-divider { property: padding; } } + +@keyframes open { + from { + grid-template-rows: #{$open-anim-rows-from}; + } + + to { + grid-template-rows: #{$open-anim-rows-to}; + } +} + +@keyframes open-opacity { + from { + opacity: #{$open-anim-opacity-from}; + } + + to { + opacity: #{$open-anim-opacity-to}; + } +} + +@keyframes close { + from { + grid-template-rows: #{$open-anim-rows-to}; + opacity: #{$open-anim-opacity-to}; + } + + to { + grid-template-rows: #{$open-anim-rows-from}; + opacity: #{$open-anim-opacity-from}; + } +} diff --git a/src/components/selection-panel/selection-panel.stories.ts b/src/components/selection-panel/selection-panel.stories.ts index 83eaae6ef0..21adc171a8 100644 --- a/src/components/selection-panel/selection-panel.stories.ts +++ b/src/components/selection-panel/selection-panel.stories.ts @@ -723,7 +723,7 @@ const meta: Meta = { withActions as Decorator, ], parameters: { - chromatic: { delay: 9000 }, + chromatic: { delay: 9000, fixedHeight: '14500px' }, actions: { handles: [ SbbSelectionPanelElement.events.didOpen, diff --git a/src/components/selection-panel/selection-panel.ts b/src/components/selection-panel/selection-panel.ts index 3337957462..6404985648 100644 --- a/src/components/selection-panel/selection-panel.ts +++ b/src/components/selection-panel/selection-panel.ts @@ -1,14 +1,13 @@ -import type { CSSResultGroup, TemplateResult } from 'lit'; +import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; import { html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { ref } from 'lit/directives/ref.js'; -import type { SbbCheckboxElement, SbbCheckboxStateChange } from '../checkbox'; +import type { SbbCheckboxElement } from '../checkbox'; import { NamedSlotStateController } from '../core/common-behaviors'; import { setAttribute } from '../core/dom'; import { EventEmitter, ConnectedAbortController } from '../core/eventing'; import type { SbbStateChange } from '../core/interfaces'; -import type { SbbRadioButtonElement, SbbRadioButtonStateChange } from '../radio-button'; +import type { SbbRadioButtonElement } from '../radio-button'; import style from './selection-panel.scss?lit&inline'; import '../divider'; @@ -21,8 +20,8 @@ import '../divider'; * @slot content - Use this slot to provide custom content for the panel (optional). * @event {CustomEvent} willOpen - Emits whenever the content section starts the opening transition. * @event {CustomEvent} didOpen - Emits whenever the content section is opened. - * @event {CustomEvent<{ closeTarget: HTMLElement }>} willClose - Emits whenever the content section begins the closing transition. - * @event {CustomEvent<{ closeTarget: HTMLElement }>} didClose - Emits whenever the content section is closed. + * @event {CustomEvent} willClose - Emits whenever the content section begins the closing transition. + * @event {CustomEvent} didClose - Emits whenever the content section is closed. */ @customElement('sbb-selection-panel') export class SbbSelectionPanelElement extends LitElement { @@ -38,7 +37,7 @@ export class SbbSelectionPanelElement extends LitElement { @property() public color: 'white' | 'milk' = 'white'; /** Whether the content section is always visible. */ - @property({ attribute: 'force-open', reflect: true, type: Boolean }) public forceOpen = false; + @property({ attribute: 'force-open', type: Boolean }) public forceOpen = false; /** Whether the unselected panel has a border. */ @property({ reflect: true, type: Boolean }) public borderless = false; @@ -48,7 +47,7 @@ export class SbbSelectionPanelElement extends LitElement { public disableAnimation = false; /** The state of the selection panel. */ - @state() private _state?: 'closed' | 'opening' | 'opened' | 'closing'; + @state() private _state: 'closed' | 'opening' | 'opened' | 'closing' = 'closed'; /** Whether the selection panel is checked. */ @state() private _checked = false; @@ -60,130 +59,130 @@ export class SbbSelectionPanelElement extends LitElement { private _willOpen: EventEmitter = new EventEmitter( this, SbbSelectionPanelElement.events.willOpen, - { - bubbles: true, - composed: true, - }, ); /** Emits whenever the content section is opened. */ private _didOpen: EventEmitter = new EventEmitter( this, SbbSelectionPanelElement.events.didOpen, - { - bubbles: true, - composed: true, - }, ); /** Emits whenever the content section begins the closing transition. */ - private _willClose: EventEmitter<{ closeTarget: HTMLElement }> = new EventEmitter( + private _willClose: EventEmitter = new EventEmitter( this, SbbSelectionPanelElement.events.willClose, - { bubbles: true, composed: true }, ); /** Emits whenever the content section is closed. */ - private _didClose: EventEmitter<{ closeTarget: HTMLElement }> = new EventEmitter( + private _didClose: EventEmitter = new EventEmitter( this, SbbSelectionPanelElement.events.didClose, - { bubbles: true, composed: true }, ); - private _contentElement?: HTMLElement; - private _didLoad = false; private _abort = new ConnectedAbortController(this); - private _namedSlots = new NamedSlotStateController(this); + private _initialized: boolean = false; /** * Whether it has an expandable content * @internal */ public get hasContent(): boolean { - return this._namedSlots.slots.has('content'); + // We cannot use the NamedSlots because it's too slow to initialize + return this.querySelectorAll?.('[slot="content"]').length > 0; } - private get _input(): SbbCheckboxElement | SbbRadioButtonElement { - return this.querySelector('sbb-checkbox, sbb-radio-button') as - | SbbCheckboxElement - | SbbRadioButtonElement; + public constructor() { + super(); + new NamedSlotStateController(this); } - private _onInputChange( - event: CustomEvent, - ): void { - if (!this._state || !this._didLoad) { - return; + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.addEventListener('stateChange', this._onInputStateChange.bind(this), { signal }); + this.addEventListener('checkboxLoaded', this._initFromInput.bind(this), { signal }); + this.addEventListener('radioButtonLoaded', this._initFromInput.bind(this), { signal }); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('forceOpen')) { + this._updateState(); } + } - if (event.detail.type === 'disabled') { - this._disabled = event.detail.disabled; - return; - } else if (event.detail.type !== 'checked') { + protected override firstUpdated(): void { + this._initialized = true; + } + + private _updateState(): void { + if (!this.hasContent) { return; } - this._checked = event.detail.checked; + this.forceOpen || this._checked ? this._open(!this._initialized) : this._close(); + } - if (!this._namedSlots.slots.has('content') || this.forceOpen) { + private _open(skipAnimation = false): void { + if (this._state !== 'closed' && this._state !== 'closing') { return; } - if (this._checked) { - this._state = 'opening'; - this._willOpen.emit(); - } else { - this._state = 'closing'; - this._willClose.emit(); + this._state = 'opening'; + this._willOpen.emit(); + + if (skipAnimation) { + this._state = 'opened'; + this._didOpen.emit(); } } - public override connectedCallback(): void { - super.connectedCallback(); - const signal = this._abort.signal; - this.addEventListener( - 'stateChange', - (e: CustomEvent) => - this._onInputChange(e as CustomEvent), - { signal, passive: true }, - ); - this.addEventListener('checkboxLoaded', () => this._updateSelectionPanel(), { signal }); - this.addEventListener('radioButtonLoaded', () => this._updateSelectionPanel(), { signal }); - } + private _close(): void { + if (this._state !== 'opened' && this._state !== 'opening') { + return; + } - protected override firstUpdated(): void { - this._didLoad = true; + this._state = 'closing'; + this._willClose.emit(); } - private _updateSelectionPanel(): void { - this._checked = this._input?.checked; - this._state = - this.forceOpen || (this._namedSlots.slots.has('content') && this._checked) - ? 'opened' - : 'closed'; - this._disabled = this._input?.disabled; + private _initFromInput(event: Event): void { + const input = event.target as SbbCheckboxElement | SbbRadioButtonElement; + + if (!input.isSelectionPanelInput) { + return; + } + + this._checked = input.checked; + this._disabled = input.disabled; + this._updateState(); } - private _onTransitionEnd(event: TransitionEvent): void { - if (event.target !== this._contentElement || event.propertyName !== 'opacity') { + private _onInputStateChange(event: CustomEvent): void { + const input = event.target as SbbCheckboxElement | SbbRadioButtonElement; + + if (!input.isSelectionPanelInput) { return; } - if (this._checked) { - this._handleOpening(); - } else { - this._handleClosing(); + if (event.detail.type === 'disabled') { + this._disabled = event.detail.disabled; + return; + } else if (event.detail.type !== 'checked') { + return; } - } - private _handleOpening(): void { - this._state = 'opened'; - this._didOpen.emit(); + this._checked = event.detail.checked; + this._updateState(); } - private _handleClosing(): void { - this._state = 'closed'; - this._didClose.emit(); + private _onAnimationEnd(event: AnimationEvent): void { + if (event.animationName === 'open-opacity' && this._state === 'opening') { + this._state = 'opened'; + this._didOpen.emit(); + } else if (event.animationName === 'close' && this._state === 'closing') { + this._state = 'closed'; + this._didClose.emit(); + } } protected override render(): TemplateResult { @@ -202,14 +201,8 @@ export class SbbSelectionPanelElement extends LitElement {
this._onTransitionEnd(event)} - ${ref((el?: Element) => { - this._contentElement = el as HTMLElement; - if (this._contentElement) { - this._contentElement.inert = !this._checked && !this.forceOpen; - } - })} + .inert=${this._state !== 'opened'} + @animationend=${(event: AnimationEvent) => this._onAnimationEnd(event)} >