From 676fff98aa6dda3c9a074c397f1841572e92fe66 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 27 Jun 2025 18:32:52 +0300 Subject: [PATCH 01/19] feat: SlotController implementation and tests --- .../common/controllers/slot.spec.ts | 233 ++++++++++++++++++ src/components/common/controllers/slot.ts | 196 +++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 src/components/common/controllers/slot.spec.ts create mode 100644 src/components/common/controllers/slot.ts diff --git a/src/components/common/controllers/slot.spec.ts b/src/components/common/controllers/slot.spec.ts new file mode 100644 index 000000000..263258f24 --- /dev/null +++ b/src/components/common/controllers/slot.spec.ts @@ -0,0 +1,233 @@ +import { + defineCE, + elementUpdated, + expect, + fixture, + html, + unsafeStatic, +} from '@open-wc/testing'; +import { LitElement } from 'lit'; +import { first, last } from '../util.js'; +import { + addSlotController, + type SlotChangeCallbackParameters, + type SlotController, + setSlots, +} from './slot.js'; + +type TestSlotController = SlotController<'[default]' | 'start' | 'end'>; + +describe('Slots controller', () => { + let tag: string; + let instance: LitElement & { + slotController: TestSlotController; + slotsCallbackResults: SlotChangeCallbackParameters< + '[default]' | 'start' | 'end' + >[]; + }; + let slots: TestSlotController; + + before(() => { + tag = defineCE( + class extends LitElement { + public readonly slotsCallbackResults: SlotChangeCallbackParameters< + '[default]' | 'start' | 'end' + >[] = []; + + public readonly slotController = addSlotController(this, { + slots: setSlots('start', 'end'), + onChange: (params) => this.slotsCallbackResults.push(params), + initial: true, + }); + + protected override render() { + return html` + + Default node content + + Default element content + + `; + } + } + ); + }); + + describe('Assigned nodes API', () => { + beforeEach(async () => { + const tagName = unsafeStatic(tag); + instance = await fixture(html`<${tagName}> { + expect(slots.hasAssignedNodes('[default]')).to.be.false; + }); + + it('should return true for default assigned nodes with `flatten` = true', () => { + expect(slots.hasAssignedNodes('[default]', true)).to.be.true; + }); + + it('should return false for slots lacking default nodes with `flatten` = false', () => { + expect(slots.hasAssignedNodes('start')).to.be.false; + }); + + it('should return false for slots lacking default nodes with `flatten` = true', () => { + expect(slots.hasAssignedNodes('start', true)).to.be.false; + }); + + it('should return an empty array for default assigned nodes with `flatten` = false', () => { + expect(slots.getAssignedNodes('[default]')).to.be.empty; + }); + + it('should return an array of one text node for default assigned nodes with `flatten` = true', () => { + expect(slots.getAssignedNodes('[default]', true)).lengthOf(1); + }); + + it('should return an empty array for slots lacking default nodes with `flatten` = false', () => { + expect(slots.getAssignedNodes('start')).to.be.empty; + }); + + it('should return an empty array for slots lacking default nodes with `flatten` = true', () => { + expect(slots.getAssignedNodes('start')).to.be.empty; + }); + + it('correctly reflects nodes projected in the default slot', () => { + const text = document.createTextNode('Slotted content'); + instance.append(text); + + expect(first(slots.getAssignedNodes('[default]')).textContent).to.equal( + 'Slotted content' + ); + }); + }); + + describe('Assigned elements API', () => { + beforeEach(async () => { + const tagName = unsafeStatic(tag); + instance = await fixture(html`<${tagName}> { + expect(slots.hasAssignedElements('[default]')).to.be.false; + }); + + it('should return false for slots lacking default elements with `flatten` = true', () => { + expect(slots.hasAssignedElements('[default]', { flatten: true })).to.be + .false; + }); + + it('should return false for slots lacking default elements with `flatten` = false', () => { + expect(slots.hasAssignedElements('end')).to.be.false; + }); + + it('should return true for slots with default elements with `flatten` = true', () => { + expect(slots.hasAssignedElements('end', { flatten: true })).to.be.true; + }); + + it('should return an empty array for default assigned elements with `flatten` = false', () => { + expect(slots.getAssignedElements('end')).to.be.empty; + }); + + it('should return an array of one element for default assigned elements with `flatten` = true', () => { + expect(slots.getAssignedElements('end', { flatten: true })).lengthOf(1); + }); + + it('should correctly reflects element projected in a slot', () => { + const elements = Array.from({ length: 5 }, () => + document.createElement('span') + ); + instance.append(...elements); + + expect(slots.getAssignedElements('[default]')).lengthOf(5); + }); + + it('should correctly filter assigned elements based on a CSS selector', () => { + const elements = Array.from({ length: 6 }, (_, i) => { + const element = document.createElement('span'); + if (i % 2) { + element.hidden = true; + } + + return element; + }); + instance.append(...elements); + + expect( + slots.getAssignedElements('[default]', { selector: ':not([hidden])' }) + ).lengthOf(3); + }); + }); + + describe('Slot onChange callback', () => { + beforeEach(async () => { + beforeEach(async () => { + const tagName = unsafeStatic(tag); + instance = await fixture(html`<${tagName}> { + const state = first(instance.slotsCallbackResults); + expect(state.isInitial).to.be.true; + expect(state.isDefault).to.be.false; + expect(state.slot).to.equal(''); + }); + + it('should not invoke the onChange callback with initial state after the first update', async () => { + instance.requestUpdate(); + await elementUpdated(instance); + + expect(instance.slotsCallbackResults).lengthOf(1); + }); + + it('should return the correct callback parameters for default slot changes', async () => { + instance.append(document.createElement('span')); + await elementUpdated(instance); + + const state = last(instance.slotsCallbackResults); + expect(state.isDefault).to.be.true; + expect(state.isInitial).to.be.false; + expect(state.slot).to.be.empty; + }); + + it('should return the correct callback parameters for named slots', async () => { + const element = document.createElement('span'); + element.slot = 'start'; + instance.append(element); + + await elementUpdated(instance); + + const state = last(instance.slotsCallbackResults); + expect(state.isDefault).to.be.false; + expect(state.isInitial).to.be.false; + expect(state.slot).to.equal('start'); + }); + + it('should correctly handle multiple slot change events', async () => { + const startElement = document.createElement('span'); + startElement.slot = 'start'; + + instance.append( + document.createElement('span'), + startElement, + document.createElement('div') + ); + await elementUpdated(instance); + + expect(slots.getAssignedElements('start')).lengthOf(1); + expect(slots.getAssignedElements('[default]')).lengthOf(2); + expect(instance.slotsCallbackResults).lengthOf(3); + + const [_, defaultSlot, startSlot] = instance.slotsCallbackResults; + + expect(defaultSlot.isDefault).to.be.true; + expect(defaultSlot.slot).to.be.empty; + + expect(startSlot.isDefault).to.be.false; + expect(startSlot.slot).to.equal('start'); + }); + }); +}); diff --git a/src/components/common/controllers/slot.ts b/src/components/common/controllers/slot.ts new file mode 100644 index 000000000..0f20cde05 --- /dev/null +++ b/src/components/common/controllers/slot.ts @@ -0,0 +1,196 @@ +import type { + LitElement, + ReactiveController, + ReactiveControllerHost, +} from 'lit'; +import { isEmpty } from '../util.js'; + +type InferSlotNames = T extends readonly (infer U)[] ? U : never; + +/** + * Additional query options for the slot controller methods. + */ +type SlotQueryOptions = { + /** + * If set to `true`, it returns a sequence of both the elements assigned to the queried slot, + * as well as elements assigned to any other slots that are descendants of this slot. If no + * assigned elements are found, it returns the slot's fallback content. + * + * Defaults to `false`. + */ + flatten?: boolean; + /** + * CSS selector used to filter the elements returned. + */ + selector?: string; +}; + +type SlotChangeCallback = ( + parameters: SlotChangeCallbackParameters +) => void; + +type SlotChangeCallbackParameters = { + /** The slot name that has its assigned nodes changed. */ + slot: T; + /** `true` if the slot is the default slot. */ + isDefault: boolean; + /** `true` if the callback handler is called for the initial host update. */ + isInitial: boolean; +}; + +type SlotControllerOptions = { + /** An iterable collection of slot names to observe. */ + slots?: Iterable; + /** Callback function which is invoked a slot's assigned nodes change. */ + onChange?: SlotChangeCallback; + /** If set to `true`, the `onChange` callback is invoked once after the host is updated for the first time. */ + initial?: boolean; +}; + +const DefaultSlot = '[default]'; + +class SlotController implements ReactiveController { + private readonly _host: ReactiveControllerHost & LitElement; + private readonly _options: SlotControllerOptions; + private readonly _slots?: Set; + private _initialized = false; + + constructor( + host: ReactiveControllerHost & LitElement, + options?: SlotControllerOptions + ) { + this._host = host; + this._host.addController(this); + + this._options = { ...options }; + this._slots = options?.slots ? new Set(options.slots) : undefined; + } + + private _getSlot(slotName?: T): HTMLSlotElement | null { + if (slotName === DefaultSlot) { + return this._host.renderRoot.querySelector( + 'slot:not([name])' + ); + } + + return this._host.renderRoot.querySelector( + `slot[name=${slotName}]` + ); + } + + /** + * Returns an array of the assigned nodes for `slot`. + * + * If `flatten` is set to `true`, it returns a sequence of both the nodes assigned to the queried slot, + * as well as nodes assigned to any other slots that are descendants of this slot. If no + * assigned nodes are found, it returns the slot's fallback content. + */ + public getAssignedNodes(slot: T, flatten = false): Node[] { + return this._getSlot(slot)?.assignedNodes({ flatten }) ?? []; + } + + /** + * Returns an array of the assigned elements for `slot` with additional `options`. + * + * See {@link SlotQueryOptions.flatten} and {@link SlotQueryOptions.selector} for more information. + */ + public getAssignedElements( + slot: T, + options?: SlotQueryOptions + ): U[] { + const elements = + (this._getSlot(slot)?.assignedElements({ + flatten: options?.flatten, + }) as U[]) ?? []; + + return options?.selector + ? elements.filter((e) => e.matches(options.selector!)) + : elements; + } + + /** + * Return whether `slot` has assigned nodes. + * + * If `flatten` is set to `true`, it returns a sequence of both the nodes assigned to the queried slot, + * as well as nodes assigned to any other slots that are descendants of this slot. If no + * assigned nodes are found, it returns the slot's fallback content. + */ + public hasAssignedNodes(slot: T, flatten = false): boolean { + return !isEmpty(this.getAssignedNodes(slot, flatten)); + } + + /** + * Return whether `slot` has assigned elements accepting additional `options`. + * + * See {@link SlotQueryOptions.flatten} and {@link SlotQueryOptions.selector} for more information. + */ + public hasAssignedElements(slot: T, options?: SlotQueryOptions): boolean { + return !isEmpty(this.getAssignedElements(slot, options)); + } + + /** @internal */ + public handleEvent(event: Event): void { + const slot = event.target as HTMLSlotElement; + const name = slot.name as T; + const isDefault = name === ''; + + if ( + !this._slots || + this._slots.has(isDefault ? (DefaultSlot as T) : (slot.name as T)) + ) { + this._options.onChange?.call(this._host, { + slot: name, + isDefault, + isInitial: false, + }); + this._host.requestUpdate(); + } + } + + /** @internal */ + public hostConnected(): void { + this._host.renderRoot.addEventListener('slotchange', this); + } + + /** @internal */ + public hostDisconnected(): void { + this._host.renderRoot.removeEventListener('slotchange', this); + } + + /** @internal */ + public hostUpdated(): void { + if (!this._initialized && this._options.initial) { + this._initialized = true; + this._options.onChange?.call(this._host, { + slot: '' as T, + isDefault: false, + isInitial: true, + }); + } + } +} + +function addSlotController( + host: ReactiveControllerHost, + options?: SlotControllerOptions> & { + slots?: K; + } +): SlotController> { + return new SlotController(host as ReactiveControllerHost & LitElement, { + ...options, + slots: options?.slots as Iterable>, + }); +} + +function setSlots(...slots: T) { + return [DefaultSlot, ...slots] as const; +} + +export { addSlotController, DefaultSlot, setSlots }; +export type { + SlotController, + SlotQueryOptions, + SlotChangeCallback, + SlotChangeCallbackParameters, + SlotControllerOptions, +}; From 020ce669eed3d026c656dffdcd6c4e098953002b Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 27 Jun 2025 18:33:13 +0300 Subject: [PATCH 02/19] refactor(chip): Use SlotController and ARIA fixes --- src/components/chip/chip.ts | 144 ++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 70 deletions(-) diff --git a/src/components/chip/chip.ts b/src/components/chip/chip.ts index 5f4ba4113..a393714eb 100644 --- a/src/components/chip/chip.ts +++ b/src/components/chip/chip.ts @@ -1,13 +1,12 @@ import { html, LitElement, nothing } from 'lit'; -import { property, queryAssignedElements } from 'lit/decorators.js'; -import { createRef, type Ref, ref } from 'lit/directives/ref.js'; - +import { property } from 'lit/decorators.js'; +import { createRef, ref } from 'lit/directives/ref.js'; import { addThemingController } from '../../theming/theming-controller.js'; import { addKeybindings } from '../common/controllers/key-bindings.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { isEmpty } from '../common/util.js'; import IgcIconComponent from '../icon/icon.js'; import type { StyleVariant } from '../types.js'; import { styles } from './themes/chip.base.css.js'; @@ -42,11 +41,15 @@ export default class IgcChipComponent extends EventEmitterMixin< public static styles = [styles, shared]; /* blazorSuppress */ - public static register() { + public static register(): void { registerComponent(IgcChipComponent, IgcIconComponent); } - private _removePartRef: Ref = createRef(); + private readonly _removePartRef = createRef(); + + private readonly _slots = addSlotController(this, { + slots: setSlots('prefix', 'suffix', 'start', 'end', 'select', 'remove'), + }); /** * Sets the disabled state for the chip. @@ -84,18 +87,6 @@ export default class IgcChipComponent extends EventEmitterMixin< @property({ reflect: true }) public variant!: StyleVariant; - @queryAssignedElements({ slot: 'prefix' }) - protected prefixes!: Array; - - @queryAssignedElements({ slot: 'start' }) - protected contentStart!: Array; - - @queryAssignedElements({ slot: 'suffix' }) - protected suffixes!: Array; - - @queryAssignedElements({ slot: 'end' }) - protected contentEnd!: Array; - constructor() { super(); @@ -104,75 +95,88 @@ export default class IgcChipComponent extends EventEmitterMixin< addKeybindings(this, { ref: this._removePartRef, bindingDefaults: { triggers: ['keyup'] }, - }).setActivateHandler(this.handleRemove); + }).setActivateHandler(this._handleRemove); } - protected override createRenderRoot() { - const root = super.createRenderRoot(); - root.addEventListener('slotchange', () => this.requestUpdate()); - return root; - } - - protected handleSelect() { + protected _handleSelect(): void { if (this.selectable) { this.selected = !this.selected; this.emitEvent('igcSelect', { detail: this.selected }); } } - protected handleRemove(e: Event) { + protected _handleRemove(event: Event): void { + event.stopPropagation(); this.emitEvent('igcRemove'); - e.stopPropagation(); + } + + protected _renderPrefix() { + const isVisible = + this._slots.hasAssignedElements('prefix') || + this._slots.hasAssignedElements('start'); + + const selectSlot = + this.selectable && this.selected + ? html` + + + + ` + : nothing; + + return html` + + ${selectSlot} + + + + `; + } + + protected _renderSuffix() { + const isVisible = + this._slots.hasAssignedElements('suffix') || + this._slots.hasAssignedElements('end'); + + const removeSlot = + this.removable && !this.disabled + ? html` + + + + ` + : nothing; + + return html` + + + + ${removeSlot} + + `; } protected override render() { return html` `; } From caedbd47c5c0da772939cb52db48fa157d85af77 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 27 Jun 2025 18:56:00 +0300 Subject: [PATCH 03/19] docs(chip): Added missing slots JSDoc documentation --- src/components/chip/chip.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/chip/chip.ts b/src/components/chip/chip.ts index a393714eb..6cff4fbb8 100644 --- a/src/components/chip/chip.ts +++ b/src/components/chip/chip.ts @@ -17,14 +17,17 @@ export interface IgcChipComponentEventMap { igcRemove: CustomEvent; igcSelect: CustomEvent; } + /** * Chips help people enter information, make selections, filter content, or trigger actions. * * @element igc-chip * - * @slot - Renders the chip data. - * @slot prefix - Renders content before the data of the chip. - * @slot suffix - Renders content after the data of the chip. + * @slot - Renders content in the default slot of the chip. + * @slot prefix - Renders content at the start of the chip, before the default content. + * @slot suffix - Renders content at the end of the chip after the default content. + * @slot select - Content to render when the chip in selected state. + * @slot remove - Content to override the default remove chip icon. * * @fires igcRemove - Emits an event when the chip component is removed. Returns the removed chip component. * @fires igcSelect - Emits event when the chip component is selected/deselected and any related animations and transitions also end. @@ -167,10 +170,12 @@ export default class IgcChipComponent extends EventEmitterMixin< } protected override render() { + const ariaPressed = this.selectable ? this.selected.toString() : null; + return html`