Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): slot controller ssr hint attributes #2893

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clear-pugs-make.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@patternfly/pfe-core": patch
"@patternfly/elements": patch
---
Enable context protocol in SSR scenarios.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.10.0
v22.13.0
14 changes: 9 additions & 5 deletions core/pfe-core/controllers/light-dom-controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactiveController, ReactiveElement } from 'lit';
import { isServer, type ReactiveController, type ReactiveElement } from 'lit';

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

@@ -52,9 +52,13 @@ export class LightDOMController implements ReactiveController {
* Returns a boolean statement of whether or not this component contains any light DOM.
*/
hasLightDOM(): boolean {
return !!(
this.host.children.length > 0
|| (this.host.textContent ?? '').trim().length > 0
);
if (isServer) {
return false;
} else {
return !!(
this.host.children.length > 0
|| (this.host.textContent ?? '').trim().length > 0
);
}
}
}
4 changes: 2 additions & 2 deletions core/pfe-core/controllers/scroll-spy-controller.ts
Original file line number Diff line number Diff line change
@@ -44,7 +44,7 @@ export class ScrollSpyController implements ReactiveController {
#rootMargin?: string;
#threshold: number | number[];

#getRootNode: () => Node;
#getRootNode: () => Node | null;
#getHash: (el: Element) => string | null;

get #linkChildren(): Element[] {
@@ -92,7 +92,7 @@ export class ScrollSpyController implements ReactiveController {
this.#rootMargin = options.rootMargin;
this.#activeAttribute = options.activeAttribute ?? 'active';
this.#threshold = options.threshold ?? 0.85;
this.#getRootNode = () => options.rootNode ?? host.getRootNode();
this.#getRootNode = () => options.rootNode ?? host.getRootNode?.() ?? null;
this.#getHash = options?.getHash ?? ((el: Element) => el.getAttribute('href'));
}

45 changes: 45 additions & 0 deletions core/pfe-core/controllers/slot-controller-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 property = 'ssrHintHasSlotted' as const;

static attribute = 'ssr-hint-has-slotted' as const;

static anonymousAttribute = 'ssr-hint-has-default-slotted' as const;

constructor(public host: ReactiveElement, ..._: SlotControllerArgs) {
host.addController(this);
}

hostConnected?(): Promise<void>;

private fromAttribute(slots: string | null) {
return (slots ?? '')
.split(/[, ]/)
.map(x => x.trim());
}

getSlotted<T extends Element = Element>(..._: 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);
}
}
195 changes: 112 additions & 83 deletions core/pfe-core/controllers/slot-controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { isServer, type ReactiveController, type ReactiveElement } from 'lit';

import { Logger } from './logger.js';
import type { ReactiveController, ReactiveElement } from 'lit';

interface AnonymousSlot {
hasContent: boolean;
@@ -15,8 +13,10 @@ interface NamedSlot extends AnonymousSlot {

export type Slot = NamedSlot | AnonymousSlot;

export type SlotName = string | null;

export interface SlotsConfig {
slots: (string | null)[];
slots: SlotName[];
/**
* Object mapping new slot name keys to deprecated slot name values
* @example `pf-modal--header` is deprecated in favour of `header`
@@ -32,9 +32,9 @@ export interface SlotsConfig {
deprecations?: Record<string, string>;
}

function isObjectConfigSpread(
config: ([SlotsConfig] | (string | null)[]),
): config is [SlotsConfig] {
export type SlotControllerArgs = [SlotsConfig] | SlotName[];

export function isObjectSpread(config: SlotControllerArgs): config is [SlotsConfig] {
return config.length === 1 && typeof config[0] === 'object' && config[0] !== null;
}

@@ -49,66 +49,141 @@ 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<void>;

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<T extends Element = Element>(...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` */
public static anonymous: symbol = this.default;

#nodes = new Map<string | typeof SlotController.default, Slot>();

#logger: Logger;

#firstUpdated = false;
#slotMapInitialized = false;

#mo = new MutationObserver(records => this.#onMutation(records));

#slotNames: (string | null)[];
#slotNames: (string | null)[] = [];

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

constructor(public host: ReactiveElement, ...config: ([SlotsConfig] | (string | null)[])) {
this.#logger = new Logger(this.host);
#mo = new MutationObserver(this.#initSlotMap.bind(this));

constructor(public host: ReactiveElement, ...args: SlotControllerArgs) {
this.#initialize(...args);
host.addController(this);
if (!this.#slotNames.length) {
this.#slotNames = [null];
}
}

if (isObjectConfigSpread(config)) {
#initialize(...config: SlotControllerArgs) {
if (isObjectSpread(config)) {
const [{ slots, deprecations }] = config;
this.#slotNames = slots;
this.#deprecations = deprecations ?? {};
} else if (config.length >= 1) {
this.#slotNames = config;
this.#deprecations = {};
} else {
this.#slotNames = [null];
}


host.addController(this);
}

async hostConnected(): Promise<void> {
this.host.addEventListener('slotchange', this.#onSlotChange as EventListener);
this.#firstUpdated = false;
this.#mo.observe(this.host, { childList: true });
// Map the defined slots into an object that is easier to query
this.#nodes.clear();
// Loop over the properties provided by the schema
this.#slotNames.forEach(this.#initSlot);
Object.values(this.#deprecations).forEach(this.#initSlot);
this.host.requestUpdate();
this.#initSlotMap();
// insurance for framework integrations
await this.host.updateComplete;
this.host.requestUpdate();
}

hostDisconnected(): void {
this.#mo.disconnect();
}

hostUpdated(): void {
if (!this.#firstUpdated) {
this.#slotNames.forEach(this.#initSlot);
this.#firstUpdated = true;
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
.concat(Object.values(this.#deprecations))) {
const slotId = slotName || SlotController.default;
const name = slotName ?? '';
const elements = this.#getChildrenForSlot(slotId);
const slot = this.#getSlotElement(slotId);
const hasContent =
!!elements.length || !!slot?.assignedNodes?.()?.filter(x => x.textContent?.trim()).length;
this.#nodes.set(slotId, { elements, name, hasContent, slot });
}
this.host.requestUpdate();
this.#slotMapInitialized = true;
}

#getSlotElement(slotId: string | symbol) {
const selector =
slotId === SlotController.default ? 'slot:not([name])' : `slot[name="${slotId as string}"]`;
return this.host.shadowRoot?.querySelector?.<HTMLSlotElement>(selector) ?? null;
}

#getChildrenForSlot<T extends Element = Element>(
name: string | typeof SlotController.default,
): T[] {
if (this.#nodes.has(name)) {
return (this.#nodes.get(name)!.slot?.assignedElements?.() ?? []) as T[];
} else {
const children = Array.from(this.host.children) as T[];
return children.filter(isSlot(name));
}
}

/**
@@ -143,19 +218,11 @@ export class SlotController implements ReactiveController {
* @example this.hasSlotted('header');
*/
hasSlotted(...names: (string | null | undefined)[]): boolean {
if (isServer) {
return this.host
.getAttribute('ssr-hint-has-slotted')
?.split(',')
.map(name => name.trim())
.some(name => names.includes(name === 'default' ? null : name)) ?? false;
} else {
const slotNames = Array.from(names, x => x == null ? SlotController.default : x);
if (!slotNames.length) {
slotNames.push(SlotController.default);
}
return slotNames.some(x => this.#nodes.get(x)?.hasContent ?? false);
const slotNames = Array.from(names, x => x == null ? SlotController.default : x);
if (!slotNames.length) {
slotNames.push(SlotController.default);
}
return slotNames.some(x => this.#nodes.get(x)?.hasContent ?? false);
}

/**
@@ -168,42 +235,4 @@ export class SlotController implements ReactiveController {
isEmpty(...names: (string | null | undefined)[]): boolean {
return !this.hasSlotted(...names);
}

#onSlotChange = (event: Event & { target: HTMLSlotElement }) => {
const slotName = event.target.name;
this.#initSlot(slotName);
this.host.requestUpdate();
};

#onMutation = async (records: MutationRecord[]) => {
const changed = [];
for (const { addedNodes, removedNodes } of records) {
for (const node of [...addedNodes, ...removedNodes]) {
if (node instanceof HTMLElement && node.slot) {
this.#initSlot(node.slot);
changed.push(node.slot);
}
}
}
this.host.requestUpdate();
};

#getChildrenForSlot<T extends Element = Element>(
name: string | typeof SlotController.default,
): T[] {
const children = Array.from(this.host.children) as T[];
return children.filter(isSlot(name));
}

#initSlot = (slotName: string | null) => {
const name = slotName || SlotController.default;
const elements = this.#nodes.get(name)?.slot?.assignedElements?.()
?? this.#getChildrenForSlot(name);
const selector = slotName ? `slot[name="${slotName}"]` : 'slot:not([name])';
const slot = this.host.shadowRoot?.querySelector?.<HTMLSlotElement>(selector) ?? null;
const nodes = slot?.assignedNodes?.();
const hasContent = !!elements.length || !!nodes?.filter(x => x.textContent?.trim()).length;
this.#nodes.set(name, { elements, name: slotName ?? '', hasContent, slot });
this.#logger.debug(slotName, hasContent);
};
}
5 changes: 5 additions & 0 deletions core/pfe-core/functions/context.ts
Original file line number Diff line number Diff line change
@@ -7,6 +7,11 @@ function makeContextRoot() {
const root = new ContextRoot();
if (!isServer) {
root.attach(document.body);
} else {
root.attach(
// @ts-expect-error: enable context root in ssr
globalThis.litServerRoot,
);
}
return root;
}
Loading
Loading