Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/components/chat/chat-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,6 @@ export default class IgcChatMessageComponent extends HydrationMixin(
) {
return html`
<igc-icon-button
id=${`${name}-button`}
name=${name}
variant="flat"
aria-label=${label}
Expand Down
51 changes: 51 additions & 0 deletions src/internals/controllers/internals.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
unsafeStatic,
} from '@open-wc/testing';
import { LitElement } from 'lit';
import sinon from 'sinon';
import {
addInternalsController,
type ElementInternalsController,
Expand Down Expand Up @@ -243,5 +244,55 @@ describe('ElementInternals controller', () => {

expect(host.internals.labels).to.be.null;
});

describe('of a form associated host', () => {
let tag: string;
let tagName: ReturnType<typeof unsafeStatic>;

type Fixture = LitElement & { internals: ElementInternalsController };

before(() => {
tag = defineCE(
class extends LitElement {
public static formAssociated = true;
public internals = addInternalsController(this);
}
);
tagName = unsafeStatic(tag);
});

afterEach(() => sinon.restore());

it('does not create the live list when no label can name the host', async () => {
const root = await fixture<HTMLElement>(
html`<div><label>Other</label><${tagName}></${tagName}></div>`
);
const host = root.querySelector<Fixture>(tag)!;
const read = sinon.spy(ElementInternals.prototype, 'labels', ['get']);

expect(host.internals.labels).to.be.null;
expect(read.get.called).to.be.false;
});

it('returns the label that names the host id', async () => {
const root = await fixture<HTMLElement>(
html`<div>
<label for="named">Name</label><${tagName} id="named"></${tagName}>
</div>`
);
const host = root.querySelector<Fixture>('#named')!;

expect(host.internals.labels).to.eql([root.querySelector('label')]);
});

it('returns an ancestor label', async () => {
const label = await fixture<HTMLLabelElement>(
html`<label>Name <${tagName}></${tagName}></label>`
);
const host = label.querySelector<Fixture>(tag)!;

expect(host.internals.labels).to.eql([label]);
});
});
});
});
23 changes: 16 additions & 7 deletions src/internals/controllers/internals.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type {
LitElement,
ReactiveController,
ReactiveControllerHost,
import {
isServer,
type LitElement,
type ReactiveController,
type ReactiveControllerHost,
} from 'lit';
import { sameItems } from '../utils/arrays.js';
import { setOrRemoveAttribute } from '../utils/dom.js';
Expand Down Expand Up @@ -106,9 +107,17 @@ class ElementInternalsController implements ReactiveController {
return this._labels;
}

const host = this._host.constructor as { formAssociated?: boolean };
// The getter throws for an element that is not form associated.
const list = host.formAssociated ? this._internals.labels : null;
const host = this._host;
// The getter throws unless the host is form associated. Without an id or an
// ancestor `<label>`, no label can name the host, so skip the read: Chromium
// updates each live list on every DOM change until GC. The SSR DOM shim has
// no `closest()`.
const list =
!isServer &&
(host.constructor as { formAssociated?: boolean }).formAssociated &&
(host.id || host.closest('label'))
? this._internals.labels
: null;
const labels = list?.length
? Array.from(list as NodeListOf<Element>)
: null;
Expand Down
44 changes: 23 additions & 21 deletions src/theming/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { isServer } from 'lit';
import {
CHANGE_THEME_EVENT,
CHANGED_THEME_EVENT,
type ChangeThemeEventDetail,
} from './theming-event.js';
import {
Expand Down Expand Up @@ -30,37 +29,40 @@ function setTheme(value: Theme, variant: ThemeVariant): void {
themeVariant = variant;
}

/**
* The theming controllers that follow the global theme. Not an `EventTarget`:
* Chromium scans all listeners on each add and remove.
*/
export const _globalThemeListeners = new Set<{ globalThemeChanged(): void }>();

/**
* Relays "igc-change-theme" events from `window` to the theming controllers.
* It also syncs the module state from the event detail, so an event from another
* copy of the library changes the theme here too.
*/
class ThemeChangedEmitter extends EventTarget {
constructor() {
super();
if (!isServer) {
globalThis.addEventListener(CHANGE_THEME_EVENT, this);
function relayThemeChange({
detail,
}: CustomEvent<ChangeThemeEventDetail>): void {
if (
isOfTypeTheme(detail?.theme) &&
isOfTypeThemeVariant(detail?.themeVariant)
) {
// A repeated call or another copy of the library may send the active theme again.
if (detail.theme === theme && detail.themeVariant === themeVariant) {
return;
}
}

/** @internal */
public handleEvent({ detail }: CustomEvent<ChangeThemeEventDetail>): void {
if (
isOfTypeTheme(detail?.theme) &&
isOfTypeThemeVariant(detail?.themeVariant)
) {
// A repeated call or another copy of the library may send the active theme again.
if (detail.theme === theme && detail.themeVariant === themeVariant) {
return;
}
setTheme(detail.theme, detail.themeVariant);

setTheme(detail.theme, detail.themeVariant);
this.dispatchEvent(new CustomEvent(CHANGED_THEME_EVENT));
for (const listener of _globalThemeListeners) {
listener.globalThemeChanged();
}
}
}

export const _themeChangedEmitter = new ThemeChangedEmitter();
if (!isServer) {
globalThis.addEventListener(CHANGE_THEME_EVENT, relayThemeChange);
}

export function getTheme(): ChangeThemeEventDetail {
if (!(theme && themeVariant)) {
Expand Down Expand Up @@ -92,7 +94,7 @@ export function getTheme(): ChangeThemeEventDetail {
export function configureTheme(t: Theme, v: ThemeVariant = 'light'): void {
if (isOfTypeTheme(t) && isOfTypeThemeVariant(v)) {
if (isServer) {
// On the client, the emitter's window listener sets the state.
// On the client, the window listener sets the state.
setTheme(t, v);
} else {
globalThis.dispatchEvent(
Expand Down
15 changes: 7 additions & 8 deletions src/theming/theming-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import {
} from 'lit';

import { getOrInsertComputed } from '#internals/utils/objects.js';
import { _themeChangedEmitter, getTheme } from './config.js';
import { _globalThemeListeners, getTheme } from './config.js';
import { type ThemeContext, themeContext } from './context.js';
import { CHANGED_THEME_EVENT } from './theming-event.js';
import type {
ComponentThemes,
Theme,
Expand Down Expand Up @@ -67,7 +66,7 @@ function resolveSheets(
* 2. **Global** - the application-wide theme set via `configureTheme()`.
*
* When a context provider is present, the controller subscribes to its updates
* and stops listening to global theme change events. When the host element is
* and stops following the global theme. When the host element is
* disconnected and reconnected outside a provider, it automatically falls back
* to the global theme.
*
Expand Down Expand Up @@ -139,25 +138,25 @@ class ThemingController implements ReactiveController {
/** @internal */
public hostConnected(): void {
if (this._themeSource === 'uninitialized') {
_themeChangedEmitter.addEventListener(CHANGED_THEME_EVENT, this);
_globalThemeListeners.add(this);
this._applyGlobalTheme();
}
}

/** @internal */
public hostDisconnected(): void {
_themeChangedEmitter.removeEventListener(CHANGED_THEME_EVENT, this);
_globalThemeListeners.delete(this);

// Re-resolve the theme source on reconnect, which may be in another part of the tree.
this._themeSource = 'uninitialized';
}

//#endregion

//#region Event handling
//#region Global theme

/** @internal */
public handleEvent(): void {
public globalThemeChanged(): void {
if (this._themeSource === 'global') {
this._applyGlobalTheme();
}
Expand All @@ -168,7 +167,7 @@ class ThemingController implements ReactiveController {
//#region Internal methods

private _applyContextTheme({ theme, variant }: ThemeContext): void {
_themeChangedEmitter.removeEventListener(CHANGED_THEME_EVENT, this);
_globalThemeListeners.delete(this);
this._themeSource = 'context';
this._applyTheme(theme, variant);
}
Expand Down
1 change: 0 additions & 1 deletion src/theming/theming-event.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Theme, ThemeVariant } from './types.js';

export const CHANGE_THEME_EVENT = 'igc-change-theme';
export const CHANGED_THEME_EVENT = 'igc-changed-theme';

declare global {
interface WindowEventMap {
Expand Down
Loading