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.