diff --git a/.changeset/all-tigers-wear.md b/.changeset/all-tigers-wear.md new file mode 100644 index 0000000000..ae90c162b3 --- /dev/null +++ b/.changeset/all-tigers-wear.md @@ -0,0 +1,4 @@ +--- +"@patternfly/elements": patch +--- +**SSR**: enable more elements and more features to be rendered server-side diff --git a/.changeset/clear-pugs-make.md b/.changeset/clear-pugs-make.md index 2bc7175b94..f5abb0b87c 100644 --- a/.changeset/clear-pugs-make.md +++ b/.changeset/clear-pugs-make.md @@ -1,6 +1,5 @@ --- "@patternfly/pfe-core": major -"@patternfly/elements": patch --- Enable `connectedCallback()` and context protocol in SSR scenarios. @@ -15,7 +14,7 @@ Before: ```js connectedCallback() { super.connectedCallback(); - this.items = this.querySelectorAll('my-item'); + this.items = [...this.querySelectorAll('my-item')]; } ``` @@ -24,8 +23,7 @@ After: connectedCallback() { super.connectedCallback(); if (!isServer) { - this.items = this.querySelectorAll('my-item'); + this.items = isServer ? [] : [...this.querySelectorAll('my-item')]; } } ``` - diff --git a/core/pfe-core/controllers/slot-controller-server.ts b/core/pfe-core/controllers/slot-controller-server.ts new file mode 100644 index 0000000000..a9ea9ae53f --- /dev/null +++ b/core/pfe-core/controllers/slot-controller-server.ts @@ -0,0 +1,43 @@ +import type { ReactiveElement } from 'lit'; +import { + type SlotControllerArgs, + type SlotControllerPublicAPI, +} from './slot-controller.js'; + +export class SlotController implements SlotControllerPublicAPI { + public static default = Symbol('default slot') satisfies symbol as symbol; + + /** @deprecated use `default` */ + public static anonymous: symbol = this.default; + + static attribute = 'ssr-hint-has-slotted' as const; + + static anonymousAttribute = 'ssr-hint-has-slotted-default' as const; + + constructor(public host: ReactiveElement, ..._: SlotControllerArgs) { + host.addController(this); + } + + hostConnected?(): Promise; + + private fromAttribute(slots: string | null) { + return (slots ?? '') + .split(/[, ]/) + .map(x => x.trim()); + } + + getSlotted(..._: string[]): T[] { + return []; + } + + hasSlotted(...names: (string | null)[]): boolean { + const attr = this.host.getAttribute(SlotController.attribute); + const anon = this.host.hasAttribute(SlotController.anonymousAttribute); + const hints = new Set(this.fromAttribute(attr)); + return names.every(x => x === null ? anon : hints.has(x)); + } + + isEmpty(...names: (string | null)[]): boolean { + return !this.hasSlotted(...names); + } +} diff --git a/core/pfe-core/controllers/slot-controller.ts b/core/pfe-core/controllers/slot-controller.ts index 63dc3bf444..3a25bcf2ba 100644 --- a/core/pfe-core/controllers/slot-controller.ts +++ b/core/pfe-core/controllers/slot-controller.ts @@ -1,4 +1,4 @@ -import { isServer, type ReactiveController, type ReactiveElement } from 'lit'; +import type { ReactiveController, ReactiveElement } from 'lit'; interface AnonymousSlot { hasContent: boolean; @@ -49,7 +49,56 @@ const isSlot = n === SlotController.default ? !child.hasAttribute('slot') : child.getAttribute('slot') === n; -export class SlotController implements ReactiveController { +export declare class SlotControllerPublicAPI implements ReactiveController { + static default: symbol; + + public host: ReactiveElement; + + constructor(host: ReactiveElement, ...args: SlotControllerArgs); + + hostConnected?(): Promise; + + hostDisconnected?(): void; + + hostUpdated?(): void; + + /** + * Given a slot name or slot names, returns elements assigned to the requested slots as an array. + * If no value is provided, it returns all children not assigned to a slot (without a slot attribute). + * @param slotNames slots to query + * @example Get header-slotted elements + * ```js + * this.getSlotted('header') + * ``` + * @example Get header- and footer-slotted elements + * ```js + * this.getSlotted('header', 'footer') + * ``` + * @example Get default-slotted elements + * ```js + * this.getSlotted(); + * ``` + */ + getSlotted(...slotNames: string[]): T[]; + + /** + * Returns a boolean statement of whether or not any of those slots exists in the light DOM. + * @param names The slot names to check. + * @example this.hasSlotted('header'); + */ + hasSlotted(...names: (string | null | undefined)[]): boolean; + + /** + * Whether or not all the requested slots are empty. + * @param names The slot names to query. If no value is provided, it returns the default slot. + * @example this.isEmpty('header', 'footer'); + * @example this.isEmpty(); + * @returns + */ + isEmpty(...names: (string | null | undefined)[]): boolean; +} + +export class SlotController implements SlotControllerPublicAPI { public static default = Symbol('default slot') satisfies symbol as symbol; /** @deprecated use `default` */ @@ -94,16 +143,16 @@ export class SlotController implements ReactiveController { this.host.requestUpdate(); } - hostDisconnected(): void { - this.#mo.disconnect(); - } - hostUpdated(): void { if (!this.#slotMapInitialized) { this.#initSlotMap(); } } + hostDisconnected(): void { + this.#mo.disconnect(); + } + #initSlotMap() { // Loop over the properties provided by the schema for (const slotName of this.#slotNames @@ -113,9 +162,7 @@ export class SlotController implements ReactiveController { const elements = this.#getChildrenForSlot(slotId); const slot = this.#getSlotElement(slotId); const hasContent = - !isServer - && !!elements.length - || !!slot?.assignedNodes?.()?.filter(x => x.textContent?.trim()).length; + !!elements.length || !!slot?.assignedNodes?.()?.filter(x => x.textContent?.trim()).length; this.#nodes.set(slotId, { elements, name, hasContent, slot }); } this.host.requestUpdate(); @@ -123,21 +170,15 @@ export class SlotController implements ReactiveController { } #getSlotElement(slotId: string | symbol) { - if (isServer) { - return null; - } else { - const selector = + const selector = slotId === SlotController.default ? 'slot:not([name])' : `slot[name="${slotId as string}"]`; - return this.host.shadowRoot?.querySelector?.(selector) ?? null; - } + return this.host.shadowRoot?.querySelector?.(selector) ?? null; } #getChildrenForSlot( name: string | typeof SlotController.default, ): T[] { - if (isServer) { - return []; - } else if (this.#nodes.has(name)) { + if (this.#nodes.has(name)) { return (this.#nodes.get(name)!.slot?.assignedElements?.() ?? []) as T[]; } else { const children = Array.from(this.host.children) as T[]; diff --git a/core/pfe-core/package.json b/core/pfe-core/package.json index 7dbfea1e05..6974a01d6a 100644 --- a/core/pfe-core/package.json +++ b/core/pfe-core/package.json @@ -33,6 +33,7 @@ "./controllers/roving-tabindex-controller.js": "./controllers/roving-tabindex-controller.js", "./controllers/scroll-spy-controller.js": "./controllers/scroll-spy-controller.js", "./controllers/slot-controller.js": { + "node": "./controllers/slot-controller-server.js", "import": "./controllers/slot-controller.js", "default": "./controllers/slot-controller.js" }, diff --git a/docs/components/demos.11tydata.cjs b/docs/components/demos.11tydata.cjs new file mode 100644 index 0000000000..449e25f0d6 --- /dev/null +++ b/docs/components/demos.11tydata.cjs @@ -0,0 +1,10 @@ +module.exports = { + templateEngineOverride: 'njk', + permalink: '{{ demo.permalink }}', + pagination: { + data: 'demos', + alias: 'demo', + size: 1, + before: xs => xs.filter(x => x.permalink), + }, +}; diff --git a/docs/components/demos.html b/docs/components/demos.html index 0d17a73eec..9716c96064 100644 --- a/docs/components/demos.html +++ b/docs/components/demos.html @@ -1,93 +1,52 @@ ----js -{ -templateEngineOverride: 'njk', -permalink: '{{ demo.permalink }}', -pagination: { - data: 'demos', - alias: 'demo', - size: 1, - before: xs => xs.filter(x => x.permalink), -}, -preloads: [ - '@lit/reactive-element@1.0.2/development/css-tag.js', - '@lit/reactive-element@1.0.2/development/decorators/base.js', - '@lit/reactive-element@1.0.2/development/decorators/custom-element.js', - '@lit/reactive-element@1.0.2/development/decorators/event-options.js', - '@lit/reactive-element@1.0.2/development/decorators/property.js', - '@lit/reactive-element@1.0.2/development/decorators/query-all.js', - '@lit/reactive-element@1.0.2/development/decorators/query-assigned-nodes.js', - '@lit/reactive-element@1.0.2/development/decorators/query-async.js', - '@lit/reactive-element@1.0.2/development/decorators/query.js', - '@lit/reactive-element@1.0.2/development/decorators/state.js', - '@lit/reactive-element@1.0.2/development/reactive-element.js', - 'lit-element@3.0.2/development/experimental-hydrate-support.js', - 'lit-element@3.0.2/development/lit-element.js', - 'lit-html@2.0.2/_/a39ea7cf.js', - 'lit-html@2.0.2/development/async-directive.js', - 'lit-html@2.0.2/development/directive-helpers.js', - 'lit-html@2.0.2/development/directive.js', - 'lit-html@2.0.2/development/directives/guard.js', - 'lit-html@2.0.2/development/directives/if-defined.js', - 'lit-html@2.0.2/development/directives/live.js', - 'lit-html@2.0.2/development/directives/ref.js', - 'lit-html@2.0.2/development/directives/repeat.js', - 'lit-html@2.0.2/development/directives/style-map.js', - 'lit-html@2.0.2/development/directives/template-content.js', - 'lit-html@2.0.2/development/directives/unsafe-html.js', - 'lit-html@2.0.2/development/directives/unsafe-svg.js', - 'lit-html@2.0.2/development/directives/until.js', - 'lit-html@2.0.2/development/experimental-hydrate.js', - 'lit-html@2.0.2/development/lit-html.js', - 'lit-html@2.0.2/development/static.js', - 'lit@2.0.2/async-directive.js', - 'lit@2.0.2/decorators.js', - 'lit@2.0.2/decorators/query-all.js', - 'lit@2.0.2/decorators/query-assigned-nodes.js', - 'lit@2.0.2/decorators/query-async.js', - 'lit@2.0.2/decorators/query.js', - 'lit@2.0.2/directives/guard.js', - 'lit@2.0.2/directives/if-defined.js', - 'lit@2.0.2/directives/live.js', - 'lit@2.0.2/directives/ref.js', - 'lit@2.0.2/directives/repeat.js', - 'lit@2.0.2/directives/style-map.js', - 'lit@2.0.2/directives/template-content.js', - 'lit@2.0.2/directives/unsafe-html.js', - 'lit@2.0.2/directives/unsafe-svg.js', - 'lit@2.0.2/directives/until.js', - 'lit@2.0.2/experimental-hydrate-support.js', - 'lit@2.0.2/experimental-hydrate.js', - 'lit@2.0.2/html.js', - 'lit@2.0.2/index.js', - 'lit@2.0.2/polyfill-support.js', - 'lit@2.0.2/static-html.js', -] -} ---- - - - - - - - {{ demo.title or (demo.tagName) }} | PatternFly Elements - - - - {%- for path in preloads -%} - - {%- endfor -%} - - - - - - -
-
{% if demo.filePath %} - {%- include demo.filePath -%}{% endif %} -
-
- + + + + + + + + {{ demo.title or (demo.tagName) }} | PatternFly Elements + + + + + + + + + + + + +
+
{% if demo.filePath %} + {%- include demo.filePath -%}{% endif %} +
+
+ + diff --git a/elements/pf-card/demo/ssr.html b/elements/pf-card/demo/ssr.html new file mode 100644 index 0000000000..1713e50a47 --- /dev/null +++ b/elements/pf-card/demo/ssr.html @@ -0,0 +1,50 @@ + +

Header

+

Body

+ Footer +
+ + +

Body

+
+ + +

Header

+
+ + +

Header

+

Body

+
+ + +

Header

+ Footer +
+ + +

Body

+ Footer +
+ + + Footer + + + diff --git a/elements/pf-card/test/pf-card.e2e.ts b/elements/pf-card/test/pf-card.e2e.ts index 106ae16648..27ea980470 100644 --- a/elements/pf-card/test/pf-card.e2e.ts +++ b/elements/pf-card/test/pf-card.e2e.ts @@ -4,6 +4,8 @@ import { SSRPage } from '@patternfly/pfe-tools/test/playwright/SSRPage.js'; const tagName = 'pf-card'; +const html = String.raw; + test.describe(tagName, () => { test('snapshot', async ({ page }) => { const componentPage = new PfeDemoPage(page, tagName); @@ -28,8 +30,9 @@ test.describe(tagName, () => { tagName, browser, importSpecifiers: [`@patternfly/elements/${tagName}/${tagName}.js`], - demoContent: /* html */ ` - + demoContent: html` +

Header

Body Footer diff --git a/package-lock.json b/package-lock.json index b2e2e38be1..a6b6a9fde3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ }, "core/pfe-core": { "name": "@patternfly/pfe-core", - "version": "4.0.4", + "version": "4.0.5", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.10", @@ -2500,9 +2500,9 @@ } }, "node_modules/@lit-labs/ssr": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@lit-labs/ssr/-/ssr-3.3.0.tgz", - "integrity": "sha512-OGlPfWfJIC2CXQLuXXRtbWlgidryVI8VOEFUPc++Vk7gQ4aapAJwHJFi7Mi614ekebNLzhkpA/10IZy5g+nGcQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr/-/ssr-3.3.1.tgz", + "integrity": "sha512-JlF1PempxvzrGEpRFrF+Ki0MHzR3HA51SK8Zv0cFpW9p0bPW4k0FeCwrElCu371UEpXF7RcaE2wgYaE1az0XKg==", "peer": true, "dependencies": { "@lit-labs/ssr-client": "^1.1.7", @@ -16904,7 +16904,7 @@ }, "tools/create-element": { "name": "@patternfly/create-element", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "dependencies": { "case": "^1.6.3", @@ -17049,7 +17049,7 @@ }, "tools/pfe-tools": { "name": "@patternfly/pfe-tools", - "version": "4.0.1", + "version": "4.0.3", "license": "MIT", "devDependencies": { "@types/dedent": "^0.7.2", diff --git a/tools/pfe-tools/dev-server/demo.css b/tools/pfe-tools/dev-server/demo.css index fa90fd5a08..4b86971a1d 100644 --- a/tools/pfe-tools/dev-server/demo.css +++ b/tools/pfe-tools/dev-server/demo.css @@ -597,3 +597,11 @@ footer ul { top: 2rem; } } + +strong.noscript { + background-color: var(--pf-global--danger-color--100, #c9190b); + color: white; + border-radius: 4px; + padding: 4px 12px; + display: inline-block; +} diff --git a/tools/pfe-tools/dev-server/plugins/templates/index.html b/tools/pfe-tools/dev-server/plugins/templates/index.html index b910a4a0a3..df77100625 100644 --- a/tools/pfe-tools/dev-server/plugins/templates/index.html +++ b/tools/pfe-tools/dev-server/plugins/templates/index.html @@ -1,3 +1,6 @@ + + + {% set groupeddemos = demos | sort(false, false, 'permalink') | groupby('primaryElementName') %} {% if demo.title %} {% set title = demo.title + ' | ' + options.site.title %} @@ -16,8 +19,6 @@ {% set repoHost = '' %} {% endif %} {% endif %} - - @@ -28,6 +29,7 @@ {{ title }} + {% for sheet in options.site.stylesheets %} {% endfor %} @@ -43,16 +45,18 @@
- + {{ title }} + + +