Skip to content

Commit c700fef

Browse files
committed
feat(core): hasSlotted SSR hints
adds ssr-only `ssr-hint-has-slotted="slotA,default,slotB"` attribute to elements which use SlotController
1 parent fed5735 commit c700fef

File tree

4 files changed

+95
-43
lines changed

4 files changed

+95
-43
lines changed
Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import type { ReactiveElement, ReactiveController } from 'lit';
22

33
export class CssVariableController implements ReactiveController {
4-
style: CSSStyleDeclaration;
4+
style?: CSSStyleDeclaration;
55

66
constructor(public host: ReactiveElement) {
7-
this.style = window.getComputedStyle(host);
7+
if (this.host.isConnected) {
8+
this.hostConnected();
9+
}
810
}
911

1012
private parseProperty(name: string) {
1113
return name.substring(0, 2) !== '--' ? `--${name}` : name;
1214
}
1315

1416
getVariable(name: string): string | null {
15-
return this.style.getPropertyValue(this.parseProperty(name)).trim() || null;
17+
return this.style?.getPropertyValue(this.parseProperty(name)).trim() || null;
1618
}
1719

18-
hostConnected?(): void;
20+
hostConnected(): void {
21+
this.style = window.getComputedStyle(this.host);
22+
};
1923
}

core/pfe-core/controllers/slot-controller.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ReactiveController, ReactiveElement } from 'lit';
1+
import { isServer, type ReactiveController, type ReactiveElement } from 'lit';
22

33
import { Logger } from './logger.js';
44

@@ -143,11 +143,19 @@ export class SlotController implements ReactiveController {
143143
* @example this.hasSlotted('header');
144144
*/
145145
hasSlotted(...names: (string | null | undefined)[]): boolean {
146-
const slotNames = Array.from(names, x => x == null ? SlotController.default : x);
147-
if (!slotNames.length) {
148-
slotNames.push(SlotController.default);
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);
149158
}
150-
return slotNames.some(x => this.#nodes.get(x)?.hasContent ?? false);
151159
}
152160

153161
/**

elements/pf-card/test/pf-card.e2e.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test } from '@playwright/test';
1+
import { test, expect } from '@playwright/test';
22
import { PfeDemoPage } from '@patternfly/pfe-tools/test/playwright/PfeDemoPage.js';
33
import { SSRPage } from '@patternfly/pfe-tools/test/playwright/SSRPage.js';
44

@@ -22,4 +22,21 @@ test.describe(tagName, () => {
2222
});
2323
await fixture.snapshots();
2424
});
25+
26+
test('ssr hints', async ({ browser }) => {
27+
const fixture = new SSRPage({
28+
tagName,
29+
browser,
30+
importSpecifiers: [`@patternfly/elements/${tagName}/${tagName}.js`],
31+
demoContent: /* html */ `
32+
<pf-card ssr-hint-has-slotted="header,default,footer">
33+
<h2 slot="header">Header</h2>
34+
<span>Body</span>
35+
<span slot="footer">Footer</span>
36+
</pf-card>
37+
`,
38+
});
39+
await fixture.updateCompleteFor('pf-card');
40+
expect(fixture.page.locator('pf-card #title')).toHaveAttribute('hidden');
41+
});
2542
});

tools/pfe-tools/test/playwright/SSRPage.ts

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import Koa from 'koa';
99
import type { Browser, Page } from '@playwright/test';
1010
import type { Server } from 'node:http';
1111
import type { AddressInfo } from 'node:net';
12+
import type { LitElement } from 'lit';
1213

13-
interface SSRDemoConfig {
14-
demoDir: URL;
14+
interface SSRPageConfig {
15+
demoDir?: URL;
16+
demoContent?: string;
1517
importSpecifiers: string[];
1618
tagName: string;
1719
browser: Browser;
@@ -25,37 +27,54 @@ export class SSRPage {
2527
private app: Koa;
2628
private server!: Server;
2729
private host!: string;
28-
private page!: Page;
2930
private demoPaths!: string[];
3031

31-
constructor(
32-
private config: SSRDemoConfig,
33-
) {
32+
public page!: Page;
33+
34+
constructor(private config: SSRPageConfig) {
3435
this.app = new Koa();
35-
this.app.use(async (ctx, next) => {
36+
this.app.use(this.middleware(config));
37+
}
38+
39+
private middleware({ demoContent, demoDir, importSpecifiers }: SSRPageConfig) {
40+
return async (ctx: Koa.Context, next: Koa.Next) => {
3641
if (ctx.method === 'GET') {
37-
const origPath = ctx.request.path.replace(/^\//, '');
38-
const demoDir = config.demoDir.href;
39-
const fileUrl = resolve(demoDir, origPath);
40-
if (ctx.request.path.endsWith('.html')) {
42+
if (demoContent) {
4143
try {
42-
const content = await readFile(fileURLToPath(fileUrl), 'utf-8');
43-
ctx.response.body = await renderGlobal(content, this.config.importSpecifiers);
44+
ctx.response.body = await renderGlobal(
45+
demoContent,
46+
importSpecifiers,
47+
);
4448
} catch (e) {
4549
ctx.response.status = 500;
4650
ctx.response.body = (e as Error).stack;
4751
}
48-
} else {
49-
try {
50-
ctx.response.body = await readFile(fileURLToPath(fileUrl));
51-
} catch (e) {
52-
ctx.throw(500, e as Error);
52+
} else if (demoDir) {
53+
const origPath = ctx.request.path.replace(/^\//, '');
54+
const { href } = demoDir;
55+
const fileUrl = resolve(href, origPath);
56+
if (ctx.request.path.endsWith('.html')) {
57+
try {
58+
const content = await readFile(fileURLToPath(fileUrl), 'utf-8');
59+
ctx.response.body = await renderGlobal(content, importSpecifiers);
60+
} catch (e) {
61+
ctx.response.status = 500;
62+
ctx.response.body = (e as Error).stack;
63+
}
64+
} else {
65+
try {
66+
ctx.response.body = await readFile(fileURLToPath(fileUrl));
67+
} catch (e) {
68+
ctx.throw(500, e as Error);
69+
}
5370
}
71+
} else {
72+
throw new Error('SSRPage must either have a demoDir URL or a demoContent string');
5473
}
5574
} else {
5675
return next();
5776
}
58-
});
77+
};
5978
}
6079

6180
private async initPage() {
@@ -82,6 +101,22 @@ export class SSRPage {
82101
!this.server ? rej('no server') : this.server?.close(e => e ? rej(e) : res()));
83102
}
84103

104+
/**
105+
* Take a visual regression snapshot and save it to disk
106+
* @param url url to the demo file
107+
*/
108+
private async snapshot(url: string) {
109+
const response = await this.page.goto(url, { waitUntil: 'load' });
110+
if (response?.status() === 404) {
111+
throw new Error(`Not Found: ${url}`);
112+
}
113+
expect(response?.status(), await response?.text())
114+
.toEqual(200);
115+
const snapshot = await this.page.screenshot({ fullPage: true });
116+
expect(snapshot, new URL(url).pathname)
117+
.toMatchSnapshot(`${this.config.tagName}-${basename(url)}.png`);
118+
}
119+
85120
/**
86121
* Creates visual regression snapshots for each demo in the server's `demoDir`
87122
*/
@@ -99,19 +134,7 @@ export class SSRPage {
99134
}
100135
}
101136

102-
/**
103-
* Take a visual regression snapshot and save it to disk
104-
* @param url url to the demo file
105-
*/
106-
private async snapshot(url: string) {
107-
const response = await this.page.goto(url, { waitUntil: 'load' });
108-
if (response?.status() === 404) {
109-
throw new Error(`Not Found: ${url}`);
110-
}
111-
expect(response?.status(), await response?.text())
112-
.toEqual(200);
113-
const snapshot = await this.page.screenshot({ fullPage: true });
114-
expect(snapshot, new URL(url).pathname)
115-
.toMatchSnapshot(`${this.config.tagName}-${basename(url)}.png`);
137+
async updateCompleteFor(tagName: string): Promise<void> {
138+
await this.page.$eval(tagName, el => (el as LitElement).updateComplete);
116139
}
117140
}

0 commit comments

Comments
 (0)