Skip to content

Commit d605a41

Browse files
committed
feat(core): wip slots decorator
1 parent 9bd0874 commit d605a41

File tree

2 files changed

+106
-81
lines changed

2 files changed

+106
-81
lines changed
+79-81
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { isServer, type ReactiveController, type ReactiveElement } from 'lit';
22

3-
import { Logger } from './logger.js';
4-
53
interface AnonymousSlot {
64
hasContent: boolean;
75
elements: Element[];
@@ -32,7 +30,9 @@ export interface SlotsConfig {
3230
deprecations?: Record<string, string>;
3331
}
3432

35-
function isObjectConfigSpread(
33+
export type SlotControllerArgs = [SlotsConfig] | (string | null)[];
34+
35+
function isObjectSpread(
3636
config: ([SlotsConfig] | (string | null)[]),
3737
): config is [SlotsConfig] {
3838
return config.length === 1 && typeof config[0] === 'object' && config[0] !== null;
@@ -55,60 +55,108 @@ export class SlotController implements ReactiveController {
5555
/** @deprecated use `default` */
5656
public static anonymous: symbol = this.default;
5757

58-
#nodes = new Map<string | typeof SlotController.default, Slot>();
58+
private static singletons = new WeakMap<ReactiveElement, SlotController>();
5959

60-
#logger: Logger;
60+
#nodes = new Map<string | typeof SlotController.default, Slot>();
6161

62-
#firstUpdated = false;
62+
#slotMapInitialized = false;
6363

64-
#mo = new MutationObserver(records => this.#onMutation(records));
64+
#slotNames: (string | null)[] = [];
6565

66-
#slotNames: (string | null)[];
66+
#ssrHintHasSlotted: (string | null)[] = [];
6767

6868
#deprecations: Record<string, string> = {};
6969

70-
constructor(public host: ReactiveElement, ...config: ([SlotsConfig] | (string | null)[])) {
71-
this.#logger = new Logger(this.host);
70+
#mo = new MutationObserver(this.#initSlotMap.bind(this));
71+
72+
constructor(public host: ReactiveElement, ...args: SlotControllerArgs) {
73+
const singleton = SlotController.singletons.get(host);
74+
if (singleton) {
75+
singleton.#initialize(...args);
76+
return singleton;
77+
}
78+
this.#initialize(...args);
79+
host.addController(this);
80+
SlotController.singletons.set(host, this);
81+
if (!this.#slotNames.length) {
82+
this.#slotNames = [null];
83+
}
84+
}
7285

73-
if (isObjectConfigSpread(config)) {
86+
#initialize(...config: SlotControllerArgs) {
87+
if (isObjectSpread(config)) {
7488
const [{ slots, deprecations }] = config;
7589
this.#slotNames = slots;
7690
this.#deprecations = deprecations ?? {};
7791
} else if (config.length >= 1) {
7892
this.#slotNames = config;
7993
this.#deprecations = {};
80-
} else {
81-
this.#slotNames = [null];
8294
}
83-
84-
85-
host.addController(this);
8695
}
8796

8897
async hostConnected(): Promise<void> {
89-
this.host.addEventListener('slotchange', this.#onSlotChange as EventListener);
90-
this.#firstUpdated = false;
9198
this.#mo.observe(this.host, { childList: true });
99+
this.#ssrHintHasSlotted =
100+
this.host
101+
// @ts-expect-error: this is a ponyfill for ::has-slotted, is not intended as a public API
102+
.ssrHintHasSlotted
103+
?? [];
92104
// Map the defined slots into an object that is easier to query
93105
this.#nodes.clear();
94-
// Loop over the properties provided by the schema
95-
this.#slotNames.forEach(this.#initSlot);
96-
Object.values(this.#deprecations).forEach(this.#initSlot);
97-
this.host.requestUpdate();
106+
this.#initSlotMap();
98107
// insurance for framework integrations
99108
await this.host.updateComplete;
100109
this.host.requestUpdate();
101110
}
102111

112+
hostDisconnected(): void {
113+
this.#mo.disconnect();
114+
}
115+
103116
hostUpdated(): void {
104-
if (!this.#firstUpdated) {
105-
this.#slotNames.forEach(this.#initSlot);
106-
this.#firstUpdated = true;
117+
if (!this.#slotMapInitialized) {
118+
this.#initSlotMap();
107119
}
108120
}
109121

110-
hostDisconnected(): void {
111-
this.#mo.disconnect();
122+
#initSlotMap() {
123+
// Loop over the properties provided by the schema
124+
for (const slotName of this.#slotNames
125+
.concat(Object.values(this.#deprecations))) {
126+
const slotId = slotName || SlotController.default;
127+
const name = slotName ?? '';
128+
const elements = this.#getChildrenForSlot(slotId);
129+
const slot = this.#getSlotElement(slotId);
130+
const hasContent =
131+
isServer ? this.#ssrHintHasSlotted.includes(slotName)
132+
: !!elements.length || !!slot?.assignedNodes?.()?.filter(x => x.textContent?.trim()).length;
133+
this.#nodes.set(slotId, { elements, name, hasContent, slot });
134+
}
135+
this.host.requestUpdate();
136+
this.#slotMapInitialized = true;
137+
}
138+
139+
#getSlotElement(slotId: string | symbol) {
140+
if (isServer) {
141+
return null;
142+
} else {
143+
const selector =
144+
slotId === SlotController.default ? 'slot:not([name])' : `slot[name="${slotId as string}"]`;
145+
return this.host.shadowRoot?.querySelector?.<HTMLSlotElement>(selector) ?? null;
146+
}
147+
}
148+
149+
#getChildrenForSlot<T extends Element = Element>(
150+
name: string | typeof SlotController.default,
151+
): T[] {
152+
if (isServer) {
153+
return [];
154+
} else if (this.#nodes.has(name)) {
155+
return this.#nodes.get(name)!.slot?.assignedElements?.() as T[];
156+
} else {
157+
const children = Array.from(this.host.children) as T[];
158+
return children.filter(isSlot(name));
159+
}
112160
}
113161

114162
/**
@@ -143,19 +191,11 @@ export class SlotController implements ReactiveController {
143191
* @example this.hasSlotted('header');
144192
*/
145193
hasSlotted(...names: (string | null | undefined)[]): boolean {
146-
if (isServer) {
147-
return this.host
148-
.getAttribute('ssr-hint-has-slotted')
149-
?.split(',')
150-
.map(name => name.trim())
151-
.some(name => names.includes(name === 'default' ? null : name)) ?? false;
152-
} else {
153-
const slotNames = Array.from(names, x => x == null ? SlotController.default : x);
154-
if (!slotNames.length) {
155-
slotNames.push(SlotController.default);
156-
}
157-
return slotNames.some(x => this.#nodes.get(x)?.hasContent ?? false);
194+
const slotNames = Array.from(names, x => x == null ? SlotController.default : x);
195+
if (!slotNames.length) {
196+
slotNames.push(SlotController.default);
158197
}
198+
return slotNames.some(x => this.#nodes.get(x)?.hasContent ?? false);
159199
}
160200

161201
/**
@@ -168,46 +208,4 @@ export class SlotController implements ReactiveController {
168208
isEmpty(...names: (string | null | undefined)[]): boolean {
169209
return !this.hasSlotted(...names);
170210
}
171-
172-
#onSlotChange = (event: Event & { target: HTMLSlotElement }) => {
173-
const slotName = event.target.name;
174-
this.#initSlot(slotName);
175-
this.host.requestUpdate();
176-
};
177-
178-
#onMutation = async (records: MutationRecord[]) => {
179-
const changed = [];
180-
for (const { addedNodes, removedNodes } of records) {
181-
for (const node of [...addedNodes, ...removedNodes]) {
182-
if (node instanceof HTMLElement && node.slot) {
183-
this.#initSlot(node.slot);
184-
changed.push(node.slot);
185-
}
186-
}
187-
}
188-
this.host.requestUpdate();
189-
};
190-
191-
#getChildrenForSlot<T extends Element = Element>(
192-
name: string | typeof SlotController.default,
193-
): T[] {
194-
if (isServer) {
195-
return [];
196-
} else {
197-
const children = Array.from(this.host.children) as T[];
198-
return children.filter(isSlot(name));
199-
}
200-
}
201-
202-
#initSlot = (slotName: string | null) => {
203-
const name = slotName || SlotController.default;
204-
const elements = this.#nodes.get(name)?.slot?.assignedElements?.()
205-
?? this.#getChildrenForSlot(name);
206-
const selector = slotName ? `slot[name="${slotName}"]` : 'slot:not([name])';
207-
const slot = this.host.shadowRoot?.querySelector?.<HTMLSlotElement>(selector) ?? null;
208-
const nodes = slot?.assignedNodes?.();
209-
const hasContent = !!elements.length || !!nodes?.filter(x => x.textContent?.trim()).length;
210-
this.#nodes.set(name, { elements, name: slotName ?? '', hasContent, slot });
211-
this.#logger.debug(slotName, hasContent);
212-
};
213211
}

core/pfe-core/decorators/slots.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { ReactiveElement } from 'lit';
2+
3+
import { SlotController, type SlotControllerArgs } from '../controllers/slot-controller.js';
4+
5+
/**
6+
* Enable ssr hints for element
7+
* @param args a spread of slot names, or a config object.
8+
* @see SlotController constructor args
9+
*/
10+
export function slots<T extends typeof ReactiveElement>(...args: SlotControllerArgs) {
11+
return function(klass: T): void {
12+
klass.createProperty('ssrHintHasSlotted', {
13+
attribute: 'ssr-hint-has-slotted',
14+
converter: {
15+
fromAttribute(slots) {
16+
return (slots ?? '')
17+
.split(/[, ]/)
18+
.map(x => x.trim())
19+
.map(x => x === 'default' ? null : x);
20+
},
21+
},
22+
});
23+
klass.addInitializer(instance => {
24+
new SlotController(instance, ...args);
25+
});
26+
};
27+
}

0 commit comments

Comments
 (0)