From 2f688d4ab7eba556283b6ab2ee5d0f6c58bf5498 Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Thu, 7 Nov 2024 21:49:06 -0800 Subject: [PATCH] fix(labs): add mixinCustomStateSet() for :state() compatibility PiperOrigin-RevId: 694358062 --- labs/behaviors/custom-state-set.ts | 159 ++++++++++++++++++++++++ labs/behaviors/custom-state-set_test.ts | 141 +++++++++++++++++++++ 2 files changed, 300 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..ee6cf88234 --- /dev/null +++ b/labs/behaviors/custom-state-set.ts @@ -0,0 +1,159 @@ +/** + * @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 { + this[internals].states.add('x'); + this[internals].states.delete('x'); + 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..91afe343c9 --- /dev/null +++ b/labs/behaviors/custom-state-set_test.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {forceElementInternalsPolyfill} from 'element-internals-polyfill'; +import {LitElement} from 'lit'; + +import { + WithCustomStateSet, + hasState, + mixinCustomStateSet, + toggleState, +} from './custom-state-set.js'; +import {mixinElementInternals} from './element-internals.js'; + +for (const testWithPolyfill of [false, true]) { + const describeSuffix = testWithPolyfill + ? ' with element-internals-polyfill' + : ''; + + describe(`mixinCustomStateSet()${describeSuffix}`, () => { + const nativeAttachInternals = HTMLElement.prototype.attachInternals; + + let elementCtor: new () => HTMLElement & WithCustomStateSet; + + beforeAll(() => { + if (testWithPolyfill) { + forceElementInternalsPolyfill(); + + class PolyfillTestCustomStateSet extends mixinCustomStateSet( + mixinElementInternals(LitElement), + ) {} + + elementCtor = PolyfillTestCustomStateSet; + customElements.define( + 'test-custom-state-set-polyfill', + PolyfillTestCustomStateSet, + ); + } else { + class TestCustomStateSet extends mixinCustomStateSet( + mixinElementInternals(LitElement), + ) {} + + elementCtor = TestCustomStateSet; + customElements.define('test-custom-state-set', TestCustomStateSet); + } + }); + + afterAll(() => { + if (testWithPolyfill) { + HTMLElement.prototype.attachInternals = nativeAttachInternals; + } + }); + + describe('[hasState]()', () => { + it('returns false when the state is not active', () => { + // Arrange + const element = new elementCtor(); + + // Assert + expect(element[hasState]('foo')) + .withContext("[hasState]('foo')") + .toBeFalse(); + }); + + it('returns true when the state is active', () => { + // Arrange + const element = new elementCtor(); + + // 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 elementCtor(); + 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 elementCtor(); + + // 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 elementCtor(); + 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 elementCtor(); + + // Assert + expect(element.matches(fooStateSelector)) + .withContext(`element.matches('${fooStateSelector}')`) + .toBeFalse(); + }); + }); + }); +}