diff --git a/src/ui/components/checkbox-group.ts b/src/ui/components/checkbox-group.ts index adbc821..728aaaa 100644 --- a/src/ui/components/checkbox-group.ts +++ b/src/ui/components/checkbox-group.ts @@ -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` 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``; } /** - * 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): 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(); + 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); + }); } } @@ -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; } } diff --git a/src/ui/components/checkbox.ts b/src/ui/components/checkbox.ts index e6b46ef..9d8507b 100644 --- a/src/ui/components/checkbox.ts +++ b/src/ui/components/checkbox.ts @@ -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(LitElement))) { /** * @inheritdoc */ @@ -44,7 +45,7 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) { } /** - * Checkbox and text + * Checkbox and slot */ .checkbox { @@ -52,6 +53,7 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) { 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; } @@ -60,10 +62,6 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) { visibility: hidden; } - .text { - margin-left: var(--space-xs); - } - /** * States */ @@ -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; } @@ -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; + } } /** @@ -136,24 +146,25 @@ export class SDCheckboxElement extends Labeled(Input(LitElement)) { public override render(): TemplateResult { return html` `; } + + /** + * @inheritdoc + */ + protected override willUpdate(_changedProperties: Map): 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; } diff --git a/src/ui/components/option.ts b/src/ui/components/option.ts index 5787dd5..bffa4b3 100644 --- a/src/ui/components/option.ts +++ b/src/ui/components/option.ts @@ -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): void { - super.update(changedProperties); - this.dispatchEvent(new Event("update")); - } - - /** - * @inheritdoc - */ - protected override willUpdate(_changedProperties: Map): 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 { diff --git a/src/ui/components/radio-group.ts b/src/ui/components/radio-group.ts index b450afb..7f036be 100644 --- a/src/ui/components/radio-group.ts +++ b/src/ui/components/radio-group.ts @@ -1,59 +1,162 @@ 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 { SDRadioElement } from "./radio"; /** * Element that offers persisting a `boolean`, `number`, or `string` from a list of radio options. */ -@customElement("sd-radiogroup") -export class SDRadioGroupElement extends List(Input(LitElement)) { +@customElement("sd-radio-group") +export class SDRadioGroupElement extends Input(Persistable(LitElement)) { /** * @inheritdoc */ public static styles = [ super.styles ?? [], - ...SDRadioElement.styles, css` - sd-radio { + ::slotted(sd-radio) { display: flex; } `, ]; + /** + * Gets the radios managed by this group. + * @returns The radios. + */ + get #radios(): SDRadioElement[] { + // Is there a way to query only radios that aren't in a nested radio group without filtering? + return [...this.querySelectorAll("sd-radio")].filter((radio) => radio.closest("sd-radio-group") === this); + } + /** * @inheritdoc */ public override render(): TemplateResult { - return html` - ${repeat( - this.items, - (opt) => opt, - (opt) => { - return html` { - this.value = opt.value; - }} - />`; - }, - )} - `; + return html``; + } + + /** + * @inheritdoc + */ + protected override update(changedProperties: Map): void { + super.update(changedProperties); + + if (changedProperties.has("value")) { + this.#syncRadios(); + } + } + + /** + * Determines whether the specified event was dispatched for a radio that is managed by this group. + * @param ev Source event. + * @returns `true` when the event was dispatched from a radio this group manages. + */ + #isRadioEvent(ev: Event | KeyboardEvent): ev is RadioEvent { + return ev.target instanceof SDRadioElement && ev.target.closest("sd-radio-group") === this; + } + + /** + * Handles a key down emitted from the slot containing the radios, allowing the user to changed the + * checked state of the radios using either the space bar, or arrow keys. + * @param ev Source event. + */ + #onKeyDown(ev: KeyboardEvent): void { + if (!this.#isRadioEvent(ev) || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " "].includes(ev.key)) { + return; + } + + // Prevent page shift. + ev.preventDefault(); + + // Select radio from event source when space bar was pressed. + if (ev.key === " ") { + this.value = ev.target.typedValue; + return; + } + + const radios = [...this.#radios]; + + // Determine starting point; either checked radio, or radio with focus (when none are checked). + let startIndex = radios.findIndex((radio) => radio.checked); + if (startIndex < 0) { + startIndex = radios.indexOf(ev.target); + } + + const incrementor = ev.key === "ArrowUp" || ev.key === "ArrowLeft" ? -1 : 1; + let index = startIndex + incrementor; + + while (index !== startIndex) { + // Loop round the radio indexes if we have reached the start / end. + if (index < 0) { + index = radios.length - 1; + } else if (index > radios.length - 1) { + index = 0; + } + + // Found available radio, update group value to trigger re-sync. + if (!radios[index].disabled) { + this.value = radios[index].typedValue; + radios[index].focus(); + + return; + } + + index += incrementor; + } + } + + /** + * Updates the current value of the radio group, based on the radio that was checked. + * @param ev Source event of the click. + */ + #onClick(ev: Event): void { + if (this.#isRadioEvent(ev) && !ev.target.disabled) { + this.value = ev.target.typedValue; + } + } + + /** + * Synchronizes radios within this group, setting their checked and focusable (tabindex) states. + */ + #syncRadios(): void { + let foundCheckedRadio = false; + + // Set the checked state of the radios. + this.#radios.forEach((radio) => { + radio.checked = this.value === radio.typedValue; + radio.tabIndex = radio.checked ? 0 : -1; + + foundCheckedRadio = foundCheckedRadio || radio.checked; + }); + + // When no radios are checked, make the first focusable + if (!foundCheckedRadio) { + const [first] = this.#radios; + if (first) { + first.tabIndex = 0; + } + } } } +/** + * Event that was dispatched from a radio element. + */ +type RadioEvent = Omit & { + /** + * Radio element that dispatched the event. + */ + readonly target: SDRadioElement; +}; + declare global { interface HTMLElementTagNameMap { /** * Element that offers persisting a `boolean`, `number`, or `string` from a list of radio options. */ - "sd-radiogroup": SDRadioGroupElement; + "sd-radio-group": SDRadioGroupElement; } } diff --git a/src/ui/components/radio.ts b/src/ui/components/radio.ts index 148553b..bcb8888 100644 --- a/src/ui/components/radio.ts +++ b/src/ui/components/radio.ts @@ -1,30 +1,33 @@ -import { css, html, type TemplateResult } from "lit"; +import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; +import { ref } from "lit/directives/ref.js"; +import { Input } from "../mixins/input"; +import { Option } from "../mixins/option"; import { preventDoubleClickSelection } from "../utils"; -import { SDOptionElement } from "./option"; /** - * Element that offers an option in the form of a radio button. + * Element that offers an option in the form of a radio. */ @customElement("sd-radio") -export class SDRadioElement extends SDOptionElement { +export class SDRadioElement extends Option(Input(LitElement)) { + /** + * @inheritdoc + */ + public static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }; + /** * @inheritdoc */ public static styles = [ css` - label.sd-radio-container { - display: inline-flex; + label { align-items: center; + display: inline-flex; + outline: none; & input { - /* Hide the input, whilst still allowing focus */ - height: 0; - opacity: 0; - position: absolute; - width: 0; + display: none; } /** @@ -96,7 +99,7 @@ export class SDRadioElement extends SDOptionElement { * Focus */ - & input:focus-visible + span[role="radio"] { + &:focus-visible span[role="radio"] { box-shadow: var(--highlight-box-shadow); outline: var(--highlight-outline--focus); outline-offset: var(--highlight-outline-offset); @@ -105,11 +108,6 @@ export class SDRadioElement extends SDOptionElement { `, ]; - /** - * Determines whether the shared styles have already been appended to the document. - */ - static #isStyleAppended = false; - /** * Name of the radio button group the element is associated with. */ @@ -125,35 +123,14 @@ export class SDRadioElement extends SDOptionElement { }) public accessor checked: boolean = false; - /** - * @inheritdoc - */ - public override connectedCallback(): void { - super.connectedCallback(); - if (SDRadioElement.#isStyleAppended) { - return; - } - - // As the root of the element is not a shadow DOM, we can't scope styles, so instead we add - // the styles as a