diff --git a/config/custom-elements-manifest.config.js b/config/custom-elements-manifest.config.js index 665249747c..fb19700740 100644 --- a/config/custom-elements-manifest.config.js +++ b/config/custom-elements-manifest.config.js @@ -1,5 +1,3 @@ -import { parse } from 'comment-parser'; - /** * Docs: https://custom-elements-manifest.open-wc.org/analyzer/getting-started/ */ @@ -13,26 +11,7 @@ export default { plugins: [ { analyzePhase({ ts, node, moduleDoc }) { - if (ts.isPropertyDeclaration(node)) { - const className = node.parent.name.getText(); - const classDoc = moduleDoc.declarations.find( - (declaration) => declaration.name === className, - ); - - for (const jsDoc of node.jsDoc ?? []) { - for (const parsedJsDoc of parse(jsDoc.getFullText())) { - for (const tag of parsedJsDoc.tags) { - if (tag.tag === 'ssrchildcounter') { - const member = classDoc.members.find((m) => m.name === node.name.getText()); - member['_ssrchildcounter'] = true; - } - } - } - } - } else if ( - ts.isNewExpression(node) && - node.expression.getText() === 'NamedSlotStateController' - ) { + if (ts.isNewExpression(node) && node.expression.getText() === 'NamedSlotStateController') { let classNode = node; while (classNode) { if (ts.isClassDeclaration(classNode)) { diff --git a/src/components/autocomplete/autocomplete.ts b/src/components/autocomplete/autocomplete.ts index d8a18b3b24..29a48152f2 100644 --- a/src/components/autocomplete/autocomplete.ts +++ b/src/components/autocomplete/autocomplete.ts @@ -239,6 +239,7 @@ export class SbbAutocompleteElement extends SlotChildObserver(LitElement) { } protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); if (changedProperties.has('origin')) { this._resetOriginClickListener(this.origin, changedProperties.get('origin')); } diff --git a/src/components/breadcrumb/breadcrumb-group/__snapshots__/breadcrumb-group.spec.snap.js b/src/components/breadcrumb/breadcrumb-group/__snapshots__/breadcrumb-group.spec.snap.js index 06c6a30362..9892581766 100644 --- a/src/components/breadcrumb/breadcrumb-group/__snapshots__/breadcrumb-group.spec.snap.js +++ b/src/components/breadcrumb/breadcrumb-group/__snapshots__/breadcrumb-group.spec.snap.js @@ -4,7 +4,7 @@ export const snapshots = {}; snapshots["sbb-breadcrumb-group renders"] = `
  1. - +
  2. - +
  3. - +
diff --git a/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.e2e.ts b/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.e2e.ts index 089dd3d7af..2dc512f10c 100644 --- a/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.e2e.ts +++ b/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.e2e.ts @@ -91,8 +91,8 @@ describe('sbb-breadcrumb-group', () => { // only two slots are displayed, and the second is the last one const slots = breadcrumbGroup.shadowRoot!.querySelectorAll('li > slot'); expect(slots.length).to.be.equal(2); - expect(slots[0]).to.have.attribute('name', 'breadcrumb-0'); - expect(slots[1]).to.have.attribute('name', 'breadcrumb-6'); + expect(slots[0]).to.have.attribute('name', 'li-0'); + expect(slots[1]).to.have.attribute('name', 'li-6'); }); it('keyboard navigation with ellipsis', async () => { diff --git a/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.spec.ts b/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.spec.ts index 859548f7b0..3adb44d206 100644 --- a/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.spec.ts +++ b/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.spec.ts @@ -19,11 +19,11 @@ describe('sbb-breadcrumb-group', () => { expect(root).dom.to.be.equal(` - - + + One - + Two diff --git a/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.ts b/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.ts index 771a6f5e8d..392459b6ba 100644 --- a/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.ts +++ b/src/components/breadcrumb/breadcrumb-group/breadcrumb-group.ts @@ -1,9 +1,10 @@ -import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; -import { html, LitElement, nothing } from 'lit'; +import type { CSSResultGroup, PropertyValueMap, PropertyValues, TemplateResult } from 'lit'; +import { html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { getNextElementIndex, isArrowKeyPressed, sbbInputModalityDetector } from '../../core/a11y'; -import { LanguageController, SlotChildObserver } from '../../core/common-behaviors'; +import type { WithListChildren } from '../../core/common-behaviors'; +import { LanguageController, NamedSlotListElement } from '../../core/common-behaviors'; import { setAttribute } from '../../core/dom'; import { ConnectedAbortController } from '../../core/eventing'; import { i18nBreadcrumbEllipsisButtonLabel } from '../../core/i18n'; @@ -20,11 +21,9 @@ import '../../icon'; * @slot - Use the unnamed slot to add `sbb-breadcrumb` elements. */ @customElement('sbb-breadcrumb-group') -export class SbbBreadcrumbGroupElement extends SlotChildObserver(LitElement) { +export class SbbBreadcrumbGroupElement extends NamedSlotListElement { public static override styles: CSSResultGroup = style; - - /** Local instance of slotted sbb-breadcrumb elements */ - @state() private _breadcrumbs: SbbBreadcrumbElement[] = []; + protected override readonly listChildTagNames = ['SBB-BREADCRUMB']; @state() private _state?: 'collapsed' | 'manually-expanded'; @@ -35,7 +34,7 @@ export class SbbBreadcrumbGroupElement extends SlotChildObserver(LitElement) { private _handleKeyDown(evt: KeyboardEvent): void { if ( - !this._breadcrumbs || + !this.listChildren.length || // don't trap nested handling ((evt.target as HTMLElement) !== this && (evt.target as HTMLElement).parentElement !== this) ) { @@ -62,52 +61,41 @@ export class SbbBreadcrumbGroupElement extends SlotChildObserver(LitElement) { this.toggleAttribute('data-loaded', true); } - protected override updated(): void { - if (this._markForFocus && sbbInputModalityDetector.mostRecentModality === 'keyboard') { - this._breadcrumbs[1]?.focus(); - - // Reset mark for focus - this._markForFocus = false; - } - } - public override disconnectedCallback(): void { super.disconnectedCallback(); this._resizeObserver.disconnect(); } - /** Creates and sets an array with only the sbb-breadcrumb children. */ - protected override checkChildren(): void { - this._evaluateCollapsedState(); + protected override willUpdate(changedProperties: PropertyValueMap>): void { + super.willUpdate(changedProperties); + if (changedProperties.has('listChildren')) { + this._syncBreadcrumbs(); + } + } - const breadcrumbs = Array.from(this.children ?? []).filter( - (e): e is SbbBreadcrumbElement => e.tagName === 'SBB-BREADCRUMB', - ); - // If the slotted sbb-breadcrumb instances have not changed, - // we can skip syncing and updating the breadcrumb reference list. - if ( - this._breadcrumbs && - breadcrumbs.length === this._breadcrumbs.length && - this._breadcrumbs.every((e, i) => breadcrumbs[i] === e) - ) { - return; + protected override updated(changedProperties: PropertyValueMap>): void { + super.updated(changedProperties); + if (changedProperties.has('listChildren')) { + Promise.resolve().then(() => this._evaluateCollapsedState()); + } + if (this._markForFocus && sbbInputModalityDetector.mostRecentModality === 'keyboard') { + this.listChildren[1]?.focus(); + + // Reset mark for focus + this._markForFocus = false; } - this._breadcrumbs = breadcrumbs; - this._syncBreadcrumbs(); } /** Apply the aria-current attribute to the last sbb-breadcrumb element. */ private _syncBreadcrumbs(): void { - this._breadcrumbs.forEach((breadcrumb, index) => { - breadcrumb.removeAttribute('aria-current'); - if (!breadcrumb.id) { - breadcrumb.id = `sbb-breadcrumb-${index}`; - } - }); - this._breadcrumbs[this._breadcrumbs.length - 1]?.setAttribute('aria-current', 'page'); + this.listChildren + .slice(0, -1) + .filter((c) => c.hasAttribute('aria-current')) + .forEach((c) => c.removeAttribute('aria-current')); + this.listChildren[this.listChildren.length - 1]?.setAttribute('aria-current', 'page'); // If it is not expandable, reset state - if (this._breadcrumbs.length < 3) { + if (this.listChildren.length < 3) { this._state = undefined; } } @@ -117,16 +105,16 @@ export class SbbBreadcrumbGroupElement extends SlotChildObserver(LitElement) { */ private _focusNextCollapsed(evt: KeyboardEvent): void { const arrayCollapsed: SbbBreadcrumbElement[] = [ - this._breadcrumbs[0], + this.listChildren[0], this.shadowRoot!.querySelector('#sbb-breadcrumb-ellipsis') as SbbBreadcrumbElement, - this._breadcrumbs[this._breadcrumbs.length - 1], + this.listChildren[this.listChildren.length - 1], ]; this._focusNext(evt, arrayCollapsed); } private _focusNext( evt: KeyboardEvent, - breadcrumbs: SbbBreadcrumbElement[] = this._breadcrumbs, + breadcrumbs: SbbBreadcrumbElement[] = this.listChildren, ): void { const current: number = breadcrumbs.findIndex( (e) => e === document.activeElement || e === this.shadowRoot!.activeElement, @@ -154,17 +142,9 @@ export class SbbBreadcrumbGroupElement extends SlotChildObserver(LitElement) { } private _renderCollapsed(): TemplateResult { - for (let i = 0; i < this._breadcrumbs.length; i++) { - if (i === 0 || i === this._breadcrumbs.length - 1) { - this._breadcrumbs[i].setAttribute('slot', `breadcrumb-${i}`); - } else { - this._breadcrumbs[i].removeAttribute('slot'); - } - } - return html`
  • - +
  • - +
  • `; } private _renderExpanded(): TemplateResult[] { - const slotName = (index: number): string => `breadcrumb-${index}`; - - return this._breadcrumbs.map((element: SbbBreadcrumbElement, index: number) => { - element.setAttribute('slot', slotName(index)); - - return html` + return this.listSlotNames().map( + (name, index, array) => html`
  • - - ${index !== this._breadcrumbs.length - 1 + + ${index !== array.length - 1 ? html`` : nothing}
  • - `; - }); + `, + ); } protected override render(): TemplateResult { @@ -216,12 +192,10 @@ export class SbbBreadcrumbGroupElement extends SlotChildObserver(LitElement) { setAttribute(this, 'data-state', this._state); return html` -
      +
        ${this._state === 'collapsed' ? this._renderCollapsed() : this._renderExpanded()}
      - + ${this.renderHiddenSlot()} `; } } diff --git a/src/components/breadcrumb/breadcrumb/breadcrumb.spec.ts b/src/components/breadcrumb/breadcrumb/breadcrumb.spec.ts index 1b2c9bbf16..6da7c4ac3f 100644 --- a/src/components/breadcrumb/breadcrumb/breadcrumb.spec.ts +++ b/src/components/breadcrumb/breadcrumb/breadcrumb.spec.ts @@ -1,17 +1,27 @@ import { expect, fixture } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; + +import { waitForLitRender } from '../../core/testing'; + import './breadcrumb'; describe('sbb-breadcrumb', () => { it('renders with text', async () => { const root = await fixture(html` - Breadcrumb `); expect(root).dom.to.be.equal(` - + Breadcrumb `); @@ -24,8 +34,14 @@ describe('sbb-breadcrumb', () => { `); + await waitForLitRender(root); expect(root).dom.to.be.equal(` - + `); await expect(root).shadowDom.to.equalSnapshot(); @@ -36,8 +52,14 @@ describe('sbb-breadcrumb', () => { Home `); + await waitForLitRender(root); expect(root).dom.to.be.equal(` - + Home `); diff --git a/src/components/breadcrumb/breadcrumb/breadcrumb.ts b/src/components/breadcrumb/breadcrumb/breadcrumb.ts index 9e465a0abb..23d6c33ac2 100644 --- a/src/components/breadcrumb/breadcrumb/breadcrumb.ts +++ b/src/components/breadcrumb/breadcrumb/breadcrumb.ts @@ -51,9 +51,6 @@ export class SbbBreadcrumbElement extends SlotChildObserver(LitElement) { public override connectedCallback(): void { super.connectedCallback(); - this._hasText = Array.from(this.childNodes ?? []).some( - (n) => !(n as Element).slot && n.textContent?.trim(), - ); this._handlerRepository.connect(); } @@ -63,9 +60,9 @@ export class SbbBreadcrumbElement extends SlotChildObserver(LitElement) { } protected override checkChildren(): void { - this._hasText = !!this.shadowRoot!.querySelector('slot:not([name])') - ?.assignedNodes() - .some((n) => !!n.textContent?.trim()); + this._hasText = Array.from(this.childNodes ?? []).some( + (n) => !(n as Element).slot && n.textContent?.trim(), + ); } protected override render(): TemplateResult { diff --git a/src/components/core/common-behaviors/index.ts b/src/components/core/common-behaviors/index.ts index f6f89ce314..c643161d9e 100644 --- a/src/components/core/common-behaviors/index.ts +++ b/src/components/core/common-behaviors/index.ts @@ -1,5 +1,6 @@ export * from './constructor'; export * from './language-controller'; export * from './named-slot-state-controller'; +export * from './named-slot-list-element'; export * from './slot-child-observer'; export * from './update-scheduler'; diff --git a/src/components/core/common-behaviors/named-slot-list-element.ts b/src/components/core/common-behaviors/named-slot-list-element.ts new file mode 100644 index 0000000000..f3fc3541fe --- /dev/null +++ b/src/components/core/common-behaviors/named-slot-list-element.ts @@ -0,0 +1,127 @@ +import type { TemplateResult } from 'lit'; +import { LitElement, html, nothing } from 'lit'; +import { state } from 'lit/decorators.js'; + +import { SlotChildObserver } from './slot-child-observer'; + +const SSR_CHILD_COUNT_ATTRIBUTE = 'data-ssr-child-count'; +const SLOTNAME_PREFIX = 'li'; + +/** + * Helper type for willUpdate or similar checks. + * Allows the usage of the string literal 'listChildren'. + * + * @example + * protected override willUpdate(changedProperties: PropertyValueMap>): void { + * if (changedProperties.has('listChildren')) { + * ... + * } + * } + */ +export type WithListChildren< + T extends NamedSlotListElement, + C extends HTMLElement = HTMLElement, +> = T & { listChildren: C[] }; + +/** + * This base class provides named slot list observer functionality. + * This allows using the pattern of rendering a named slot for each child, which allows + * wrapping children in a ul/li list. + */ +export abstract class NamedSlotListElement< + C extends HTMLElement = HTMLElement, +> extends SlotChildObserver(LitElement) { + /** A list of upper-cased tag names to match against. (e.g. SBB-LINK) */ + protected abstract readonly listChildTagNames: string[]; + + /** + * A list of children with the defined tag names. + * This array is only updated, if there is an actual change + * to the child elements. + */ + @state() protected listChildren: C[] = []; + + protected override checkChildren(): void { + const listChildren = Array.from(this.children ?? []).filter((e): e is C => + this.listChildTagNames.includes(e.tagName), + ); + // If the slotted child instances have not changed, we can skip syncing and updating + // the link reference list. + if ( + listChildren.length === this.listChildren.length && + this.listChildren.every((e, i) => listChildren[i] === e) + ) { + return; + } + + this.listChildren + .filter((c) => !listChildren.includes(c)) + .forEach((c) => c.removeAttribute('slot')); + this.listChildren = listChildren; + this.listChildren.forEach((c, index) => c.setAttribute('slot', `${SLOTNAME_PREFIX}-${index}`)); + + // Remove the ssr attribute, once we have actually initialized the children elements. + this.removeAttribute(SSR_CHILD_COUNT_ATTRIBUTE); + } + + /** + * Renders list and list slots for slotted children or an amount of list slots + * corresponding to the `data-ssr-child-count` attribute value. + * + * This is a possible optimization for SSR, as in an SSR Lit environment + * other elements are not available, but might be available in the meta + * framework wrapper (like e.g. React). This allows to provide the amount of + * children to be passed via the `data-ssr-child-count` attribute value. + */ + protected renderList( + attributes: { class?: string; ariaLabel?: string; ariaLabelledby?: string } = {}, + ): TemplateResult { + return html` +
        + ${this.listSlotNames().map((name) => html`
      • `)} +
      + ${this.renderHiddenSlot()} + `; + } + + /** + * Returns an array of list slot names with the length corresponding to the amount of matched + * children or the `data-ssr-child-count` attribute value. + * + * This is a possible optimization for SSR, as in an SSR Lit environment + * other elements are not available, but might be available in the meta + * framework wrapper (like e.g. React). This allows to provide the amount of + * children to be passed via the `data-ssr-child-count` attribute value. + */ + protected listSlotNames(): string[] { + const listChildren = this.listChildren.length + ? this.listChildren + : Array.from({ length: +(this.getAttribute(SSR_CHILD_COUNT_ATTRIBUTE) ?? 0) }); + return listChildren.map((_, i) => `${SLOTNAME_PREFIX}-${i}`); + } + + /** + * Returns 'presentation' when less than two children are available. + * This is an accessibility improvement, as only lists with more than one + * children should be marked as lists. + */ + protected roleOverride(): 'presentation' | typeof nothing { + return (this.listChildren.length || +(this.getAttribute(SSR_CHILD_COUNT_ATTRIBUTE) ?? 0)) >= 2 + ? nothing + : 'presentation'; + } + + /** + * Returns a hidden slot, which is intended as the children change detection. + * When an element without a slot attribute is slotted to the element, it triggers + * the slotchange event, which can be used to assign it to the appropriate named slot. + */ + protected renderHiddenSlot(): TemplateResult { + return html``; + } +} diff --git a/src/components/core/common-behaviors/slot-child-observer.ts b/src/components/core/common-behaviors/slot-child-observer.ts index 530926c469..2cff8635c7 100644 --- a/src/components/core/common-behaviors/slot-child-observer.ts +++ b/src/components/core/common-behaviors/slot-child-observer.ts @@ -1,12 +1,10 @@ import type { LitElement, PropertyValues } from 'lit'; -import { ConnectedAbortController } from '../eventing'; - import type { Constructor } from './constructor'; // Define the interface for the mixin -export declare class SlotChildObserverType { - protected checkChildren(): void; +export declare abstract class SlotChildObserverType { + protected abstract checkChildren(): void; } /** @@ -22,51 +20,67 @@ export const SlotChildObserver = >( ): Constructor & T => { class SlotChildObserverClass extends base implements Partial { /** + * Whether the component needs hydration. + * @see https://github.com/lit/lit/blob/main/packages/labs/ssr-client/src/lit-element-hydrate-support.ts + * * We have a problem with SSR, in that we don't have a reference to children. * Due to this, the SSR/hydration can fail, when internal elements depend on children * and will therefore not be rendered server side, but will be immediately rendered client side. * Due to this, we add this initialized property, which will prevent child detection * until it has been initialized/hydrated. - * - * https://github.com/lit/lit/discussions/4407 + * @see https://github.com/lit/lit/discussions/4407 */ - private _hydrated = new Promise((r) => (this._hydratedResolve = r)); - private _hydratedResolve?: () => void; - private _hydratedResolved = false; - private _slotchangeAbortController = new ConnectedAbortController(this); + private _needsHydration = false; + private _slotchangeHandler = (): void => { + if (this._needsHydration) { + return; + } + this.checkChildren(); + }; - public override connectedCallback(): void { - super.connectedCallback(); - if (this._hydratedResolved) { + protected override createRenderRoot(): HTMLElement | DocumentFragment { + // Check whether hydration is needed by checking whether the shadow root + // is available before createRenderRoot is called. + this._needsHydration = !!this.shadowRoot && 'litElementHydrateSupport' in globalThis; + return super.createRenderRoot(); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + // If hydration is not needed we can immediately check for children + // in the first update. + if (!this.hasUpdated && !this._needsHydration) { this.checkChildren(); } - this.shadowRoot?.addEventListener( - 'slotchange', - () => { - if (!this._hydratedResolved) { - return; - } - this.checkChildren(); - }, - { signal: this._slotchangeAbortController.signal }, - ); } - protected checkChildren(): void { - // Needs to be implemented by inherited classes + protected override update(changedProperties: PropertyValues): void { + // When hydration is needed, we wait the hydration process to finish, which is patched + // into the update method of the LitElement base class. + super.update(changedProperties); + if (this._needsHydration) { + this._needsHydration = false; + // Scheduling checkChildren needs to be delayed by a micro-task, else lit + // will detect a change immediately after an update and warn in the console. + Promise.resolve().then(() => this.checkChildren()); + } } - protected override firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - this._hydratedResolved = true; - this._hydratedResolve?.(); - Promise.resolve().then(() => this.checkChildren()); + public override connectedCallback(): void { + super.connectedCallback(); + if (!this._needsHydration) { + this.checkChildren(); + } + this.shadowRoot!.addEventListener('slotchange', this._slotchangeHandler); } - protected override async getUpdateComplete(): Promise { - const result = await super.getUpdateComplete(); - await this._hydrated; - return result; + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.shadowRoot!.removeEventListener('slotchange', this._slotchangeHandler); + } + + protected checkChildren(): void { + // Needs to be implemented by inherited classes } } return SlotChildObserverClass as unknown as Constructor & T; diff --git a/src/components/icon/icon-base.ts b/src/components/icon/icon-base.ts index 8db12e58e1..e92e776dd2 100644 --- a/src/components/icon/icon-base.ts +++ b/src/components/icon/icon-base.ts @@ -2,12 +2,13 @@ import type { CSSResultGroup, TemplateResult } from 'lit'; import { html, LitElement } from 'lit'; import { property, state } from 'lit/decorators.js'; +import { UpdateScheduler } from '../core/common-behaviors'; import { setAttribute } from '../core/dom'; import { getSvgContent } from './icon-request'; import style from './icon.scss?lit&inline'; -export abstract class SbbIconBase extends LitElement { +export abstract class SbbIconBase extends UpdateScheduler(LitElement) { public static override styles: CSSResultGroup = style; @state() private _svgNamespace = 'default'; @@ -30,13 +31,18 @@ export abstract class SbbIconBase extends LitElement { return; } + this.startUpdate(); const [namespace, name] = this._splitIconName(iconName); if (namespace) { this._svgNamespace = namespace; } - this._svgIcon = await this.fetchSvgIcon(this._svgNamespace, name); + try { + this._svgIcon = await this.fetchSvgIcon(this._svgNamespace, name); + } finally { + this.completeUpdate(); + } } protected async fetchSvgIcon(namespace: string, name: string): Promise { diff --git a/src/components/icon/icon.spec.ts b/src/components/icon/icon.spec.ts index b39a936f92..4dbade117e 100644 --- a/src/components/icon/icon.spec.ts +++ b/src/components/icon/icon.spec.ts @@ -110,7 +110,7 @@ describe('sbb-icon', () => { await waitForLitRender(root); expect(root).dom.to.be.equal(` - `, @@ -140,8 +146,17 @@ describe('sbb-train-wagon', () => { - - Additional wagon information + + @@ -157,6 +172,7 @@ describe('sbb-train-wagon', () => { >`, ); + await waitForLitRender(root); expect(root).dom.to.be.equal( ` @@ -165,13 +181,13 @@ describe('sbb-train-wagon', () => { data-namespace="default" name="sa-rs" role="img" - slot="sbb-train-wagon-icon-0"> + slot="li-0"> + slot="li-1"> `, ); @@ -193,14 +209,14 @@ describe('sbb-train-wagon', () => {
        -
      • - +
      • +
      • -
      • - +
      • +
      - diff --git a/src/components/train/train-wagon/train-wagon.ts b/src/components/train/train-wagon/train-wagon.ts index 11636795a1..a96fc99a30 100644 --- a/src/components/train/train-wagon/train-wagon.ts +++ b/src/components/train/train-wagon/train-wagon.ts @@ -1,9 +1,9 @@ import type { CSSResultGroup, TemplateResult } from 'lit'; -import { LitElement, nothing } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; import { html, unsafeStatic } from 'lit/static-html.js'; -import { LanguageController, SlotChildObserver } from '../../core/common-behaviors'; +import { LanguageController, NamedSlotListElement } from '../../core/common-behaviors'; import { setAttribute } from '../../core/dom'; import { EventEmitter } from '../../core/eventing'; import { @@ -28,11 +28,12 @@ import style from './train-wagon.scss?lit&inline'; * @slot - Use the unnamed slot to add one or more `sbb-icon` for meta-information of the `sbb-train-wagon`. */ @customElement('sbb-train-wagon') -export class SbbTrainWagonElement extends SlotChildObserver(LitElement) { +export class SbbTrainWagonElement extends NamedSlotListElement { public static override styles: CSSResultGroup = style; public static readonly events = { sectorChange: 'sectorChange', } as const; + protected override readonly listChildTagNames = ['SBB-ICON']; /** Wagon type. */ @property({ reflect: true }) public type: 'locomotive' | 'closed' | 'wagon' = 'wagon'; @@ -65,9 +66,6 @@ export class SbbTrainWagonElement extends SlotChildObserver(LitElement) { @property({ attribute: 'additional-accessibility-text' }) public additionalAccessibilityText?: string; - /** Slotted Sbb-icons. */ - @state() private _icons: SbbIconElement[] = []; - private _language = new LanguageController(this); /** @@ -87,25 +85,7 @@ export class SbbTrainWagonElement extends SlotChildObserver(LitElement) { this._sectorChange.emit(); } - /** - * Create an array with only the sbb-icon children. - */ - protected override checkChildren(): void { - this._icons = Array.from(this.children ?? []).filter( - (e): e is SbbIconElement => e.tagName === 'SBB-ICON', - ); - } - protected override render(): TemplateResult { - // We should avoid lists with only one entry - if (this._icons?.length > 1) { - this._icons.forEach((icon, index) => - icon.setAttribute('slot', `sbb-train-wagon-icon-${index}`), - ); - } else { - this._icons?.forEach((icon) => icon.removeAttribute('slot')); - } - const label = (tagName: string): TemplateResult => { const TAG_NAME = tagName; /* eslint-disable lit/binding-positions */ @@ -204,31 +184,11 @@ export class SbbTrainWagonElement extends SlotChildObserver(LitElement) { ? html`, ${this.additionalAccessibilityText}` : nothing} ${this.type === 'wagon' - ? html` - ${this._icons?.length > 1 - ? html`
        - ${this._icons.map( - (_, index) => - html`
      • - -
      • `, - )} -
      ` - : nothing} - - ${this._icons?.length === 1 - ? html` - ${i18nAdditionalWagonInformationHeading[this._language.current]} - ` - : nothing} - - + ? html` + ${this.renderList({ + class: 'sbb-train-wagon__icons-list', + ariaLabel: i18nAdditionalWagonInformationHeading[this._language.current], + })} ` : nothing} diff --git a/src/components/train/train/train.spec.ts b/src/components/train/train/train.spec.ts index 26ef0479df..8d2ea5ed50 100644 --- a/src/components/train/train/train.spec.ts +++ b/src/components/train/train/train.spec.ts @@ -1,5 +1,8 @@ import { expect, fixture } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; + +import { waitForLitRender } from '../../core/testing'; + import './train'; import '../../icon'; @@ -9,6 +12,7 @@ describe('sbb-train', () => { html``, ); + await waitForLitRender(root); expect(root).dom.to.be.equal( ``, ); @@ -16,7 +20,7 @@ describe('sbb-train', () => { `
      Train, Driving direction Bern.
      -
        + diff --git a/src/components/train/train/train.ts b/src/components/train/train/train.ts index f5c98d0e75..f4523f24c3 100644 --- a/src/components/train/train/train.ts +++ b/src/components/train/train/train.ts @@ -1,9 +1,10 @@ -import type { CSSResultGroup, TemplateResult } from 'lit'; -import { LitElement, nothing } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import type { CSSResultGroup, PropertyValueMap, TemplateResult } from 'lit'; +import { nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; import { html, unsafeStatic } from 'lit/static-html.js'; -import { LanguageController, SlotChildObserver } from '../../core/common-behaviors'; +import type { WithListChildren } from '../../core/common-behaviors'; +import { LanguageController, NamedSlotListElement } from '../../core/common-behaviors'; import { EventEmitter } from '../../core/eventing'; import { i18nTrain, i18nWagonsLabel } from '../../core/i18n'; import type { TitleLevel } from '../../title'; @@ -20,11 +21,14 @@ import '../../icon'; * @slot - Use the unnamed slot to add 'sbb-train-wagon' elements to the `sbb-train`. */ @customElement('sbb-train') -export class SbbTrainElement extends SlotChildObserver(LitElement) { +export class SbbTrainElement extends NamedSlotListElement< + SbbTrainWagonElement | SbbTrainBlockedPassageElement +> { public static override styles: CSSResultGroup = style; public static readonly events = { trainSlotChange: 'trainSlotChange', } as const; + protected override readonly listChildTagNames = ['SBB-TRAIN-WAGON', 'SBB-TRAIN-BLOCKED-PASSAGE']; /** General label for "driving direction". */ @property({ attribute: 'direction-label' }) public directionLabel!: string; @@ -41,8 +45,6 @@ export class SbbTrainElement extends SlotChildObserver(LitElement) { /** Controls the direction indicator to show the arrow left or right. Default is left. */ @property({ reflect: true }) public direction: 'left' | 'right' = 'left'; - @state() private _wagons: (SbbTrainBlockedPassageElement | SbbTrainWagonElement)[] = []; - private _language = new LanguageController(this); /** @@ -75,28 +77,15 @@ export class SbbTrainElement extends SlotChildObserver(LitElement) { return `${textParts.join(', ')}.`; } - protected override checkChildren(): void { - const wagons = Array.from(this.children ?? []).filter( - (e): e is SbbTrainBlockedPassageElement | SbbTrainWagonElement => - e.tagName === 'SBB-TRAIN-WAGON' || e.tagName === 'SBB-TRAIN-BLOCKED-PASSAGE', - ); - // If the slotted sbb-train-wagon and sbb-train-blocked-passage instances have not changed, we can skip syncing and updating - // the link reference list. - if ( - this._wagons && - wagons.length === this._wagons.length && - this._wagons.every((e, i) => wagons[i] === e) - ) { - return; + protected override willUpdate(changedProperties: PropertyValueMap>): void { + super.willUpdate(changedProperties); + if (changedProperties.has('listChildren')) { + this._trainSlotChange.emit(); } - - this._trainSlotChange.emit(); - this._wagons = wagons; } protected override render(): TemplateResult { const TITLE_TAG_NAME = `h${this.directionLabelLevel}`; - this._wagons.forEach((wagon, index) => wagon.setAttribute('slot', `wagon-${index}`)); /* eslint-disable lit/binding-positions */ return html` @@ -104,17 +93,10 @@ export class SbbTrainElement extends SlotChildObserver(LitElement) { <${unsafeStatic(TITLE_TAG_NAME)} class="sbb-train__direction-label-sr"> ${this._getDirectionAriaLabel()} -
          - ${this._wagons.map( - (_, index) => - html`
        • - -
        • `, - )} -
        - + ${this.renderList({ + class: 'sbb-train__wagons', + ariaLabel: i18nWagonsLabel[this._language.current], + })} ${ this.directionLabel diff --git a/src/react/vite.config.ts b/src/react/vite.config.ts index 282b31d201..e4c91a2208 100644 --- a/src/react/vite.config.ts +++ b/src/react/vite.config.ts @@ -89,7 +89,7 @@ function generateReactWrappers(): PluginOption { .filter((m) => m.kind === 'javascript-module') .reduce((current, next) => current.concat(next.declarations ?? []), [] as Declaration[]); for (const module of manifest.modules) { - for (const declaration of module.declarations.filter( + for (const declaration of module.declarations?.filter( (d): d is CustomElementDeclaration => 'customElement' in d && d.customElement, ) ?? []) { const targetPath = new URL(`./${module.path}/index.ts`, packageRoot); @@ -115,8 +115,8 @@ function generateReactWrappers(): PluginOption { } } - config.build.lib = { - ...(config.build.lib ? config.build.lib : {}), + config.build!.lib = { + ...(config.build!.lib ? config.build!.lib : {}), entry: globIndexMap(packageRoot), }; }, @@ -189,7 +189,7 @@ function findExtensionUsage( if (usesSsrSlotState(declaration, declarations)) { extensions.set('withSsrDataSlotNames', (v) => `withSsrDataSlotNames(${v})`); } - const childTypes = usesSsrSlotChildCounter(declaration); + const childTypes = namedSlotListElements(declaration); if (childTypes.length) { extensions.set( 'withSsrDataChildCount', @@ -199,32 +199,44 @@ function findExtensionUsage( return extensions; } +// eslint-disable-next-line @typescript-eslint/naming-convention +type ClassDeclarationSSR = ClassDeclaration & { _ssrslotstate?: boolean }; const ssrSlotStateKey = '_ssrslotstate'; -function usesSsrSlotState(declaration: ClassDeclaration, declarations: Declaration[]): boolean { +function usesSsrSlotState( + declaration: ClassDeclarationSSR | undefined, + declarations: Declaration[], +): boolean { while (declaration) { if ( declaration[ssrSlotStateKey] || declaration.mixins?.some((m) => - declarations.find((d) => d.name === m.name && d[ssrSlotStateKey]), + declarations.find((d) => d.name === m.name && (d as ClassDeclarationSSR)[ssrSlotStateKey]), ) ) { return true; } declaration = declarations.find( - (d): d is ClassDeclaration => d.name === declaration.superclass?.name, + (d): d is ClassDeclarationSSR => d.name === declaration!.superclass?.name, ); } return false; } -const ssrSlotChildCountKey = '_ssrchildcounter'; -function usesSsrSlotChildCounter(declaration: ClassDeclaration): string[] { +function namedSlotListElements(declaration: ClassDeclaration): string[] { return ( declaration.members - ?.find((m): m is ClassField => m[ssrSlotChildCountKey]) - ?.type?.text.replace(/[()[\] ]/g, '') - .split('|') ?? [] + ?.find( + (m): m is ClassField => + m.inheritedFrom?.name === 'NamedSlotListElement' && m.name === 'listChildTagNames', + ) + ?.default?.match(/([\w-]+)/g) + ?.map((m) => + m + .split('-') + .map((s) => s[0] + s.substring(1).toLowerCase()) + .join(''), + ) ?? [] ); }