Skip to content

Commit

Permalink
refactor: replace namedSlotChangeHandlerAspect with `NamedSlotState…
Browse files Browse the repository at this point in the history
…Controller` functionality
  • Loading branch information
kyubisation committed Dec 12, 2023
1 parent 87d0e68 commit af25938
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 86 deletions.
1 change: 1 addition & 0 deletions src/components/core/common-behaviors/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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<string>();

// 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<HTMLElement>) {
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* @web/test-runner snapshot v1 */
export const snapshots = {};

snapshots["sbb-toggle-option renders"] =
`<input
aria-hidden="true"
id="sbb-toggle-option-id"
tabindex="-1"
type="radio"
value="Option 1"
>
<label
class="sbb-toggle-option"
for="sbb-toggle-option-id"
>
<slot name="icon">
</slot>
<span class="sbb-toggle-option__label">
<slot>
</slot>
</span>
</label>
`;
/* end snapshot sbb-toggle-option renders */

snapshots["sbb-toggle-option renders with sbb-icon"] =
`<input
aria-hidden="true"
id="sbb-toggle-option-id"
tabindex="-1"
type="radio"
>
<label
class="sbb-toggle-option"
for="sbb-toggle-option-id"
>
<slot name="icon">
<sbb-icon
aria-hidden="true"
data-namespace="default"
name="arrow-right-small"
role="img"
>
</sbb-icon>
</slot>
<span class="sbb-toggle-option__label">
<slot>
</slot>
</span>
</label>
`;
/* end snapshot sbb-toggle-option renders with sbb-icon */

snapshots["sbb-toggle-option renders with slotted sbb-icon"] =
`<input
aria-hidden="true"
id="sbb-toggle-option-id"
tabindex="-1"
type="radio"
>
<label
class="sbb-toggle-option"
for="sbb-toggle-option-id"
>
<slot name="icon">
</slot>
<span class="sbb-toggle-option__label">
<slot>
</slot>
</span>
</label>
`;
/* end snapshot sbb-toggle-option renders with slotted sbb-icon */

2 changes: 1 addition & 1 deletion src/components/toggle/toggle-option/toggle-option.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
69 changes: 34 additions & 35 deletions src/components/toggle/toggle-option/toggle-option.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,56 +10,55 @@ describe('sbb-toggle-option', () => {
html`<sbb-toggle-option checked value="Option 1"></sbb-toggle-option>`,
);

expect(root).dom.to.be.equal(
`
<sbb-toggle-option aria-checked="true" checked="" role="radio" tabindex="0" value="Option 1">
expect(root).dom.to.be.equal(`
<sbb-toggle-option aria-checked="true" checked role="radio" tabindex="0" value="Option 1">
</sbb-toggle-option>
`,
);
expect(root).shadowDom.to.be.equal(
`
<input aria-hidden="true" id="sbb-toggle-option-id" tabindex="-1" type="radio" value="Option 1">
<label class="sbb-toggle-option" for="sbb-toggle-option-id">
<span class="sbb-toggle-option__label">
<slot></slot>
</span>
</label>
`,
);
`);
await expect(root).shadowDom.to.be.equalSnapshot();
});

it('renders with sbb-icon', async () => {
const root = await fixture(
html`<sbb-toggle-option checked icon-name="arrow-right-small"></sbb-toggle-option>`,
);

expect(root).dom.to.be.equal(
`
expect(root).dom.to.be.equal(`
<sbb-toggle-option
aria-checked="true"
checked=""
checked
icon-name="arrow-right-small"
role="radio"
tabindex="0"
data-icon-only
>
</sbb-toggle-option>
`,
);
expect(root).shadowDom.to.be.equal(
`
<input aria-hidden="true" id="sbb-toggle-option-id" tabindex="-1" type="radio">
<label class="sbb-toggle-option" for="sbb-toggle-option-id">
<slot name="icon">
<sbb-icon aria-hidden="true" data-namespace="default" name="arrow-right-small" role="img"></sbb-icon>
</slot>
<span class="sbb-toggle-option__label">
<slot></slot>
</span>
</label>
`,
`);
await expect(root).shadowDom.to.be.equalSnapshot();
});

it('renders with slotted sbb-icon', async () => {
const root = await fixture(
html` <sbb-toggle-option>
<sbb-icon slot="icon" name="arrow-right-small"></sbb-icon>
</sbb-toggle-option>`,
);

expect(root).dom.to.be.equal(`
<sbb-toggle-option
aria-checked="false"
role="radio"
tabindex="-1"
data-slot-names="icon unnamed"
>
<sbb-icon
aria-hidden="true"
data-namespace="default"
name="arrow-right-small"
role="img"
slot="icon"
>
</sbb-icon>
</sbb-toggle-option>
`);
await expect(root).shadowDom.to.be.equalSnapshot();
});
});
65 changes: 15 additions & 50 deletions src/components/toggle/toggle-option/toggle-option.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -59,7 +54,7 @@ export class SbbToggleOptionElement extends LitElement {
/**
* Name of the icon for `<sbb-icon>`.
*/
@property({ attribute: 'icon-name' }) public iconName?: string;
@property({ attribute: 'icon-name', reflect: true }) public iconName?: string;

/**
* Value of toggle-option.
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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`
<input
Expand All @@ -184,18 +156,11 @@ export class SbbToggleOptionElement extends LitElement {
@click=${(event) => event.stopPropagation()}
/>
<label class="sbb-toggle-option" for="sbb-toggle-option-id">
${this.iconName || this._namedSlots.icon
? html`<slot name="icon">
${this.iconName ? html`<sbb-icon name=${this.iconName}></sbb-icon>` : nothing}
</slot>`
: nothing}
<slot name="icon"
>${this.iconName ? html`<sbb-icon name=${this.iconName}></sbb-icon>` : nothing}</slot
>
<span class="sbb-toggle-option__label">
<slot
@slotchange=${(event) =>
(this._hasLabel = (event.target as HTMLSlotElement)
.assignedNodes()
.some((n) => !!n.textContent?.trim()))}
></slot>
<slot></slot>
</span>
</label>
`;
Expand Down

0 comments on commit af25938

Please sign in to comment.