Skip to content

Commit 045fe94

Browse files
asynclizcopybara-github
authored andcommitted
fix(labs): add mixinCustomStateSet() for :state() compatibility
PiperOrigin-RevId: 702017230
1 parent e217185 commit 045fe94

File tree

2 files changed

+326
-0
lines changed

2 files changed

+326
-0
lines changed

labs/behaviors/custom-state-set.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {LitElement} from 'lit';
8+
9+
import {internals, WithElementInternals} from './element-internals.js';
10+
import {MixinBase, MixinReturn} from './mixin.js';
11+
12+
/**
13+
* A unique symbol used to check if an element's `CustomStateSet` has a state.
14+
*
15+
* Provides compatibility with legacy dashed identifier syntax (`:--state`) used
16+
* by the element-internals-polyfill for Chrome extension support.
17+
*
18+
* @example
19+
* ```ts
20+
* const baseClass = mixinCustomStateSet(mixinElementInternals(LitElement));
21+
*
22+
* class MyElement extends baseClass {
23+
* get checked() {
24+
* return this[hasState]('checked');
25+
* }
26+
* set checked(value: boolean) {
27+
* this[toggleState]('checked', value);
28+
* }
29+
* }
30+
* ```
31+
*/
32+
export const hasState = Symbol('hasState');
33+
34+
/**
35+
* A unique symbol used to add or delete a state from an element's
36+
* `CustomStateSet`.
37+
*
38+
* Provides compatibility with legacy dashed identifier syntax (`:--state`) used
39+
* by the element-internals-polyfill for Chrome extension support.
40+
*
41+
* @example
42+
* ```ts
43+
* const baseClass = mixinCustomStateSet(mixinElementInternals(LitElement));
44+
*
45+
* class MyElement extends baseClass {
46+
* get checked() {
47+
* return this[hasState]('checked');
48+
* }
49+
* set checked(value: boolean) {
50+
* this[toggleState]('checked', value);
51+
* }
52+
* }
53+
* ```
54+
*/
55+
export const toggleState = Symbol('toggleState');
56+
57+
/**
58+
* An instance with `[hasState]()` and `[toggleState]()` symbol functions that
59+
* provide compatibility with `CustomStateSet` legacy dashed identifier syntax,
60+
* used by the element-internals-polyfill and needed for Chrome extension
61+
* compatibility.
62+
*/
63+
export interface WithCustomStateSet {
64+
/**
65+
* Checks if the state is active, returning true if the element matches
66+
* `:state(customstate)`.
67+
*
68+
* @param customState the `CustomStateSet` state to check. Do not use the
69+
* `--customstate` dashed identifier syntax.
70+
* @return true if the custom state is active, or false if not.
71+
*/
72+
[hasState](customState: string): boolean;
73+
74+
/**
75+
* Toggles the state to be active or inactive based on the provided value.
76+
* When active, the element matches `:state(customstate)`.
77+
*
78+
* @param customState the `CustomStateSet` state to check. Do not use the
79+
* `--customstate` dashed identifier syntax.
80+
* @param isActive true to add the state, or false to delete it.
81+
*/
82+
[toggleState](customState: string, isActive: boolean): void;
83+
}
84+
85+
// Private symbols
86+
const privateUseDashedIdentifier = Symbol('privateUseDashedIdentifier');
87+
const privateGetStateIdentifier = Symbol('privateGetStateIdentifier');
88+
89+
/**
90+
* Mixes in compatibility functions for access to an element's `CustomStateSet`.
91+
*
92+
* Use this mixin's `[hasState]()` and `[toggleState]()` symbol functions for
93+
* compatibility with `CustomStateSet` legacy dashed identifier syntax.
94+
*
95+
* https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet#compatibility_with_dashed-ident_syntax.
96+
*
97+
* The dashed identifier syntax is needed for element-internals-polyfill, a
98+
* requirement for Chome extension compatibility.
99+
*
100+
* @example
101+
* ```ts
102+
* const baseClass = mixinCustomStateSet(mixinElementInternals(LitElement));
103+
*
104+
* class MyElement extends baseClass {
105+
* get checked() {
106+
* return this[hasState]('checked');
107+
* }
108+
* set checked(value: boolean) {
109+
* this[toggleState]('checked', value);
110+
* }
111+
* }
112+
* ```
113+
*
114+
* @param base The class to mix functionality into.
115+
* @return The provided class with `[hasState]()` and `[toggleState]()`
116+
* functions mixed in.
117+
*/
118+
export function mixinCustomStateSet<
119+
T extends MixinBase<LitElement & WithElementInternals>,
120+
>(base: T): MixinReturn<T, WithCustomStateSet> {
121+
abstract class WithCustomStateSetElement
122+
extends base
123+
implements WithCustomStateSet
124+
{
125+
[hasState](state: string) {
126+
state = this[privateGetStateIdentifier](state);
127+
return this[internals].states.has(state);
128+
}
129+
130+
[toggleState](state: string, isActive: boolean) {
131+
state = this[privateGetStateIdentifier](state);
132+
if (isActive) {
133+
this[internals].states.add(state);
134+
} else {
135+
this[internals].states.delete(state);
136+
}
137+
}
138+
139+
[privateUseDashedIdentifier]: boolean | null = null;
140+
141+
[privateGetStateIdentifier](state: string) {
142+
if (this[privateUseDashedIdentifier] === null) {
143+
// Check if `--state-string` needs to be used. See
144+
// https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet#compatibility_with_dashed-ident_syntax
145+
try {
146+
const testState = '_test';
147+
this[internals].states.add(testState);
148+
this[internals].states.delete(testState);
149+
this[privateUseDashedIdentifier] = false;
150+
} catch {
151+
this[privateUseDashedIdentifier] = true;
152+
}
153+
}
154+
155+
return this[privateUseDashedIdentifier] ? `--${state}` : state;
156+
}
157+
}
158+
159+
return WithCustomStateSetElement;
160+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
// import 'jasmine'; (google3-only)
8+
9+
import {LitElement} from 'lit';
10+
import {customElement} from 'lit/decorators.js';
11+
12+
import {hasState, mixinCustomStateSet, toggleState} from './custom-state-set.js';
13+
import {mixinElementInternals} from './element-internals.js';
14+
15+
@customElement('test-custom-state-set')
16+
class TestCustomStateSet extends mixinCustomStateSet(
17+
mixinElementInternals(LitElement),
18+
) {}
19+
20+
for (const testWithPolyfill of [false, true]) {
21+
const describeSuffix = testWithPolyfill
22+
? ' (with element-internals-polyfill)'
23+
: '';
24+
25+
describe(`mixinCustomStateSet()${describeSuffix}`, () => {
26+
const nativeAttachInternals = HTMLElement.prototype.attachInternals;
27+
28+
beforeAll(() => {
29+
if (testWithPolyfill) {
30+
// A more reliable test would use `forceElementInternalsPolyfill()` from
31+
// `element-internals-polyfill`, but our GitHub test build doesn't
32+
// support it since the polyfill changes global types.
33+
34+
/* A simplified version of element-internal-polyfill CustomStateSet. */
35+
class PolyfilledCustomStateSet extends Set<string> {
36+
constructor(private readonly ref: HTMLElement) {
37+
super();
38+
}
39+
40+
override add(state: string) {
41+
if (!/^--/.test(state) || typeof state !== 'string') {
42+
throw new DOMException(
43+
`Failed to execute 'add' on 'CustomStateSet': The specified value ${state} must start with '--'.`,
44+
);
45+
}
46+
const result = super.add(state);
47+
this.ref.toggleAttribute(`state${state}`, true);
48+
return result;
49+
}
50+
51+
override clear() {
52+
for (const [entry] of this.entries()) {
53+
this.delete(entry);
54+
}
55+
super.clear();
56+
}
57+
58+
override delete(state: string) {
59+
const result = super.delete(state);
60+
this.ref.toggleAttribute(`state${state}`, false);
61+
return result;
62+
}
63+
}
64+
65+
HTMLElement.prototype.attachInternals = function (this: HTMLElement) {
66+
const internals = nativeAttachInternals.call(this);
67+
Object.defineProperty(internals, 'states', {
68+
enumerable: true,
69+
configurable: true,
70+
value: new PolyfilledCustomStateSet(this),
71+
});
72+
73+
return internals;
74+
};
75+
}
76+
});
77+
78+
afterAll(() => {
79+
if (testWithPolyfill) {
80+
HTMLElement.prototype.attachInternals = nativeAttachInternals;
81+
}
82+
});
83+
84+
describe('[hasState]()', () => {
85+
it('returns false when the state is not active', () => {
86+
// Arrange
87+
const element = new TestCustomStateSet();
88+
89+
// Assert
90+
expect(element[hasState]('foo'))
91+
.withContext("[hasState]('foo')")
92+
.toBeFalse();
93+
});
94+
95+
it('returns true when the state is active', () => {
96+
// Arrange
97+
const element = new TestCustomStateSet();
98+
99+
// Act
100+
element[toggleState]('foo', true);
101+
102+
// Assert
103+
expect(element[hasState]('foo'))
104+
.withContext("[hasState]('foo')")
105+
.toBeTrue();
106+
});
107+
108+
it('returns false when the state is deactivated', () => {
109+
// Arrange
110+
const element = new TestCustomStateSet();
111+
element[toggleState]('foo', true);
112+
113+
// Act
114+
element[toggleState]('foo', false);
115+
116+
// Assert
117+
expect(element[hasState]('foo'))
118+
.withContext("[hasState]('foo')")
119+
.toBeFalse();
120+
});
121+
});
122+
123+
describe('[toggleState]()', () => {
124+
const fooStateSelector = testWithPolyfill
125+
? `[state--foo]`
126+
: ':state(foo)';
127+
128+
it(`matches '${fooStateSelector}' when the state is active`, () => {
129+
// Arrange
130+
const element = new TestCustomStateSet();
131+
132+
// Act
133+
element[toggleState]('foo', true);
134+
135+
// Assert
136+
expect(element.matches(fooStateSelector))
137+
.withContext(`element.matches('${fooStateSelector}')`)
138+
.toBeTrue();
139+
});
140+
141+
it(`does not match '${fooStateSelector}' when the state is deactivated`, () => {
142+
// Arrange
143+
const element = new TestCustomStateSet();
144+
element[toggleState]('foo', true);
145+
146+
// Act
147+
element[toggleState]('foo', false);
148+
149+
// Assert
150+
expect(element.matches(fooStateSelector))
151+
.withContext(`element.matches('${fooStateSelector}')`)
152+
.toBeFalse();
153+
});
154+
155+
it(`does not match '${fooStateSelector}' by default`, () => {
156+
// Arrange
157+
const element = new TestCustomStateSet();
158+
159+
// Assert
160+
expect(element.matches(fooStateSelector))
161+
.withContext(`element.matches('${fooStateSelector}')`)
162+
.toBeFalse();
163+
});
164+
});
165+
});
166+
}

0 commit comments

Comments
 (0)