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\`
${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)}
>