From 045fe94872607f67da6c18d3689bff6a541102d2 Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Mon, 2 Dec 2024 10:44:03 -0800 Subject: [PATCH] fix(labs): add mixinCustomStateSet() for :state() compatibility PiperOrigin-RevId: 702017230 --- labs/behaviors/custom-state-set.ts | 160 +++++++++++++++++++++++ labs/behaviors/custom-state-set_test.ts | 166 ++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 labs/behaviors/custom-state-set.ts create mode 100644 labs/behaviors/custom-state-set_test.ts diff --git a/labs/behaviors/custom-state-set.ts b/labs/behaviors/custom-state-set.ts new file mode 100644 index 0000000000..454eaf6e25 --- /dev/null +++ b/labs/behaviors/custom-state-set.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {LitElement} from 'lit'; + +import {internals, WithElementInternals} from './element-internals.js'; +import {MixinBase, MixinReturn} from './mixin.js'; + +/** + * A unique symbol used to check if an element's `CustomStateSet` has a state. + * + * Provides compatibility with legacy dashed identifier syntax (`:--state`) used + * by the element-internals-polyfill for Chrome extension support. + * + * @example + * ```ts + * const baseClass = mixinCustomStateSet(mixinElementInternals(LitElement)); + * + * class MyElement extends baseClass { + * get checked() { + * return this[hasState]('checked'); + * } + * set checked(value: boolean) { + * this[toggleState]('checked', value); + * } + * } + * ``` + */ +export const hasState = Symbol('hasState'); + +/** + * A unique symbol used to add or delete a state from an element's + * `CustomStateSet`. + * + * Provides compatibility with legacy dashed identifier syntax (`:--state`) used + * by the element-internals-polyfill for Chrome extension support. + * + * @example + * ```ts + * const baseClass = mixinCustomStateSet(mixinElementInternals(LitElement)); + * + * class MyElement extends baseClass { + * get checked() { + * return this[hasState]('checked'); + * } + * set checked(value: boolean) { + * this[toggleState]('checked', value); + * } + * } + * ``` + */ +export const toggleState = Symbol('toggleState'); + +/** + * An instance with `[hasState]()` and `[toggleState]()` symbol functions that + * provide compatibility with `CustomStateSet` legacy dashed identifier syntax, + * used by the element-internals-polyfill and needed for Chrome extension + * compatibility. + */ +export interface WithCustomStateSet { + /** + * Checks if the state is active, returning true if the element matches + * `:state(customstate)`. + * + * @param customState the `CustomStateSet` state to check. Do not use the + * `--customstate` dashed identifier syntax. + * @return true if the custom state is active, or false if not. + */ + [hasState](customState: string): boolean; + + /** + * Toggles the state to be active or inactive based on the provided value. + * When active, the element matches `:state(customstate)`. + * + * @param customState the `CustomStateSet` state to check. Do not use the + * `--customstate` dashed identifier syntax. + * @param isActive true to add the state, or false to delete it. + */ + [toggleState](customState: string, isActive: boolean): void; +} + +// Private symbols +const privateUseDashedIdentifier = Symbol('privateUseDashedIdentifier'); +const privateGetStateIdentifier = Symbol('privateGetStateIdentifier'); + +/** + * Mixes in compatibility functions for access to an element's `CustomStateSet`. + * + * Use this mixin's `[hasState]()` and `[toggleState]()` symbol functions for + * compatibility with `CustomStateSet` legacy dashed identifier syntax. + * + * https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet#compatibility_with_dashed-ident_syntax. + * + * The dashed identifier syntax is needed for element-internals-polyfill, a + * requirement for Chome extension compatibility. + * + * @example + * ```ts + * const baseClass = mixinCustomStateSet(mixinElementInternals(LitElement)); + * + * class MyElement extends baseClass { + * get checked() { + * return this[hasState]('checked'); + * } + * set checked(value: boolean) { + * this[toggleState]('checked', value); + * } + * } + * ``` + * + * @param base The class to mix functionality into. + * @return The provided class with `[hasState]()` and `[toggleState]()` + * functions mixed in. + */ +export function mixinCustomStateSet< + T extends MixinBase, +>(base: T): MixinReturn { + abstract class WithCustomStateSetElement + extends base + implements WithCustomStateSet + { + [hasState](state: string) { + state = this[privateGetStateIdentifier](state); + return this[internals].states.has(state); + } + + [toggleState](state: string, isActive: boolean) { + state = this[privateGetStateIdentifier](state); + if (isActive) { + this[internals].states.add(state); + } else { + this[internals].states.delete(state); + } + } + + [privateUseDashedIdentifier]: boolean | null = null; + + [privateGetStateIdentifier](state: string) { + if (this[privateUseDashedIdentifier] === null) { + // Check if `--state-string` needs to be used. See + // https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet#compatibility_with_dashed-ident_syntax + try { + const testState = '_test'; + this[internals].states.add(testState); + this[internals].states.delete(testState); + this[privateUseDashedIdentifier] = false; + } catch { + this[privateUseDashedIdentifier] = true; + } + } + + return this[privateUseDashedIdentifier] ? `--${state}` : state; + } + } + + return WithCustomStateSetElement; +} diff --git a/labs/behaviors/custom-state-set_test.ts b/labs/behaviors/custom-state-set_test.ts new file mode 100644 index 0000000000..675e762a53 --- /dev/null +++ b/labs/behaviors/custom-state-set_test.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {LitElement} from 'lit'; +import {customElement} from 'lit/decorators.js'; + +import {hasState, mixinCustomStateSet, toggleState} from './custom-state-set.js'; +import {mixinElementInternals} from './element-internals.js'; + +@customElement('test-custom-state-set') +class TestCustomStateSet extends mixinCustomStateSet( + mixinElementInternals(LitElement), +) {} + +for (const testWithPolyfill of [false, true]) { + const describeSuffix = testWithPolyfill + ? ' (with element-internals-polyfill)' + : ''; + + describe(`mixinCustomStateSet()${describeSuffix}`, () => { + const nativeAttachInternals = HTMLElement.prototype.attachInternals; + + beforeAll(() => { + if (testWithPolyfill) { + // A more reliable test would use `forceElementInternalsPolyfill()` from + // `element-internals-polyfill`, but our GitHub test build doesn't + // support it since the polyfill changes global types. + + /* A simplified version of element-internal-polyfill CustomStateSet. */ + class PolyfilledCustomStateSet extends Set { + constructor(private readonly ref: HTMLElement) { + super(); + } + + override add(state: string) { + if (!/^--/.test(state) || typeof state !== 'string') { + throw new DOMException( + `Failed to execute 'add' on 'CustomStateSet': The specified value ${state} must start with '--'.`, + ); + } + const result = super.add(state); + this.ref.toggleAttribute(`state${state}`, true); + return result; + } + + override clear() { + for (const [entry] of this.entries()) { + this.delete(entry); + } + super.clear(); + } + + override delete(state: string) { + const result = super.delete(state); + this.ref.toggleAttribute(`state${state}`, false); + return result; + } + } + + HTMLElement.prototype.attachInternals = function (this: HTMLElement) { + const internals = nativeAttachInternals.call(this); + Object.defineProperty(internals, 'states', { + enumerable: true, + configurable: true, + value: new PolyfilledCustomStateSet(this), + }); + + return internals; + }; + } + }); + + afterAll(() => { + if (testWithPolyfill) { + HTMLElement.prototype.attachInternals = nativeAttachInternals; + } + }); + + describe('[hasState]()', () => { + it('returns false when the state is not active', () => { + // Arrange + const element = new TestCustomStateSet(); + + // Assert + expect(element[hasState]('foo')) + .withContext("[hasState]('foo')") + .toBeFalse(); + }); + + it('returns true when the state is active', () => { + // Arrange + const element = new TestCustomStateSet(); + + // Act + element[toggleState]('foo', true); + + // Assert + expect(element[hasState]('foo')) + .withContext("[hasState]('foo')") + .toBeTrue(); + }); + + it('returns false when the state is deactivated', () => { + // Arrange + const element = new TestCustomStateSet(); + element[toggleState]('foo', true); + + // Act + element[toggleState]('foo', false); + + // Assert + expect(element[hasState]('foo')) + .withContext("[hasState]('foo')") + .toBeFalse(); + }); + }); + + describe('[toggleState]()', () => { + const fooStateSelector = testWithPolyfill + ? `[state--foo]` + : ':state(foo)'; + + it(`matches '${fooStateSelector}' when the state is active`, () => { + // Arrange + const element = new TestCustomStateSet(); + + // Act + element[toggleState]('foo', true); + + // Assert + expect(element.matches(fooStateSelector)) + .withContext(`element.matches('${fooStateSelector}')`) + .toBeTrue(); + }); + + it(`does not match '${fooStateSelector}' when the state is deactivated`, () => { + // Arrange + const element = new TestCustomStateSet(); + element[toggleState]('foo', true); + + // Act + element[toggleState]('foo', false); + + // Assert + expect(element.matches(fooStateSelector)) + .withContext(`element.matches('${fooStateSelector}')`) + .toBeFalse(); + }); + + it(`does not match '${fooStateSelector}' by default`, () => { + // Arrange + const element = new TestCustomStateSet(); + + // Assert + expect(element.matches(fooStateSelector)) + .withContext(`element.matches('${fooStateSelector}')`) + .toBeFalse(); + }); + }); + }); +}