Skip to content

Commit 776c022

Browse files
committed
split out ability & controllable
1 parent 800d7ee commit 776c022

File tree

5 files changed

+280
-267
lines changed

5 files changed

+280
-267
lines changed

src/ability.ts

Lines changed: 12 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,17 @@
1-
import type {CustomElement} from './custom-element.js'
2-
3-
export interface Ability extends CustomElement {
4-
[attachShadowCallback]?(shadowRoot: ShadowRoot): void
5-
[attachInternalsCallback]?(internals: ElementInternals): void
6-
}
7-
8-
export interface AbilityClass {
9-
new (): Ability
10-
observedAttributes?: string[]
11-
formAssociated?: boolean
12-
}
13-
14-
export const attachShadowCallback = Symbol()
15-
export const attachInternalsCallback = Symbol()
16-
17-
type Decorator = (Class: AbilityClass) => AbilityClass
18-
const abilityMarkers = new WeakMap<AbilityClass, Set<Decorator>>()
19-
export const createAbility = (decorate: Decorator) => {
20-
return (Class: AbilityClass): AbilityClass => {
21-
if (!abilityMarkers.has(Class)) Class = abilitable(Class)
1+
import type {CustomElementClass} from './custom-element.js'
2+
3+
type Decorator = (Class: CustomElementClass) => unknown
4+
const abilityMarkers = new WeakMap<CustomElementClass, Set<Decorator>>()
5+
export const createAbility = <TExtend, TClass extends CustomElementClass>(
6+
decorate: (Class: TClass) => TExtend
7+
): ((Class: TClass) => TExtend) => {
8+
return (Class: TClass): TExtend => {
229
const markers = abilityMarkers.get(Class)
23-
if (markers?.has(decorate)) return Class
24-
const NewClass = decorate(Class as AbilityClass)
10+
if (markers?.has(decorate as Decorator)) return Class as unknown as TExtend
11+
const NewClass = decorate(Class) as TExtend
2512
const newMarkers = new Set(markers)
26-
newMarkers.add(decorate)
27-
abilityMarkers.set(NewClass, newMarkers)
13+
newMarkers.add(decorate as Decorator)
14+
abilityMarkers.set(NewClass as unknown as CustomElementClass, newMarkers)
2815
return NewClass
2916
}
3017
}
31-
32-
const shadows = new WeakMap<Ability, ShadowRoot | undefined>()
33-
const internals = new WeakMap<Ability, ElementInternals>()
34-
const internalsCalled = new WeakSet()
35-
const abilitable = (Class: AbilityClass): AbilityClass =>
36-
class extends Class {
37-
constructor() {
38-
super()
39-
const shadowRoot = this.shadowRoot
40-
if (shadowRoot && shadowRoot !== shadows.get(this)) this[attachShadowCallback](shadowRoot)
41-
if (!internalsCalled.has(this)) {
42-
try {
43-
this.attachInternals()
44-
} catch {
45-
// Ignore errors
46-
}
47-
}
48-
}
49-
50-
connectedCallback() {
51-
super.connectedCallback?.()
52-
this.setAttribute('data-catalyst', '')
53-
}
54-
55-
attachShadow(...args: [init: ShadowRootInit]): ShadowRoot {
56-
const shadowRoot = super.attachShadow(...args)
57-
this[attachShadowCallback](shadowRoot)
58-
return shadowRoot
59-
}
60-
61-
[attachShadowCallback](shadowRoot: ShadowRoot) {
62-
shadows.set(this, shadowRoot)
63-
}
64-
65-
attachInternals(): ElementInternals {
66-
if (internals.has(this) && !internalsCalled.has(this)) {
67-
internalsCalled.add(this)
68-
return internals.get(this)!
69-
}
70-
const elementInternals = super.attachInternals()
71-
this[attachInternalsCallback](elementInternals)
72-
internals.set(this, elementInternals)
73-
return elementInternals
74-
}
75-
76-
[attachInternalsCallback](elementInternals: ElementInternals) {
77-
const shadowRoot = elementInternals.shadowRoot
78-
if (shadowRoot && shadowRoot !== shadows.get(this)) {
79-
this[attachShadowCallback](shadowRoot)
80-
}
81-
}
82-
}

src/controllable.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type {CustomElementClass, CustomElement} from './custom-element.js'
2+
import {createAbility} from './ability.js'
3+
4+
export interface Controllable {
5+
[attachShadowCallback]?(shadowRoot: ShadowRoot): void
6+
[attachInternalsCallback]?(internals: ElementInternals): void
7+
}
8+
export interface ControllableClass {
9+
// TS mandates Constructors that get mixins have `...args: any[]`
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
new (...args: any[]): Controllable
12+
}
13+
14+
export const attachShadowCallback = Symbol()
15+
export const attachInternalsCallback = Symbol()
16+
17+
const shadows = new WeakMap<Controllable, ShadowRoot | undefined>()
18+
const internals = new WeakMap<Controllable, ElementInternals>()
19+
const internalsCalled = new WeakSet()
20+
export const controllable = createAbility(
21+
<T extends CustomElementClass>(Class: T): T & ControllableClass =>
22+
class extends Class {
23+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
24+
// @ts-ignore TypeScript doesn't like assigning static name
25+
static get name() {
26+
return Class.name
27+
}
28+
29+
// TS mandates Constructors that get mixins have `...args: any[]`
30+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
31+
constructor(...args: any[]) {
32+
super(...args)
33+
const shadowRoot = this.shadowRoot
34+
if (shadowRoot && shadowRoot !== shadows.get(this)) this[attachShadowCallback](shadowRoot)
35+
if (!internalsCalled.has(this)) {
36+
try {
37+
this.attachInternals()
38+
} catch {
39+
// Ignore errors
40+
}
41+
}
42+
}
43+
44+
connectedCallback() {
45+
this.setAttribute('data-catalyst', '')
46+
super.connectedCallback?.()
47+
}
48+
49+
attachShadow(...args: [init: ShadowRootInit]): ShadowRoot {
50+
const shadowRoot = super.attachShadow(...args)
51+
this[attachShadowCallback](shadowRoot)
52+
return shadowRoot
53+
}
54+
55+
[attachShadowCallback](this: CustomElement & Controllable, shadowRoot: ShadowRoot) {
56+
shadows.set(this, shadowRoot)
57+
}
58+
59+
attachInternals(): ElementInternals {
60+
if (internals.has(this) && !internalsCalled.has(this)) {
61+
internalsCalled.add(this)
62+
return internals.get(this)!
63+
}
64+
const elementInternals = super.attachInternals()
65+
this[attachInternalsCallback](elementInternals)
66+
internals.set(this, elementInternals)
67+
return elementInternals
68+
}
69+
70+
[attachInternalsCallback](elementInternals: ElementInternals) {
71+
const shadowRoot = elementInternals.shadowRoot
72+
if (shadowRoot && shadowRoot !== shadows.get(this)) {
73+
this[attachShadowCallback](shadowRoot)
74+
}
75+
}
76+
}
77+
)

src/custom-element.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ export interface CustomElement extends HTMLElement {
1010
}
1111

1212
export interface CustomElementClass {
13-
new (): CustomElement
13+
// TS mandates Constructors that get mixins have `...args: any[]`
14+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
15+
new (...args: any[]): CustomElement
1416
observedAttributes?: string[]
17+
disabledFeatures?: string[]
1518
formAssociated?: boolean
1619
}

test/ability.ts

Lines changed: 4 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import {expect, fixture, html} from '@open-wc/testing'
2-
import {restore, fake} from 'sinon'
31
import type {CustomElement} from '../src/custom-element.js'
4-
import {createAbility, attachShadowCallback, attachInternalsCallback} from '../src/ability.js'
2+
import {expect, fixture, html} from '@open-wc/testing'
3+
import {restore} from 'sinon'
4+
import {createAbility} from '../src/ability.js'
55

66
describe('ability', () => {
7-
let calls: string[] = []
7+
const calls: string[] = []
88
const fakeable = createAbility(
99
Class =>
1010
class extends Class {
@@ -91,189 +91,4 @@ describe('ability', () => {
9191
instance.connectedCallback!()
9292
expect(calls).to.eql(['fakeable connectedCallback', 'fakeable connectedCallback'])
9393
})
94-
95-
describe('subclass behaviour', () => {
96-
const CoreTest = otherfakeable(fakeable(Element))
97-
customElements.define('core-test', CoreTest)
98-
99-
let instance: CustomElement & typeof CoreTest
100-
beforeEach(async () => {
101-
instance = await fixture(html`<core-test />`)
102-
})
103-
104-
it('applies keys from delegate onto subclass upon instantiation', () => {
105-
expect(instance).to.have.property('foo')
106-
expect((instance as unknown as Record<string, () => void>).foo()).to.equal('foo!')
107-
expect(instance).to.have.property('bar')
108-
expect((instance as unknown as Record<string, () => void>).bar()).to.equal('bar!')
109-
})
110-
111-
for (const method of ['connectedCallback', 'disconnectedCallback', 'adoptedCallback', 'attributeChangedCallback']) {
112-
it(`delegates to other ${method}s before class ${method}`, () => {
113-
calls = []
114-
;(instance as unknown as Record<string, () => void>)[method]()
115-
expect(calls).to.eql([`otherfakeable ${method}`, `fakeable ${method}`])
116-
})
117-
}
118-
})
119-
120-
describe('ability extension behaviour', () => {
121-
describe('attachShadowCallback', () => {
122-
let attachShadowFake: (shadow: ShadowRoot) => void
123-
let shadow: ShadowRoot | null
124-
beforeEach(() => {
125-
shadow = null
126-
attachShadowFake = fake()
127-
})
128-
129-
const declarable = createAbility(
130-
Class =>
131-
class extends Class {
132-
[attachShadowCallback](...args: [ShadowRoot]) {
133-
super[attachShadowCallback]!(...args)
134-
return attachShadowFake.apply(this, args)
135-
}
136-
}
137-
)
138-
customElements.define(
139-
'declarative-shadow-ability',
140-
declarable(
141-
class extends HTMLElement {
142-
constructor() {
143-
super()
144-
// Declarative shadows run before constructor() is available, but
145-
// abilities run after element constructor
146-
shadow = HTMLElement.prototype.attachShadow.call(this, {mode: 'closed'})
147-
}
148-
}
149-
)
150-
)
151-
customElements.define(
152-
'closed-shadow-ability',
153-
declarable(
154-
class extends HTMLElement {
155-
constructor() {
156-
super()
157-
shadow = this.attachShadow({mode: 'closed'})
158-
}
159-
}
160-
)
161-
)
162-
customElements.define(
163-
'connected-shadow-ability',
164-
declarable(
165-
class extends HTMLElement {
166-
connectedCallback() {
167-
shadow = this.attachShadow({mode: 'closed'})
168-
}
169-
}
170-
)
171-
)
172-
customElements.define('manual-shadow-ability', declarable(class extends HTMLElement {}))
173-
174-
customElements.define(
175-
'disallowed-shadow-ability',
176-
declarable(
177-
class extends HTMLElement {
178-
static disabledFeatures = ['shadow']
179-
}
180-
)
181-
)
182-
183-
it('is called with shadowRoot of declarative ShadowDOM', async () => {
184-
const instance = await fixture(html`<declarative-shadow-ability></declarative-shadow-ability>`)
185-
expect(shadow).to.exist.and.be.instanceof(ShadowRoot)
186-
expect(attachShadowFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(shadow)
187-
})
188-
189-
it('is called with shadowRoot from attachShadow call', async () => {
190-
const instance = await fixture(html`<manual-shadow-ability></manual-shadow-ability>`)
191-
shadow = instance.attachShadow({mode: 'closed'})
192-
expect(shadow).to.exist.and.be.instanceof(ShadowRoot)
193-
expect(attachShadowFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(shadow)
194-
})
195-
196-
it('is called with shadowRoot from attachInternals call', async () => {
197-
const instance = await fixture(html`<closed-shadow-ability></closed-shadow-ability>`)
198-
expect(shadow).to.exist.and.be.instanceof(ShadowRoot)
199-
expect(attachShadowFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(shadow)
200-
})
201-
202-
it('is called with shadowRoot from connectedCallback', async () => {
203-
const instance = await fixture(html`<connected-shadow-ability></connected-shadow-ability>`)
204-
expect(shadow).to.exist.and.be.instanceof(ShadowRoot)
205-
expect(attachShadowFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(shadow)
206-
})
207-
208-
it('does not error if shadowdom is disabled', async () => {
209-
await fixture(html`<disabled-shadow-ability></disabled-shadow-ability>`)
210-
expect(attachShadowFake).to.be.have.callCount(0)
211-
})
212-
})
213-
214-
describe('attachInternalsCallback', () => {
215-
let attachInternalsFake: (internals: ElementInternals) => void
216-
let internals: ElementInternals | null
217-
beforeEach(() => {
218-
internals = null
219-
attachInternalsFake = fake()
220-
})
221-
222-
const internable = createAbility(
223-
Class =>
224-
class extends Class {
225-
[attachInternalsCallback](...args: [ElementInternals]) {
226-
super[attachInternalsCallback]!(...args)
227-
return attachInternalsFake.apply(this, args)
228-
}
229-
}
230-
)
231-
customElements.define(
232-
'internals-ability',
233-
internable(
234-
class extends HTMLElement {
235-
constructor() {
236-
super()
237-
internals = this.attachInternals()
238-
}
239-
}
240-
)
241-
)
242-
customElements.define('manual-internals-ability', internable(class extends HTMLElement {}))
243-
244-
customElements.define(
245-
'disallowed-internals-ability',
246-
internable(
247-
class extends HTMLElement {
248-
static disabledFeatures = ['internals']
249-
}
250-
)
251-
)
252-
253-
it('is called on constructor', async () => {
254-
const instance = await fixture(html`<manual-internals-ability></manual-internals-ability>`)
255-
expect(attachInternalsFake).to.be.calledOnce.calledOn(instance)
256-
})
257-
258-
it('does not prevent attachInternals being called by userland class', async () => {
259-
const instance = await fixture(html`<internals-ability></internals-ability>`)
260-
expect(internals).to.exist.and.be.instanceof(ElementInternals)
261-
expect(attachInternalsFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(internals)
262-
})
263-
264-
it('errors if userland calls attachInternals more than once', async () => {
265-
const instance = await fixture<CustomElement>(html`<manual-internals-ability></manual-internals-ability>`)
266-
internals = instance.attachInternals()
267-
expect(internals).to.exist.and.be.instanceof(ElementInternals)
268-
expect(attachInternalsFake).to.be.calledOnce.calledOn(instance).and.calledWithExactly(internals)
269-
270-
expect(() => instance.attachInternals()).to.throw(DOMException)
271-
})
272-
273-
it('does not error if element internals are disabled', async () => {
274-
await fixture(html`<disallowed-internals-ability></disallowed-internals-ability>`)
275-
expect(attachInternalsFake).to.have.callCount(0)
276-
})
277-
})
278-
})
27994
})

0 commit comments

Comments
 (0)