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

refactor: sd-checkbox-group and sd-radio-group to use child components #87

Merged
merged 20 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
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
107 changes: 70 additions & 37 deletions src/ui/components/checkbox-group.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,104 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";

import { Input } from "../mixins/input";
import { List } from "../mixins/list";
import { Persistable } from "../mixins/persistable";
import { SDCheckboxElement } from "./checkbox";
import { type SDOptionElement } from "./option";

/**
* TODO: Consider updating keyboard interaction to function in a similar way to sd-radio-group.
*/

/**
* Element that offers persisting an set of values, from a group of checkbox options.
*/
@customElement("sd-checkboxgroup")
export class SDCheckboxGroupElement extends List(Input<(boolean | number | string)[]>(LitElement)) {
@customElement("sd-checkbox-group")
export class SDCheckboxGroupElement extends Input(Persistable<(boolean | number | string)[]>(LitElement)) {
/**
* @inheritdoc
*/
public static styles = [
super.styles ?? [],
css`
sd-checkbox {
::slotted(sd-checkbox) {
display: flex;
}
`,
];

/**
* Gets the checkboxes managed by this group.
* @returns The checkboxes.
*/
get #checkboxes(): SDCheckboxElement[] {
return [...this.querySelectorAll("sd-checkbox")].filter((radio) => radio.closest("sd-checkbox-group") === this);
}

/**
* @inheritdoc
*/
public override render(): TemplateResult {
return html`
${repeat(
this.items,
(opt) => opt,
(opt) => {
return html`<sd-checkbox
.checked=${(this.value ?? []).findIndex((value) => value === opt.value) > -1}
.disabled=${opt.disabled}
.label=${opt.label}
@change=${(ev: Event): void => {
if (ev.target instanceof SDCheckboxElement) {
this.#handleChange(ev.target.checked, opt.value);
}
}}
/>`;
},
)}
`;
return html`<slot @slotchange=${this.#syncCheckboxes} @change=${this.#onChange}></slot>`;
}

/**
* Handles a checkbox state changing.
* @param checked Whether the checkbox is checked.
* @param value Value the checkbox represents.
* @inheritdoc
*/
#handleChange(checked: boolean, value: SDOptionElement["value"]): void {
if (value === undefined) {
return;
protected override update(changedProperties: Map<PropertyKey, unknown>): void {
super.update(changedProperties);

if (changedProperties.has("value")) {
this.#syncCheckboxes();
}
}

const values = new Set(this.value);
if (checked) {
values.add(value);
} else {
values.delete(value);
/**
* Handles the change event for children within the slot; when the target is a checkbox managed by
* this group, the value of this group is updated.
* @param ev Source event.
*/
#onChange(ev: Event): void {
// Ignore events that aren't associated with a checkbox this group manages.
if (
!(ev.target instanceof SDCheckboxElement) ||
ev.target.typedValue === undefined ||
ev.target.closest("sd-checkbox-group") !== this
) {
return;
}

this.value = Array.from(values);
// Build a set of all checked values.
const checkedValues = new Set<boolean | number | string>();
this.#checkboxes.forEach((checkbox) => {
if (checkbox.checked && checkbox.typedValue !== undefined) {
checkedValues.add(checkbox.typedValue);
}
});

this.value = Array.from(checkedValues);
}

/**
* Synchronizes the checkboxes checked state based on this group's value.
*/
#syncCheckboxes(): void {
this.#checkboxes.forEach((checkbox) => {
// Undefined values aren't persisted resulting in unexpected UX behavior.
// Warn the Maker that all checkboxes within a group should have a value.
if (checkbox.typedValue === undefined) {
console.warn(
"Checkbox group contains checkbox with an undefined value. Please specify a value on the checkbox",
checkbox,
);
}

if (!this.value || checkbox.typedValue === undefined) {
checkbox.checked = false;
return;
}

checkbox.checked = this.value.includes(checkbox.typedValue);
});
}
}

Expand All @@ -74,6 +107,6 @@ declare global {
/**
* Element that offers persisting an set of values, from a group of checkbox options.
*/
"sd-checkboxgroup": SDCheckboxGroupElement;
"sd-checkbox-group": SDCheckboxGroupElement;
}
}
49 changes: 34 additions & 15 deletions src/ui/components/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { ref } from "lit/directives/ref.js";

import { Input } from "../mixins/input";
import { Labeled } from "../mixins/labeled";
import { type HTMLInputEvent, preventDoubleClickSelection } from "../utils";
import { Option } from "../mixins/option";
import { Persistable } from "../mixins/persistable";
import { type HTMLEvent, preventDoubleClickSelection } from "../utils";

/**
* Element that offers persisting a `boolean` via a checkbox.
* Element that offers persisting a value via a checkbox.
*/
@customElement("sd-checkbox")
export class SDCheckboxElement extends Labeled(Input(LitElement)) {
export class SDCheckboxElement extends Option(Input(Persistable<boolean | number | string>(LitElement))) {
/**
* @inheritdoc
*/
Expand Down Expand Up @@ -44,14 +45,15 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) {
}

/**
* Checkbox and text
* Checkbox and slot
*/

.checkbox {
border: solid 1px var(--color-border-strong);
border-radius: var(--rounding-m);
box-sizing: border-box;
height: var(--size-m);
margin-right: var(--space-xs);
width: var(--size-m);
user-select: none;
}
Expand All @@ -60,10 +62,6 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) {
visibility: hidden;
}

.text {
margin-left: var(--space-xs);
}

/**
* States
*/
Expand Down Expand Up @@ -110,6 +108,14 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) {
* @returns `true` when the checkbox is checked; otherwise `false`.
*/
public get checked(): boolean {
if (this.value === undefined) {
return false;
}

if (this.typedValue !== undefined || typeof this.value !== "boolean") {
return this.value === this.typedValue;
}

return !!this.value;
}

Expand All @@ -118,7 +124,11 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) {
* @param value Value indicating whether the checkbox is checked.
*/
public set checked(value: boolean) {
this.value = value;
if (this.typedValue) {
this.value = value ? this.typedValue : undefined;
} else {
this.value = value;
}
}

/**
Expand All @@ -136,24 +146,25 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) {
public override render(): TemplateResult {
return html`
<label
${ref(this.inputRef)}
tabindex=${ifDefined(this.disabled ? undefined : 0)}
@mousedown=${preventDoubleClickSelection}
@keydown=${(ev: KeyboardEvent): void => {
// Toggle switch on space bar key.
if (ev.code === "Space") {
this.checked = !this.checked;
this.dispatchEvent(new Event("change", { bubbles: true })); // TODO: relocate this to Input for closed shadow roots
ev.preventDefault();
}
}}
>
<input
${ref(this.inputRef)}
type="checkbox"
.checked=${this.checked}
.disabled=${this.disabled}
@change=${(ev: HTMLInputEvent<HTMLInputElement>): void => {
@change=${(ev: HTMLEvent<HTMLInputElement>): void => {
this.checked = ev.target.checked;
this.dispatchEvent(new Event("change")); // TODO: relocate this to Input for closed shadow roots
this.dispatchEvent(new Event("change", { bubbles: true })); // TODO: relocate this to Input for closed shadow roots
}}
/>

Expand All @@ -165,16 +176,24 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) {
</svg>
</div>

${this.label && html`<span class="text">${this.label}</span>`}
<slot></slot>
</label>
`;
}

/**
* @inheritdoc
*/
protected override willUpdate(_changedProperties: Map<PropertyKey, unknown>): void {
super.willUpdate(_changedProperties);
this.ariaChecked = this.checked ? "checked" : null;
}
}

declare global {
interface HTMLElementTagNameMap {
/**
* Element that offers persisting a `boolean` via a checkbox.
* Element that offers persisting a value via a checkbox.
*/
"sd-checkbox": SDCheckboxElement;
}
Expand Down
80 changes: 5 additions & 75 deletions src/ui/components/option.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,15 @@
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { customElement } from "lit/decorators.js";

import { parseBoolean, parseNumber } from "../../common/utils";
import { Labeled } from "../mixins/labeled";
import { Input } from "../mixins/input";
import { Option } from "../mixins/option";

/**
* Non-visual element that provides information for an option.
*/
@customElement("sd-option")
export class SDOptionElement extends Labeled(LitElement) {
/**
* Private backing field for {@link SDOptionElement.value}.
*/
#value: boolean | number | string | null | undefined = null;

/**
* Determines whether the option is disabled; default `false`.
*/
@property({
reflect: true,
type: Boolean,
})
public accessor disabled: boolean = false;

/**
* Type of the value; allows for the value to be converted to a boolean or number.
*/
@property()
public accessor type: "boolean" | "number" | "string" = "string";

/**
* Untyped value, as defined by the `value` attribute; use `value` property for the typed-value.
*/
@property({ attribute: "value" })
public accessor htmlValue: string | undefined = undefined;

/**
* Value of the option.
* @returns The value.
*/
public get value(): boolean | number | string | undefined {
if (this.#value === null) {
if (this.type === "boolean") {
this.#value = parseBoolean(this.htmlValue);
} else if (this.type === "number") {
this.#value = parseNumber(this.htmlValue);
} else {
this.#value = this.htmlValue;
}
}

return this.#value;
}

/**
* Sets the value of the option, and associated type.
* @param value New value.
*/
public set value(value: boolean | number | string | undefined) {
this.type = typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "string";
this.htmlValue = value?.toString();
}

/**
* @inheritdoc
*/
protected override update(changedProperties: Map<PropertyKey, unknown>): void {
super.update(changedProperties);
this.dispatchEvent(new Event("update"));
}

/**
* @inheritdoc
*/
protected override willUpdate(_changedProperties: Map<PropertyKey, unknown>): void {
super.willUpdate(_changedProperties);

if (_changedProperties.has("type") || _changedProperties.has("value")) {
this.#value = null;
}
}
export class SDOptionElement extends Option(Input(LitElement)) {
// Empty element, used by sd-select to render options.
}

declare global {
Expand Down
Loading