Skip to content

Commit

Permalink
refactor: update radio group to use dynamic slots, and remove List mixin
Browse files Browse the repository at this point in the history
  • Loading branch information
GeekyEggo committed Nov 3, 2024
1 parent fc96ebb commit 7fb40a2
Show file tree
Hide file tree
Showing 6 changed files with 168 additions and 196 deletions.
2 changes: 1 addition & 1 deletion src/ui/components/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export class SDOptionElement extends LitElement {
/**
* Private backing field for {@link SDOptionElement.value}.
*/
#value: boolean | number | string | undefined | null = null;
#value: boolean | number | string | null | undefined = null;

/**
* Determines whether the option is disabled; default `false`.
Expand Down
51 changes: 32 additions & 19 deletions src/ui/components/radio-group.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,64 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { repeat } from "lit/directives/repeat.js";

import { MutationController } from "../controllers/mutation-controller";
import { Input } from "../mixins/input";
import { List } from "../mixins/list";
import { SDRadioElement } from "./radio";

/**
* Element that offers persisting a value via a list of radio options.
*/
@customElement("sd-radiogroup")
export class SDRadioGroupElement extends List(Input<boolean | number | string>(LitElement)) {
export class SDRadioGroupElement extends Input<boolean | number | string>(LitElement) {
/**
* @inheritdoc
*/
public static styles = [
super.styles ?? [],
...SDRadioElement.styles,
css`
sd-radio {
::slotted(sd-radio) {
display: flex;
}
`,
];

/**
* Mutation controller that tracks the child radio buttons.
*/
readonly #childObserver = new MutationController<SDRadioElement>(
this,
(node: Node): node is SDRadioElement => node instanceof SDRadioElement,
);

/**
* Handles a child radio button changing.
* @param ev Source event.
*/
readonly #handleChildChanged = (ev: Event): void => {
if (ev.target instanceof SDRadioElement) {
this.value = ev.target.value;
} else {
console.warn("Unrecognized change event in SDRadioGroupElement", ev);
}
};

/**
* @inheritdoc
*/
public override render(): TemplateResult {
return html`
${repeat(
this.items,
({ key }) => key,
({ disabled, label, value }) => {
return html`
<sd-radio
name="radio"
value=${ifDefined(value)}
.checked=${this.value === value}
.disabled=${disabled}
.label=${label}
@change=${(): void => {
this.value = value;
}}
></sd-radio>
`;
this.#childObserver.nodes,
(opt) => opt,
(opt, i) => {
opt.addEventListener("change", this.#handleChildChanged);
opt.checked = this.value === opt.value;
opt.name = "radio";
opt.slot = i.toString();
return html`<slot name=${i}></slot>`;
},
)}
`;
Expand Down
2 changes: 1 addition & 1 deletion src/ui/components/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export class SDRadioElement extends SDOptionElement {
/**
* @inheritdoc
*/
protected override createRenderRoot(): HTMLElement | DocumentFragment {
protected override createRenderRoot(): DocumentFragment | HTMLElement {
// Shadow root has to be open to allow for joining named radio buttons.
this.#fallbackLabel = this.innerText;
this.innerHTML = "";
Expand Down
134 changes: 134 additions & 0 deletions src/ui/controllers/mutation-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { ReactiveController, ReactiveControllerHost } from "lit";

/**
* Controller for tracking filtered mutations within a host.
*/
export class MutationController<T extends Node> implements ReactiveController {
/**
* Current nodes within the host that match the filter.
*/
public readonly nodes: T[] = [];

/**
* Host this controller is attached to.
*/
readonly #host: HTMLElement & ReactiveControllerHost;

/**
* Underlying mutation observer monitoring changes to the shadow DOM.
*/
readonly #observer: MutationObserver;

/**
* Predicate that determines if the node is tracked by the host.
*/
readonly #predicate: (node: Node) => node is T;

/**
* Initializes a new instance of the {@link MutationController} class.
* @param host Host to attached to.
* @param predicate Function that determines if a node should be tracked.
*/
constructor(host: HTMLElement & ReactiveControllerHost, predicate: (node: Node) => node is T) {
this.#host = host;
this.#host.addController(this);
this.#predicate = predicate;

this.#observer = new MutationObserver((mutations: MutationRecord[]) => this.#onMutation(mutations));
this.#setNodesByQueryingHost();
}

/**
* @inheritdoc
*/
public hostConnected(): void {
this.#observer.observe(this.#host, {
childList: true,
});
}

/**
* @inheritdoc
*/
public hostDisconnected(): void {
this.#observer.disconnect();
}

/**
* Evaluates a mutation, and determines whether a rebuild is required, or whether the collection of nodes changed.
* @param mutations Mutations that occurred.
* @returns Result of the evaluation.
*/
#evaluateMutation(mutations: MutationRecord[]): MutationEvaluationResult {
const result: MutationEvaluationResult = {
rebuildNodes: false,
requestUpdate: false,
};

for (const { addedNodes, removedNodes } of mutations) {
// When a new node was added, simply rebuild the collection of nodes to maintain correct ordering.
for (const added of addedNodes) {
if (this.#predicate(added)) {
result.rebuildNodes = true;
result.requestUpdate = true;

return result;
}
}

// When a node was removed, remove it from the collection, but continue the evaluation.
for (const removed of removedNodes) {
if (this.#predicate(removed)) {
const index = this.nodes.indexOf(removed);
if (index !== -1) {
this.nodes.splice(index, 1);
result.requestUpdate = true;
}
}
}
}

return result;
}

/**
* Handles a mutation on the host's shadow DOM, updating the tracked collection of nodes.
* @param mutations Mutations that occurred.
*/
#onMutation(mutations: MutationRecord[]): void {
const { rebuildNodes, requestUpdate } = this.#evaluateMutation(mutations);

if (rebuildNodes) {
this.#setNodesByQueryingHost();
this.#host.requestUpdate();
} else if (requestUpdate) {
this.#host.requestUpdate();
}
}

/**
* Sets the nodes associated with this instance by querying the host.
*/
#setNodesByQueryingHost(): void {
this.#host.querySelectorAll(":scope > *").forEach((node) => {
if (this.#predicate(node)) {
this.nodes.push(node);
}
});
}
}

/**
* Result of evaluating a mutation of nodes.
*/
type MutationEvaluationResult = {
/**
* Determines whether the collection of nodes should be rebuilt from the host.
*/
rebuildNodes: boolean;

/**
* Determines whether an update is requested.
*/
requestUpdate: boolean;
};
97 changes: 0 additions & 97 deletions src/ui/controllers/option-observer.ts

This file was deleted.

Loading

0 comments on commit 7fb40a2

Please sign in to comment.