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: 694358062
  • Loading branch information
asyncLiz authored and copybara-github committed Nov 8, 2024
1 parent 855de0b commit 4ad727e
Show file tree
Hide file tree
Showing 2 changed files with 300 additions and 0 deletions.
159 changes: 159 additions & 0 deletions labs/behaviors/custom-state-set.ts
Original file line number Diff line number Diff line change
@@ -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;

/**
* 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.
* @param value true to add the state, or false to delete it.
*/
[toggleState](customState: string, value: 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 {
this[internals].states.add('x');
this[internals].states.delete('x');
this[privateUseDashedIdentifier] = false;
} catch {
this[privateUseDashedIdentifier] = true;
}
}

return this[privateUseDashedIdentifier] ? `--${state}` : state;
}
}

return WithCustomStateSetElement;
}
141 changes: 141 additions & 0 deletions labs/behaviors/custom-state-set_test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
}

0 comments on commit 4ad727e

Please sign in to comment.