diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index 64a6d2b..ed4f976 100644 --- a/src/ui/components/index.ts +++ b/src/ui/components/index.ts @@ -1,7 +1,8 @@ -import "./field"; -import "./label"; -import "./option"; -import "./radio-group"; -import "./switch"; -import "./text-area"; -import "./text-field"; +export * from "./field"; +export * from "./label"; +export * from "./option"; +export * from "./radio"; +export * from "./radio-group"; +export * from "./switch"; +export * from "./text-area"; +export * from "./text-field"; diff --git a/src/ui/components/option.ts b/src/ui/components/option.ts index 2365490..68af222 100644 --- a/src/ui/components/option.ts +++ b/src/ui/components/option.ts @@ -11,21 +11,22 @@ export class SDOptionElement extends LitElement { /** * Private backing field for {@link SDOptionElement.value}. */ - #value: boolean | number | string | undefined; + #value: boolean | number | string | null | undefined = null; /** * Determines whether the option is disabled; default `false`. */ - @property({ type: Boolean }) + @property({ + reflect: true, + type: Boolean, + }) public accessor disabled: boolean = false; /** - * Label that represents the option; read from the `innerText` of the element. - * @returns The label. + * Label that represents the option. */ - public get label(): string { - return this.innerText; - } + @property() + public accessor label: string | undefined; /** * Type of the value; allows for the value to be converted to a boolean or number. @@ -44,9 +45,36 @@ export class SDOptionElement extends LitElement { * @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 */ @@ -54,13 +82,7 @@ export class SDOptionElement extends LitElement { super.willUpdate(_changedProperties); if (_changedProperties.has("type") || _changedProperties.has("value")) { - if (this.type === "boolean") { - this.#value = parseBoolean(this.htmlValue); - } else if (this.type === "number") { - this.#value = parseNumber(this.htmlValue); - } else { - this.#value = this.htmlValue; - } + this.#value = null; } } } diff --git a/src/ui/components/radio-group.ts b/src/ui/components/radio-group.ts index 6d53d17..8f455b0 100644 --- a/src/ui/components/radio-group.ts +++ b/src/ui/components/radio-group.ts @@ -1,11 +1,10 @@ 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 { Input } from "../mixins/input"; import { List } from "../mixins/list"; -import { preventDoubleClickSelection } from "../utils"; +import { SDRadioElement } from "./radio"; /** * Element that offers persisting a value via a list of radio options. @@ -17,92 +16,10 @@ export class SDRadioGroupElement extends List(Input(L */ public static styles = [ super.styles ?? [], + ...SDRadioElement.styles, css` - label { + sd-radio { display: flex; - align-items: center; - } - - input { - /* Hide the input, whilst still allowing focus */ - height: 0; - opacity: 0; - position: absolute; - width: 0; - } - - /** - * Radio button replacement. - */ - - .indicator { - --size: calc(var(--size-m) - calc(var(--border-width-thin) * 2)); - align-items: center; - border: var(--border-width-thin) solid var(--color-content-disabled); - border-radius: var(--rounding-full); - display: inline-flex; - height: var(--size); - justify-content: center; - margin: var(--space-xs) var(--space-xs) var(--space-xs) 0; - user-select: none; - width: var(--size); - } - - /** - * Checked. - */ - - input:checked { - & + .indicator { - background: var(--color-surface-accent); - border-color: var(--color-content-disabled); - border-radius: var(--rounding-full); - } - - & + .indicator::before { - content: ""; - background: var(--color-surface-ondark); - border-radius: var(--rounding-full); - display: block; - height: var(--size-xs); - width: var(--size-xs); - } - } - - /** - * Disabled. - */ - - label:has(input:disabled) { - color: var(--color-content-disabled); - } - - input:disabled + .indicator { - border-color: var(--color-border-subtle-disabled); - } - - /** - * Checked + disabled. - */ - - input:checked:disabled { - & + .indicator { - background-color: var(--color-surface-disabled); - } - - & + .indicator::before { - background-color: var(--color-content-disabled); - } - } - - /** - * Focus - */ - - input:focus-visible + .indicator { - box-shadow: var(--highlight-box-shadow); - outline: var(--highlight-outline--focus); - outline-offset: var(--highlight-outline-offset); } `, ]; @@ -114,25 +31,19 @@ export class SDRadioGroupElement extends List(Input(L return html` ${repeat( this.items, - ({ key }) => key, - ({ disabled, label, value }) => { - return html` - - `; + (opt) => opt, + (opt) => { + return html` { + this.value = opt.value; + }} + >${opt.innerText}`; }, )} `; diff --git a/src/ui/components/radio.ts b/src/ui/components/radio.ts new file mode 100644 index 0000000..c5a4904 --- /dev/null +++ b/src/ui/components/radio.ts @@ -0,0 +1,201 @@ +import { css, html, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { preventDoubleClickSelection } from "../utils"; +import { SDOptionElement } from "./option"; + +/** + * Element that offers an option in the form of a radio button. + */ +@customElement("sd-radio") +export class SDRadioElement extends SDOptionElement { + /** + * @inheritdoc + */ + public static styles = [ + css` + label.sd-radio-container { + display: inline-flex; + align-items: center; + + & input { + /* Hide the input, whilst still allowing focus */ + height: 0; + opacity: 0; + position: absolute; + width: 0; + } + + /** + * Radio button replacement. + */ + + & span[role="radio"] { + --size: calc(var(--size-m) - calc(var(--border-width-thin) * 2)); + align-items: center; + border: var(--border-width-thin) solid var(--color-content-disabled); + border-radius: var(--rounding-full); + display: inline-flex; + height: var(--size); + justify-content: center; + margin: var(--space-xs) var(--space-xs) var(--space-xs) 0; + user-select: none; + width: var(--size); + } + + /** + * Checked. + */ + + & input:checked { + & + span[role="radio"] { + background: var(--color-surface-accent); + border-color: var(--color-content-disabled); + border-radius: var(--rounding-full); + } + + & + span[role="radio"]::before { + content: ""; + background: var(--color-surface-ondark); + border-radius: var(--rounding-full); + display: block; + height: var(--size-xs); + position: absolute; + width: var(--size-xs); + } + } + + /** + * Disabled. + */ + + &:has(input:disabled) { + color: var(--color-content-disabled); + } + + & input:disabled + span[role="radio"] { + border-color: var(--color-border-subtle-disabled); + } + + /** + * Checked + disabled. + */ + + & input:checked:disabled { + & + span[role="radio"] { + background-color: var(--color-surface-disabled); + } + + & + span[role="radio"]::before { + background-color: var(--color-content-disabled); + } + } + + /** + * Focus + */ + + & input:focus-visible + span[role="radio"] { + box-shadow: var(--highlight-box-shadow); + outline: var(--highlight-outline--focus); + outline-offset: var(--highlight-outline-offset); + } + } + `, + ]; + + /** + * 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. + */ + @property() + public accessor name: string | undefined = undefined; + + /** + * Determines whether the radio button is checked; default `false`. + */ + @property({ + reflect: true, + type: Boolean, + }) + 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 + */ + 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