Skip to content

Commit

Permalink
refactor: sd-checkbox-group and sd-radio-group to use child components (
Browse files Browse the repository at this point in the history
#87)

* refactor: improve structure of radio group and radio

- Rename sd-radiogroup to sd-radio-group.
- Update sd-radio-group to use sd-radio.
- Update sd-radio to use a shadow DOM.

* feat: update radio to use slot

* feat: update checkbox to use slot, and persist non-boolean values

* docs: add overview of available mixins

* refactor: update debounce to protected

* refactor: streamline mixins

* fix: temporarily reintroduce brand on input mixin

* fix: radio group value

* refactor: update switch to inherit from checkbox

* fix: circular dependency

* refactor: update checkbox group to use checkboxes

* refactor: improve reusability of HTMLEvent type

* fix: remove duplicate ref

* fix: focus() of checkbox

* fix: use tabIndex to remove shadow querying

* fix: linting

* fix: linting

* docs: add note of TODO

* docs: add deprecation notice

* docs: fix diagrams of mixins

---------

Co-authored-by: Richard Herman <[email protected]>
  • Loading branch information
GeekyEggo and GeekyEggo authored Feb 10, 2025
1 parent 14008df commit 0475323
Show file tree
Hide file tree
Showing 15 changed files with 592 additions and 395 deletions.
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

0 comments on commit 0475323

Please sign in to comment.