From dbf99b5a7ea6e71250035721b7446fa335b35ebc Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 08:05:28 +0100 Subject: [PATCH 01/11] feat(design): add error states to form controls Input, textarea, checkbox, select and multi-select get the properties invalid and error. error shows a message under the control (part "error", announced with aria-live="polite") and marks the control as invalid: red border, aria-invalid and aria-describedby. The select triggers take the state from their select and keep a screen reader copy of the message, as aria-describedby can't cross shadow roots. Refs #535 Co-Authored-By: Claude Opus 5.5 --- .../src/components/checkbox/checkbox.ts | 26 ++- .../src/components/input/input.ts | 23 ++- .../components/multi-select/multi-select.ts | 33 +++- .../src/components/select/select.ts | 34 +++- .../src/components/textarea/textarea.ts | 23 ++- .../src/form-controls.test.ts | 157 ++++++++++++++++++ 6 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 packages/ecc-utils-design/src/form-controls.test.ts diff --git a/packages/ecc-utils-design/src/components/checkbox/checkbox.ts b/packages/ecc-utils-design/src/components/checkbox/checkbox.ts index 8351f498..249de286 100644 --- a/packages/ecc-utils-design/src/components/checkbox/checkbox.ts +++ b/packages/ecc-utils-design/src/components/checkbox/checkbox.ts @@ -1,5 +1,6 @@ import { LitElement, html, css } from "lit"; import { property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; import { GlobalStyles } from "../../global.js"; @@ -21,13 +22,24 @@ export class EccUtilsDesignCheckbox extends LitElement { @property({ type: Boolean, reflect: true }) disabled = false; @property({ type: String }) name = ""; @property({ type: String }) value = ""; + /** + * Whether the value is invalid; also implied by a non-empty `error`. + */ + @property({ type: Boolean, reflect: true }) invalid = false; + /** Error message shown under the checkbox, e.g. one returned by an API. */ + @property({ type: String }) error = ""; @state() private _focused = false; render() { + const isInvalid = this.invalid || !!this.error; const checkboxClasses = cn( - "peer h-4 w-4 shrink-0 rounded-[4px] border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + "peer h-4 w-4 shrink-0 rounded-[4px] border shadow focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50", + isInvalid + ? "border-destructive focus-visible:ring-destructive" + : "border-primary focus-visible:ring-ring", this.checked && "bg-primary text-primary-foreground", - this._focused && "ring-1 ring-ring" + this._focused && + (isInvalid ? "ring-1 ring-destructive" : "ring-1 ring-ring") ); const indicatorClasses = cn( @@ -42,6 +54,8 @@ export class EccUtilsDesignCheckbox extends LitElement { role="checkbox" aria-checked=${this.checked ? "true" : "false"} aria-disabled=${this.disabled ? "true" : "false"} + aria-invalid=${ifDefined(isInvalid ? "true" : undefined)} + aria-describedby=${ifDefined(this.error ? "error" : undefined)} tabindex=${this.disabled ? "-1" : "0"} @click=${this._handleClick} @keydown=${this._handleKeydown} @@ -63,6 +77,14 @@ export class EccUtilsDesignCheckbox extends LitElement { +

+ ${this.error} +

`; } diff --git a/packages/ecc-utils-design/src/components/input/input.ts b/packages/ecc-utils-design/src/components/input/input.ts index 0d282ca1..e187afec 100644 --- a/packages/ecc-utils-design/src/components/input/input.ts +++ b/packages/ecc-utils-design/src/components/input/input.ts @@ -1,5 +1,6 @@ import { LitElement, html, css } from "lit"; import { property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; import { GlobalStyles } from "../../global.js"; @@ -38,10 +39,20 @@ export class EccUtilsDesignInput extends LitElement { @property({ type: String }) placeholder = ""; @property({ type: String }) value = ""; @property({ type: Boolean, reflect: true }) disabled = false; + /** + * Whether the value is invalid; also implied by a non-empty `error`. + */ + @property({ type: Boolean, reflect: true }) invalid = false; + /** Error message shown under the field, e.g. one returned by an API. */ + @property({ type: String }) error = ""; render() { + const isInvalid = this.invalid || !!this.error; const classes = cn( - "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm" + "flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + isInvalid + ? "border-destructive focus-visible:ring-destructive" + : "border-input focus-visible:ring-ring" ); return html` @@ -52,8 +63,18 @@ export class EccUtilsDesignInput extends LitElement { placeholder=${this.placeholder} .value=${this.value} ?disabled=${this.disabled} + aria-invalid=${ifDefined(isInvalid ? "true" : undefined)} + aria-describedby=${ifDefined(this.error ? "error" : undefined)} @input=${this._handleInput} /> +

+ ${this.error} +

`; } diff --git a/packages/ecc-utils-design/src/components/multi-select/multi-select.ts b/packages/ecc-utils-design/src/components/multi-select/multi-select.ts index 2f8b228d..2101c63e 100644 --- a/packages/ecc-utils-design/src/components/multi-select/multi-select.ts +++ b/packages/ecc-utils-design/src/components/multi-select/multi-select.ts @@ -1,5 +1,6 @@ import { LitElement, html, css, PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; import { GlobalStyles } from "../../global.js"; import "../checkbox/index.js"; @@ -47,6 +48,12 @@ export class EccUtilsDesignMultiSelect extends LitElement { @property({ type: Array }) value: string[] = []; @property({ type: String }) placeholder = "Select options..."; @property({ type: Boolean }) disabled = false; + /** + * Whether the value is invalid; also implied by a non-empty `error`. + */ + @property({ type: Boolean, reflect: true }) invalid = false; + /** Error message shown under the select, e.g. one returned by an API. */ + @property({ type: String }) error = ""; @property({ type: String }) name = ""; @state() private isOpen = false; @@ -70,6 +77,12 @@ export class EccUtilsDesignMultiSelect extends LitElement { } updated(changedProperties: PropertyValues) { + if (changedProperties.has("invalid") || changedProperties.has("error")) { + // The trigger shows the invalid state of the select + this.querySelectorAll("ecc-utils-design-multi-select-trigger").forEach( + (trigger) => trigger.requestUpdate() + ); + } if (changedProperties.has("value") || changedProperties.has("isOpen")) { multiSelectState.set(this.selectId, { ...multiSelectState.get(this.selectId)!, @@ -134,6 +147,14 @@ export class EccUtilsDesignMultiSelect extends LitElement { > +

+ ${this.error} +

`; } } @@ -227,20 +248,30 @@ export class EccUtilsDesignMultiSelectTrigger extends LitElement { render() { const select = this._getParentSelect(); const placeholder = select?.placeholder || "Select options..."; + const error = select?.error ?? ""; + const isInvalid = !!select && (select.invalid || !!error); const classes = cn( - "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50", + isInvalid + ? "border-destructive focus:ring-destructive" + : "border-input focus:ring-ring", this.disabled && "opacity-50 cursor-not-allowed" ); const hasSelection = this.selectedValues.length > 0; + // The multi-select shows the error; the trigger has a copy for screen + // readers, as `aria-describedby` can't refer to another shadow root return html` + ${error}