From 2090d9ef8734d4137c2abe12aea74f29ec783dc1 Mon Sep 17 00:00:00 2001 From: Richard Herman <1429781+GeekyEggo@users.noreply.github.com> Date: Tue, 5 Nov 2024 16:46:25 +0000 Subject: [PATCH 1/2] feat: add sd-textarea (#67) * feat: add sd-textarea, fix disabled color text of text field * style: fix linting --------- Co-authored-by: Richard Herman --- src/ui/__tests__/utils.test.ts | 56 +++++++++ src/ui/components/index.ts | 1 + src/ui/components/text-area.ts | 216 ++++++++++++++++++++++++++++++++ src/ui/components/text-field.ts | 81 ++++++------ src/ui/utils.ts | 16 +++ 5 files changed, 331 insertions(+), 39 deletions(-) create mode 100644 src/ui/__tests__/utils.test.ts create mode 100644 src/ui/components/text-area.ts diff --git a/src/ui/__tests__/utils.test.ts b/src/ui/__tests__/utils.test.ts new file mode 100644 index 00000000..0af72003 --- /dev/null +++ b/src/ui/__tests__/utils.test.ts @@ -0,0 +1,56 @@ +import { cls } from "../utils"; + +/** + * Provides assertions for the {@link cls} utility function. + */ +describe("cls", () => { + test.each([ + { + name: "empty is undefined", + values: [], + expected: "", + }, + { + name: "single string", + values: ["test"], + expected: "test", + }, + { + name: "multiple strings", + values: ["foo", "bar"], + expected: "foo bar", + }, + { + name: "truthy", + // eslint-disable-next-line no-constant-binary-expression + values: [1 && "yes"], + expected: "yes", + }, + { + name: "falsy undefined", + // eslint-disable-next-line no-constant-binary-expression + values: [undefined && "no", "yes"], + expected: "yes", + }, + { + name: "falsy null", + // eslint-disable-next-line no-constant-binary-expression + values: [null && "no", "yes"], + expected: "yes", + }, + { + name: "falsy 0", + // eslint-disable-next-line no-constant-binary-expression + values: [0 && "no", "yes"], + expected: "yes", + }, + { + name: "hyphens", + // eslint-disable-next-line no-constant-binary-expression + values: [true && "container--disabled"], + expected: "container--disabled", + }, + ])("$name", ({ values, expected }) => { + expect(cls(...values)).toBe(expected); + }); +}); diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index b9cf3cf6..64a6d2b2 100644 --- a/src/ui/components/index.ts +++ b/src/ui/components/index.ts @@ -3,4 +3,5 @@ import "./label"; import "./option"; import "./radio-group"; import "./switch"; +import "./text-area"; import "./text-field"; diff --git a/src/ui/components/text-area.ts b/src/ui/components/text-area.ts new file mode 100644 index 00000000..b5769601 --- /dev/null +++ b/src/ui/components/text-area.ts @@ -0,0 +1,216 @@ +import { css, html, type HTMLTemplateResult, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { createRef, ref } from "lit/directives/ref.js"; + +import { Input } from "../mixins/input"; +import { type HTMLInputEvent } from "../utils"; + +/** + * Element that offers persisting a `string` via a text area. + */ +@customElement("sd-textarea") +export class SDTextAreaElement extends Input(LitElement) { + /** + * @inheritdoc + */ + public static styles = [ + super.styles ?? [], + css` + .container { + display: grid; + width: 224px; + } + + .container::after, + textarea, + .counter { + grid-area: 1 / 1 / 2 / 2; /* Place everything on top of one another */ + } + + /** + * Important: the container content placeholder and textarea *must* have the same styling + * so they wrap equally. + */ + .container::after, + textarea { + background-color: var(--color-surface); + border: none; + border-radius: var(--rounding-m); + color: var(--color-content-primary); + font-family: var(--typography-body-m-family); + font-size: var(--typography-body-m-size); + font-weight: var(--typography-body-m-weight); + min-height: var(--size-4xl); + outline: none; + padding: var(--space-xs); + overflow: hidden; + width: 224px; + } + + .container:has(.counter) { + &::after, + & > textarea { + min-height: var(--size-2xl); + padding-bottom: var(--space-xl); + } + } + + .container::after { + content: attr(data-content) " "; /* Extra space needed to prevent jumpy behavior */ + visibility: hidden; + word-wrap: break-word; + white-space: pre-wrap; + } + + textarea { + overflow: none; + resize: none; + + &::placeholder { + color: var(--color-content-secondary); + } + + &:disabled, + &:disabled::placeholder { + color: var(--color-content-disabled); + } + + &:focus, + &:invalid { + box-shadow: var(--highlight-box-shadow); + outline-offset: var(--highlight-outline-offset); + } + + &:focus, + &:focus:invalid { + outline: var(--highlight-outline--focus); + } + + &:invalid { + outline: var(--highlight-outline--invalid); + } + } + + .counter { + align-self: flex-end; + color: var(--color-content-secondary); + justify-self: flex-end; + padding: 0 var(--size-xs) var(--size-xs) 0; + user-select: none; + + & span { + margin: 0 var(--size-3xs); + } + } + + textarea:not(:disabled) + .counter { + cursor: text; /* Give the impression the label isn't there */ + } + `, + ]; + + /** + * Initializes a new instance of the {@link SDTextAreaElement} class. + */ + constructor() { + super(); + + this.debounceSave = true; + this.role = "textbox"; + } + + /** + * Maximum length the value can be. + */ + @property({ + attribute: "maxlength", + type: Number, + }) + public accessor maxLength: number | undefined; + + /** + * Optional placeholder text to be shown within the element. + */ + @property() + public accessor placeholder: string | undefined; + + /** + * Determines whether a value is required. + */ + @property({ type: Boolean }) + public accessor required = false; + + /** + * Determines whether the user has interacted with the text field; primarily used to mimic + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/:user-invalid `:user-invalid`} in + * conjunction with `required`. + */ + @state() + accessor #userHasInteracted = false; + + /** + * References to the container around the text element; allows the text area to expand. + */ + #containerRef = createRef(); + + /** + * @inheritdoc + */ + public override render(): TemplateResult { + return html` +
+ + ${this.#getCounter()} +
+ `; + } + + /** + * @inheritdoc + */ + protected override willUpdate(_changedProperties: Map): void { + super.willUpdate(_changedProperties); + + if (_changedProperties.has("value") && this.#containerRef.value) { + this.#containerRef.value.dataset.content = this.value; + } + } + + /** + * Gets the counter text, displayed in the lower right corner of the text area. + * @returns The counter element. + */ + #getCounter(): HTMLTemplateResult | undefined { + if (this.maxLength) { + return html` + + `; + } + + return undefined; + } +} + +declare global { + interface HTMLElementTagNameMap { + /** + * Element that offers persisting a `string` via a text area. + */ + "sd-textarea": SDTextAreaElement; + } +} diff --git a/src/ui/components/text-field.ts b/src/ui/components/text-field.ts index 05fe84ae..eea62cf2 100644 --- a/src/ui/components/text-field.ts +++ b/src/ui/components/text-field.ts @@ -26,33 +26,34 @@ export class SDTextFieldElement extends Input(LitElement) { font-size: var(--typography-body-m-size); font-weight: var(--typography-body-m-weight); height: var(--size-2xl); + min-height: var(--size-2xl); outline: none; padding: 0 var(--space-xs); - min-height: 32px; width: 224px; - } - - input::placeholder { - color: var(--color-content-secondary); - } - - input:disabled { - color: var(--color-content-disabled); - } - - input:focus, - input:invalid { - box-shadow: var(--highlight-box-shadow); - outline-offset: var(--highlight-outline-offset); - } - - input:focus, - input:focus:invalid { - outline: var(--highlight-outline--focus); - } - input:invalid { - outline: var(--highlight-outline--invalid); + &::placeholder { + color: var(--color-content-secondary); + } + + &:disabled, + &:disabled::placeholder { + color: var(--color-content-disabled); + } + + &:focus, + &:invalid { + box-shadow: var(--highlight-box-shadow); + outline-offset: var(--highlight-outline-offset); + } + + &:focus, + &:focus:invalid { + outline: var(--highlight-outline--focus); + } + + &:invalid { + outline: var(--highlight-outline--invalid); + } } `, ]; @@ -112,22 +113,24 @@ export class SDTextFieldElement extends Input(LitElement) { * @inheritdoc */ public override render(): TemplateResult { - return html` { - this.#userHasInteracted = true; - }} - @input=${(ev: HTMLInputEvent): void => { - this.value = ev.target.value; - }} - />`; + return html` + { + this.#userHasInteracted = true; + }} + @input=${(ev: HTMLInputEvent): void => { + this.value = ev.target.value; + }} + /> + `; } } diff --git a/src/ui/utils.ts b/src/ui/utils.ts index c8a88df2..fa8e9fba 100644 --- a/src/ui/utils.ts +++ b/src/ui/utils.ts @@ -1,3 +1,19 @@ +/** + * Utility function for building CSS class names from an array of truthy values. + * @param values CSS class names; when truthy, the class name will be included in the result. + * @returns The flattened CSS class name; otherwise `undefined` when no values were truthy. + */ +export function cls(...values: unknown[]): string { + let str = ""; + for (const value of values) { + if (value) { + str += str ? ` ${value}` : value; + } + } + + return str; +} + /** * Prevents the default behavior occurring when a double click occurs, preventing text-selection. * @param ev Source event. From 47a784ac02030a5c254dfcdb541de0a7f82992ab Mon Sep 17 00:00:00 2001 From: Richard Herman <1429781+GeekyEggo@users.noreply.github.com> Date: Tue, 5 Nov 2024 16:53:22 +0000 Subject: [PATCH 2/2] feat: add sd-radio (#68) * feat: enable standalone radio buttons * feat: add setter to Option.value, and improve getter * feat: allow label to be derived from innerText and improve scoping * refactor: update radio group to use dynamic slots, and remove List mixin * perf: improve rendering of radio buttons within a radio group * refactor: update radio group to use a slot * refactor: switch to sd-option to allow for CheckBoxList support * style: fix linting --------- Co-authored-by: Richard Herman --- src/ui/components/index.ts | 15 +- src/ui/components/option.ts | 50 ++++-- src/ui/components/radio-group.ts | 121 ++------------ src/ui/components/radio.ts | 201 ++++++++++++++++++++++++ src/ui/controllers/option-controller.ts | 109 +++++++++++++ src/ui/controllers/option-observer.ts | 87 ---------- src/ui/mixins/list.ts | 46 +----- 7 files changed, 377 insertions(+), 252 deletions(-) create mode 100644 src/ui/components/radio.ts create mode 100644 src/ui/controllers/option-controller.ts delete mode 100644 src/ui/controllers/option-observer.ts diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index 64a6d2b2..ed4f9766 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 23654908..68af2221 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 6d53d179..8f455b0a 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 00000000..c5a49047 --- /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