Skip to content

Commit

Permalink
fix(labs): add mixinCustomStateSet() for :state() compatibility
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 702017230
  • Loading branch information
asyncLiz authored and copybara-github committed Dec 2, 2024
1 parent e217185 commit 045fe94
Show file tree
Hide file tree
Showing 2 changed files with 326 additions and 0 deletions.
160 changes: 160 additions & 0 deletions labs/behaviors/custom-state-set.ts
Original file line number Diff line number Diff line change
@@ -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<LitElement & WithElementInternals>,
>(base: T): MixinReturn<T, WithCustomStateSet> {
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;
}
166 changes: 166 additions & 0 deletions labs/behaviors/custom-state-set_test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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();
});
});
});
}

0 comments on commit 045fe94

Please sign in to comment.