diff --git a/src/components/core/common-behaviors/index.ts b/src/components/core/common-behaviors/index.ts index 2f47fc9d7f7..f6f89ce3147 100644 --- a/src/components/core/common-behaviors/index.ts +++ b/src/components/core/common-behaviors/index.ts @@ -1,4 +1,5 @@ export * from './constructor'; export * from './language-controller'; +export * from './named-slot-state-controller'; export * from './slot-child-observer'; export * from './update-scheduler'; diff --git a/src/components/core/common-behaviors/named-slot-state-controller.ts b/src/components/core/common-behaviors/named-slot-state-controller.ts new file mode 100644 index 00000000000..920186a1765 --- /dev/null +++ b/src/components/core/common-behaviors/named-slot-state-controller.ts @@ -0,0 +1,62 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; + +/** + * This controller checks for slotted children. From these it generates + * a list of used slot names (`unnamed` for children without a slot attribute) + * and adds this to the `data-slot-names` attribute, as a space separated list. + * + * This allows CSS attribute selector to display/hide/configure a section + * of the component as required (see [attr~=value] selector specifically). + * + * @example + * .example { + * display: none; + * + * :host([data-slot-names~="icon"]) & { + * display: inline; + * } + * } + * + * https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors + */ +export class NamedSlotStateController implements ReactiveController { + private _slots = new Set(); + + // We avoid using AbortController here, as it would mean creating + // a new instance for every NamedSlotStateController instance. + private _slotchangeHandler = (event: Event): void => { + this._syncSlots(event.target as HTMLSlotElement); + }; + + public constructor(private _host: ReactiveControllerHost & Partial) { + this._host.addController(this); + } + + public hostConnected(): void { + // TODO: Check if this is really needed with SSR. + this._syncSlots(...this._host.querySelectorAll('slot')); + this._host.shadowRoot?.addEventListener('slotchange', this._slotchangeHandler); + } + + public hostDisconnected(): void { + this._host.shadowRoot?.removeEventListener('slotchange', this._slotchangeHandler); + } + + private _syncSlots(...slots: HTMLSlotElement[]): void { + for (const slot of slots) { + const slotName = slot.name || 'unnamed'; + if (slot.assignedNodes()) { + this._slots.add(slotName); + } else { + this._slots.delete(slotName); + } + } + + const joinedSlotNames = [...this._slots].sort().join(' '); + if (!joinedSlotNames) { + this._host.removeAttribute('data-slot-names'); + } else if (this._host.getAttribute('data-slot-names') !== joinedSlotNames) { + this._host.setAttribute('data-slot-names', joinedSlotNames); + } + } +} diff --git a/src/components/toggle/toggle-option/__snapshots__/toggle-option.spec.snap.js b/src/components/toggle/toggle-option/__snapshots__/toggle-option.spec.snap.js new file mode 100644 index 00000000000..0b2631553e0 --- /dev/null +++ b/src/components/toggle/toggle-option/__snapshots__/toggle-option.spec.snap.js @@ -0,0 +1,74 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-toggle-option renders"] = +` + +`; +/* end snapshot sbb-toggle-option renders */ + +snapshots["sbb-toggle-option renders with sbb-icon"] = +` + +`; +/* end snapshot sbb-toggle-option renders with sbb-icon */ + +snapshots["sbb-toggle-option renders with slotted sbb-icon"] = +` + +`; +/* end snapshot sbb-toggle-option renders with slotted sbb-icon */ + diff --git a/src/components/toggle/toggle-option/toggle-option.scss b/src/components/toggle/toggle-option/toggle-option.scss index 19f599281a0..0c22f2bc947 100644 --- a/src/components/toggle/toggle-option/toggle-option.scss +++ b/src/components/toggle/toggle-option/toggle-option.scss @@ -49,7 +49,7 @@ input[type='radio'] { border-radius: var(--sbb-toggle-option-border-radius); color: var(--sbb-toggle-option-color); - :host(:not([data-icon-only])) & { + :host([data-slot-names~='unnamed']:where([data-slot-names~='icon'], [icon-name])) & { gap: var(--sbb-spacing-fixed-1x); } } diff --git a/src/components/toggle/toggle-option/toggle-option.spec.ts b/src/components/toggle/toggle-option/toggle-option.spec.ts index 2c9b2947b4d..be8788cbfea 100644 --- a/src/components/toggle/toggle-option/toggle-option.spec.ts +++ b/src/components/toggle/toggle-option/toggle-option.spec.ts @@ -10,23 +10,11 @@ describe('sbb-toggle-option', () => { html``, ); - expect(root).dom.to.be.equal( - ` - - + expect(root).dom.to.be.equal(` + - `, - ); - expect(root).shadowDom.to.be.equal( - ` - - - `, - ); + `); + await expect(root).shadowDom.to.be.equalSnapshot(); }); it('renders with sbb-icon', async () => { @@ -34,32 +22,43 @@ describe('sbb-toggle-option', () => { html``, ); - expect(root).dom.to.be.equal( - ` + expect(root).dom.to.be.equal(` - - `, - ); - expect(root).shadowDom.to.be.equal( - ` - - - `, + `); + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + + it('renders with slotted sbb-icon', async () => { + const root = await fixture( + html` + + `, ); + + expect(root).dom.to.be.equal(` + + + + `); + await expect(root).shadowDom.to.be.equalSnapshot(); }); }); diff --git a/src/components/toggle/toggle-option/toggle-option.ts b/src/components/toggle/toggle-option/toggle-option.ts index 3e4a361bab0..c0669cb22b4 100644 --- a/src/components/toggle/toggle-option/toggle-option.ts +++ b/src/components/toggle/toggle-option/toggle-option.ts @@ -1,14 +1,9 @@ import { CSSResultGroup, LitElement, TemplateResult, html, nothing } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; +import { NamedSlotStateController } from '../../core/common-behaviors'; import { setAttribute } from '../../core/dom'; -import { - ConnectedAbortController, - EventEmitter, - HandlerRepository, - createNamedSlotState, - namedSlotChangeHandlerAspect, -} from '../../core/eventing'; +import { ConnectedAbortController, EventEmitter } from '../../core/eventing'; import '../../icon'; import type { SbbToggleElement, SbbToggleStateChange } from '../toggle'; @@ -59,7 +54,7 @@ export class SbbToggleOptionElement extends LitElement { /** * Name of the icon for ``. */ - @property({ attribute: 'icon-name' }) public iconName?: string; + @property({ attribute: 'icon-name', reflect: true }) public iconName?: string; /** * Value of toggle-option. @@ -75,23 +70,8 @@ export class SbbToggleOptionElement extends LitElement { } private _value: string | null = null; - /** - * Whether the toggle option has a label. - */ - @state() private _hasLabel = false; - - /** - * State of listed named slots, by indicating whether any element for a named slot is defined. - */ - @state() private _namedSlots = createNamedSlotState('icon'); - private _toggle?: SbbToggleElement; - private _handlerRepository = new HandlerRepository( - this, - namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), - ); - /** * @internal * Internal event that emits whenever the state of the toggle option @@ -103,13 +83,19 @@ export class SbbToggleOptionElement extends LitElement { { bubbles: true }, ); + private _abort = new ConnectedAbortController(this); + + public constructor() { + super(); + new NamedSlotStateController(this); + } + private _handleCheckedChange(currentValue: boolean, previousValue: boolean): void { if (currentValue !== previousValue) { this._stateChange.emit({ type: 'checked', checked: currentValue }); this._verifyTabindex(); } } - private _abort = new ConnectedAbortController(this); private _handleValueChange(currentValue: string, previousValue: string): void { if (this.checked && currentValue !== previousValue) { @@ -144,20 +130,11 @@ export class SbbToggleOptionElement extends LitElement { this.addEventListener('click', () => this.shadowRoot.querySelector('label').click(), { signal, }); - this._handlerRepository.connect(); - this._hasLabel = Array.from(this.childNodes).some( - (n) => !(n as Element).slot && n.textContent?.trim(), - ); // We can use closest here, as we expect the parent sbb-toggle to be in light DOM. this._toggle = this.closest?.('sbb-toggle'); this._verifyTabindex(); } - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._handlerRepository.disconnect(); - } - private _verifyTabindex(): void { this.tabIndex = this.checked && !this.disabled ? 0 : -1; } @@ -166,11 +143,6 @@ export class SbbToggleOptionElement extends LitElement { setAttribute(this, 'aria-checked', (!!this.checked).toString()); setAttribute(this, 'aria-disabled', this.disabled); setAttribute(this, 'role', 'radio'); - setAttribute( - this, - 'data-icon-only', - !this._hasLabel && !!(this.iconName || this._namedSlots.icon), - ); return html` event.stopPropagation()} /> `;