Initial check-it: light() works!!!!

This commit is contained in:
Ken Sternberg
2026-02-27 14:45:07 -08:00
parent 80e0e41e55
commit 4665146dbb
3 changed files with 119 additions and 13 deletions

View File

@@ -0,0 +1,99 @@
import { render, TemplateResult } from "lit";
import { AsyncDirective } from "lit/async-directive.js";
import { ChildPart, directive, PartInfo, PartType } from "lit/directive.js";
import { RootPart } from "lit/html.js";
export interface LightChildOptions {
// Optional alternative target for any `@event`-style handlers passed into the template. NOTE:
// this only works if the handlers do not already have a `this` bound to them, so only ordinary
// functions and methods will respond to this parameter; arrow function class fields and bound
// functions will use the `this` to which they were bound.
host?: Element;
slotName?: string;
}
class LightChildDirective extends AsyncDirective {
#slotName: string | null = null;
#slot: HTMLSlotElement | null = null;
#rootPart: RootPart | null = null;
#sentinel: Comment | null = null;
constructor(partInfo: PartInfo) {
super(partInfo);
if (partInfo.type !== PartType.CHILD) {
throw new Error("The `light()` directive can only be use in child position");
}
}
render(_template?: TemplateResult, options?: LightChildOptions) {
this.#slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`;
// The lack of `html` here is deliberate. This code only runs in SSR mode. We don't use in in
// the `update()` phase.
return `<slot name="${this.#slotName}"></slot>`;
}
#withSplicedSlotname(template: TemplateResult): TemplateResult {
const raw = template.strings.raw;
const strings = [...(template.strings as unknown as string[])];
const values = [...template.values];
strings.splice(1, 0, " slot=");
values.splice(1, 0, this.#slotName);
// @ts-expect-error This is esoteric coercion.
(strings as TemplateStringsArray).raw = raw;
return {
...template,
strings: Object.freeze(strings as unknown as TemplateStringsArray),
values: values,
};
}
update(part: ChildPart, [template, options = {}]: [TemplateResult, LightChildOptions]) {
if (!/^\s*</.test(template.strings[0])) {
throw new Error("The `light()` directive can only take an ElementNode, not a TextNode");
}
this.#slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`;
// This places a comment in the LightDom that belongs to this directive. Comments are not
// part of the DOM tree for the purposes of CSS, so it will be possible to style this child
// directly without a wrapper.
if (!this.#sentinel) {
const host = (part.options?.host ||
(part.parentNode.getRootNode() as ShadowRoot).host) as Element;
this.#sentinel = document.createComment("");
host.appendChild(this.#sentinel);
}
const slottedTemplate = template.strings.find((s) => /slot=["']$/.test(s))
? template
: this.#withSplicedSlotname(template);
if (!this.#sentinel.parentNode) {
throw new Error("Could not assign sentinel to element.");
}
const renderOptions = Object.fromEntries(
Object.entries(options).filter(([key]) => ["host"].includes(key)),
);
this.#rootPart = render(slottedTemplate, this.#sentinel.parentNode as HTMLElement, {
renderBefore: this.#sentinel,
...renderOptions,
});
return (this.#slot ??= Object.assign(document.createElement("slot"), {
name: this.#slotName,
}));
}
disconnected() {
this.#rootPart?.setConnected(false);
}
reconnected() {
this.#rootPart?.setConnected(true);
}
}
export const light = directive(LightChildDirective);

View File

@@ -15,6 +15,7 @@ import { AKSessionAuthenticatedEvent } from "#common/ws/events";
import { WebsocketClient } from "#common/ws/WebSocketClient";
import { listen } from "#elements/decorators/listen";
import { light } from "#elements/directives/light";
import { Interface } from "#elements/Interface";
import { showAPIErrorMessage } from "#elements/messages/MessageContainer";
import { WithBrandConfig } from "#elements/mixins/branding";
@@ -60,6 +61,7 @@ import { html as staticHTML, unsafeStatic } from "lit/static-html.js";
import PFBackgroundImage from "@patternfly/patternfly/components/BackgroundImage/background-image.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
@@ -95,6 +97,7 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
PFLogin,
PFDrawer,
PFButton,
PFFormControl,
PFTitle,
PFList,
PFBackgroundImage,
@@ -366,7 +369,7 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
.exhaustive(),
);
return staticHTML`<${unsafeStatic(tag)} ${props}></${unsafeStatic(tag)}>`;
return light(staticHTML`<${unsafeStatic(tag)} ${props}></${unsafeStatic(tag)}>`);
}
protected renderChallengeError(error: unknown): SlottedTemplateResult {

View File

@@ -4,6 +4,8 @@ import "#flow/components/ak-flow-card";
import "#flow/components/ak-flow-password-input";
import "#flow/stages/captcha/CaptchaStage";
import { light } from "#elements/directives/light";
import { AKFormErrors } from "#components/ak-field-errors";
import { AKLabel } from "#components/ak-label";
@@ -245,18 +247,20 @@ export class IdentificationStage extends BaseStage<
username: EmptyString,
autocomplete: string,
) {
return html`<input
id=${id}
type=${type}
name="uidField"
placeholder=${label}
autofocus
autocomplete=${autocomplete}
spellcheck="false"
class="pf-c-form-control"
value=${username ?? ""}
required
/>`;
return light(
html`<input
id=${id}
type=${type}
name="uidField"
placeholder=${label}
autofocus
autocomplete=${autocomplete}
spellcheck="false"
class="pf-c-form-control"
value=${username ?? ""}
required
/>`,
);
}
protected renderPasswordFields(challenge: IdentificationChallenge) {