From 14008df29a9e5230edbdf76255c768b07f460d23 Mon Sep 17 00:00:00 2001 From: Richard Herman <1429781+GeekyEggo@users.noreply.github.com> Date: Mon, 18 Nov 2024 17:08:01 +0000 Subject: [PATCH] feat: add `` and `` (#71) * feat: add sd-checkbox * refactor: remove label of switch in favour of textContext * feat: add support for sd-label * refactor: align checkbox and switch rendering * feat: improve margin management * fix: top/bottom margin * feat: add sd-checkboxgroup * docs: minor re-wording * refactor: update option-type elements to all utilize textContent as their label * refactor: update option-type elements to all utilize textContent as their label --------- Co-authored-by: Richard Herman --- src/ui/components/checkbox-group.ts | 79 ++++++++++++ src/ui/components/checkbox.ts | 181 ++++++++++++++++++++++++++++ src/ui/components/index.ts | 2 + src/ui/components/option.ts | 9 +- src/ui/components/radio-group.ts | 7 +- src/ui/components/radio.ts | 11 +- src/ui/components/switch.ts | 111 +++++++++-------- src/ui/mixins/labeled.ts | 63 ++++++++++ 8 files changed, 391 insertions(+), 72 deletions(-) create mode 100644 src/ui/components/checkbox-group.ts create mode 100644 src/ui/components/checkbox.ts create mode 100644 src/ui/mixins/labeled.ts diff --git a/src/ui/components/checkbox-group.ts b/src/ui/components/checkbox-group.ts new file mode 100644 index 0000000..adbc821 --- /dev/null +++ b/src/ui/components/checkbox-group.ts @@ -0,0 +1,79 @@ +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 { SDCheckboxElement } from "./checkbox"; +import { type SDOptionElement } from "./option"; + +/** + * 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)) { + /** + * @inheritdoc + */ + public static styles = [ + super.styles ?? [], + css` + sd-checkbox { + display: flex; + } + `, + ]; + + /** + * @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); + } + }} + />`; + }, + )} + `; + } + + /** + * Handles a checkbox state changing. + * @param checked Whether the checkbox is checked. + * @param value Value the checkbox represents. + */ + #handleChange(checked: boolean, value: SDOptionElement["value"]): void { + if (value === undefined) { + return; + } + + const values = new Set(this.value); + if (checked) { + values.add(value); + } else { + values.delete(value); + } + + this.value = Array.from(values); + } +} + +declare global { + interface HTMLElementTagNameMap { + /** + * Element that offers persisting an set of values, from a group of checkbox options. + */ + "sd-checkboxgroup": SDCheckboxGroupElement; + } +} diff --git a/src/ui/components/checkbox.ts b/src/ui/components/checkbox.ts new file mode 100644 index 0000000..e6b46ef --- /dev/null +++ b/src/ui/components/checkbox.ts @@ -0,0 +1,181 @@ +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement } 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 { Labeled } from "../mixins/labeled"; +import { type HTMLInputEvent, preventDoubleClickSelection } from "../utils"; + +/** + * Element that offers persisting a `boolean` via a checkbox. + */ +@customElement("sd-checkbox") +export class SDCheckboxElement extends Labeled(Input(LitElement)) { + /** + * @inheritdoc + */ + public static styles = [ + super.styles ?? [], + css` + /** + * Container + */ + + :host { + display: inline-flex; + } + + label { + align-items: center; + display: inline-flex; + margin: var(--space-xs) 0; + outline: none; + + &:focus-visible .checkbox { + box-shadow: var(--highlight-box-shadow); + outline: var(--highlight-outline--focus); + outline-offset: var(--highlight-outline-offset); + } + + &:has(input:disabled) { + color: var(--color-content-disabled); + } + } + + /** + * Checkbox and text + */ + + .checkbox { + border: solid 1px var(--color-border-strong); + border-radius: var(--rounding-m); + box-sizing: border-box; + height: var(--size-m); + width: var(--size-m); + user-select: none; + } + + .checkbox > svg { + visibility: hidden; + } + + .text { + margin-left: var(--space-xs); + } + + /** + * States + */ + + input { + display: none; + + /* Checked */ + &:checked + .checkbox { + border-width: 0; + background-color: var(--color-surface-accent); + color: var(--color-content-ondark); + + & > svg { + visibility: visible; + } + } + + /* Disabled */ + &:disabled { + & + .checkbox { + border-color: var(--color-border-subtle-disabled); + } + + &:checked + .checkbox { + background-color: var(--color-surface-disabled); + color: var(--color-content-disabled); + } + } + } + `, + ]; + + /** + * Initializes a new instance of the {@link SDCheckboxElement} class. + */ + constructor() { + super(); + this.role = "checkbox"; + } + + /** + * Gets the checked state. + * @returns `true` when the checkbox is checked; otherwise `false`. + */ + public get checked(): boolean { + return !!this.value; + } + + /** + * Sets the checked state. + * @param value Value indicating whether the checkbox is checked. + */ + public set checked(value: boolean) { + this.value = value; + } + + /** + * @inheritdoc + */ + public override click(): void { + if (!this.disabled) { + this.checked = !this.checked; + } + } + + /** + * @inheritdoc + */ + public override render(): TemplateResult { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + /** + * Element that offers persisting a `boolean` via a checkbox. + */ + "sd-checkbox": SDCheckboxElement; + } +} diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index f86dd9d..043b045 100644 --- a/src/ui/components/index.ts +++ b/src/ui/components/index.ts @@ -1,4 +1,6 @@ export * from "./button"; +export * from "./checkbox"; +export * from "./checkbox-group"; export * from "./divider"; export * from "./field"; export * from "./label"; diff --git a/src/ui/components/option.ts b/src/ui/components/option.ts index 68af222..5787dd5 100644 --- a/src/ui/components/option.ts +++ b/src/ui/components/option.ts @@ -2,12 +2,13 @@ import { LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; import { parseBoolean, parseNumber } from "../../common/utils"; +import { Labeled } from "../mixins/labeled"; /** * Non-visual element that provides information for an option. */ @customElement("sd-option") -export class SDOptionElement extends LitElement { +export class SDOptionElement extends Labeled(LitElement) { /** * Private backing field for {@link SDOptionElement.value}. */ @@ -22,12 +23,6 @@ export class SDOptionElement extends LitElement { }) public accessor disabled: boolean = false; - /** - * Label that represents the option. - */ - @property() - public accessor label: string | undefined; - /** * Type of the value; allows for the value to be converted to a boolean or number. */ diff --git a/src/ui/components/radio-group.ts b/src/ui/components/radio-group.ts index 8f455b0..b450afb 100644 --- a/src/ui/components/radio-group.ts +++ b/src/ui/components/radio-group.ts @@ -7,7 +7,7 @@ import { List } from "../mixins/list"; import { SDRadioElement } from "./radio"; /** - * Element that offers persisting a value via a list of radio options. + * 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)) { @@ -42,8 +42,7 @@ export class SDRadioGroupElement extends List(Input(L @change=${(): void => { this.value = opt.value; }} - >${opt.innerText}`; + />`; }, )} `; @@ -53,7 +52,7 @@ export class SDRadioGroupElement extends List(Input(L declare global { interface HTMLElementTagNameMap { /** - * Element that offers persisting a value via a list of radio options. + * Element that offers persisting a `boolean`, `number`, or `string` from a list of radio options. */ "sd-radiogroup": SDRadioGroupElement; } diff --git a/src/ui/components/radio.ts b/src/ui/components/radio.ts index c5a4904..148553b 100644 --- a/src/ui/components/radio.ts +++ b/src/ui/components/radio.ts @@ -125,11 +125,6 @@ export class SDRadioElement extends SDOptionElement { }) public accessor checked: boolean = false; - /** - * Fallback label, derived from the original inner text of this element when creating the render root. - */ - #fallbackLabel: string | undefined; - /** * @inheritdoc */ @@ -173,8 +168,10 @@ export class SDRadioElement extends SDOptionElement { .checked=${this.checked} .disabled=${this.disabled} /> + - ${this.label ?? this.#fallbackLabel} + + ${this.label} `; } @@ -184,9 +181,7 @@ export class SDRadioElement extends SDOptionElement { */ protected override createRenderRoot(): DocumentFragment | HTMLElement { // Shadow root has to be open to allow for joining named radio buttons. - this.#fallbackLabel = this.innerText; this.innerHTML = ""; - return this; } } diff --git a/src/ui/components/switch.ts b/src/ui/components/switch.ts index fc3d490..6cad8a5 100644 --- a/src/ui/components/switch.ts +++ b/src/ui/components/switch.ts @@ -4,13 +4,14 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { ref } from "lit/directives/ref.js"; import { Input } from "../mixins/input"; -import type { HTMLInputEvent } from "../utils"; +import { Labeled } from "../mixins/labeled"; +import { type HTMLInputEvent, preventDoubleClickSelection } from "../utils"; /** * Element that offers persisting a `boolean` via a toggle switch. */ @customElement("sd-switch") -export class SDSwitchElement extends Input(LitElement) { +export class SDSwitchElement extends Labeled(Input(LitElement)) { /** * @inheritdoc */ @@ -18,33 +19,35 @@ export class SDSwitchElement extends Input(LitElement) { super.styles ?? [], css` /** - * Containers + * Container */ - sd-label { - outline: none; + :host { + display: inline-flex; } - .container { + label { align-items: center; display: inline-flex; - } + margin: var(--space-xs) 0; + outline: none; - input { - display: none; + &:focus-visible .track { + box-shadow: var(--highlight-box-shadow); + outline: var(--highlight-outline--focus); + outline-offset: var(--highlight-outline-offset); + } } /** - * Track + * Track, thumb, and text */ .track { align-items: center; background: var(--color-surface-strong); border-radius: var(--rounding-full); - cursor: pointer; display: inline-flex; - margin: var(--space-xs) var(--space-xs) var(--space-xs) 0; padding: 0px var(--space-3xs); transition: 0.2; height: var(--size-m); @@ -52,25 +55,6 @@ export class SDSwitchElement extends Input(LitElement) { user-select: none; } - sd-label[aria-disabled="false"]:has(input:checked) .track { - background: var(--color-surface-accent); - } - - sd-label[aria-disabled="true"] .track { - background: var(--color-surface-disabled); - cursor: default; - } - - sd-label:focus-visible .track { - box-shadow: var(--highlight-box-shadow); - outline: var(--highlight-outline--focus); - outline-offset: var(--highlight-outline-offset); - } - - /** - * Thumb - */ - .thumb { background: var(--color-content-primary); border-radius: var(--rounding-full); @@ -80,16 +64,44 @@ export class SDSwitchElement extends Input(LitElement) { width: var(--size-s); } - sd-label:has(input:checked) .thumb { - transform: translateX(100%); + .text { + margin-left: var(--space-xs); } - sd-label[aria-disabled="false"] .thumb { - background: var(--color-surface-ondark); - } + /** + * States + */ - sd-label[aria-disabled="true"] .thumb { - background: var(--color-surface-strong); + input { + display: none; + + /* Checked */ + &:checked { + & + .track .thumb { + transform: translateX(100%); + } + + &:not(:disabled) { + & + .track { + background: var(--color-surface-accent); + } + + & + .track .thumb { + background: var(--color-surface-ondark); + } + } + } + + /* Disabled */ + &:disabled { + & + .track { + background: var(--color-surface-disabled); + } + + & + .track .thumb { + background: var(--color-surface-strong); + } + } } `, ]; @@ -99,7 +111,6 @@ export class SDSwitchElement extends Input(LitElement) { */ constructor() { super(); - this.role = "checkbox"; } @@ -123,12 +134,6 @@ export class SDSwitchElement extends Input(LitElement) { this.value = value; } - /** - * Label of the switch. - */ - @property() - public accessor label: string | undefined; - /** * @inheritdoc */ @@ -143,9 +148,9 @@ export class SDSwitchElement extends Input(LitElement) { */ public override render(): TemplateResult { return html` - { // Toggle switch on space bar key. if (ev.code === "Space") { @@ -164,13 +169,13 @@ export class SDSwitchElement extends Input(LitElement) { this.isOn = ev.target.checked; }} /> -
-
-
-
- ${this.label} + +
+
- + + ${this.label && html`${this.label}`} + `; } diff --git a/src/ui/mixins/labeled.ts b/src/ui/mixins/labeled.ts new file mode 100644 index 0000000..4318204 --- /dev/null +++ b/src/ui/mixins/labeled.ts @@ -0,0 +1,63 @@ +import { LitElement } from "lit"; +import { property } from "lit/decorators.js"; + +import type { Constructor } from "../../common/utils"; + +/** + * Labeled mixin that provides a reactive `label` property, attached to the `textContent`. + * @param superClass Class the mixin extends. + * @returns Labeled mixin class. + */ +export const Labeled = = typeof LitElement>( + superClass: TBase, +): Constructor & TBase => { + /** + * Labeled mixin that provides a reactive `label` property, attached to the `textContent`. + */ + class LabeledMixin extends superClass { + /** + * Observer for monitoring the `textContent` changing. + */ + #textContentObserver = new MutationObserver((): void => { + this.label = this.textContent?.trim(); + }); + + /** + * @inheritdoc + */ + @property({ attribute: false }) + public accessor label: string | undefined = this.textContent?.trim(); + + /** + * @inheritdoc + */ + public override connectedCallback(): void { + super.connectedCallback(); + + this.#textContentObserver.observe(this, { + childList: true, + subtree: true, + }); + } + + /** + * @inheritdoc + */ + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#textContentObserver.disconnect(); + } + } + + return LabeledMixin as unknown as Constructor & TBase; +}; + +/** + * Labeled mixin that provides a reactive `label` property, attached to the `textContent`. + */ +export declare class SDLabeledElement extends LitElement { + /** + * Label of the element. + */ + public label: string | undefined; +}