diff --git a/.changeset/design-field-errors.md b/.changeset/design-field-errors.md new file mode 100644 index 00000000..550a7918 --- /dev/null +++ b/.changeset/design-field-errors.md @@ -0,0 +1,9 @@ +--- +"@elixir-cloud/design": minor +--- + +Show field errors returned by APIs. + +- Input, textarea, select and multi-select get the properties `invalid` and `error`: `error` shows a message under the control (`aria-invalid`, `aria-describedby`, announced with `aria-live`), `invalid` marks the control as invalid without a message. +- The checkbox gets `invalid` only; show its message elsewhere. +- New utilities read RFC 9457 problem details (`readProblemDetails()`, `parseProblemDetails()`) and map their field errors to a component's fields (`mapFieldErrors()`). Import them from the new subpath `@elixir-cloud/design/utils`, which registers no components; the package root exports them too. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f29a5e3..869ebb45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,10 @@ jobs: - name: Build run: npm run build + # The design package's `./utils` subpath must load without components + - name: Check design utils entry + run: node scripts/check-design-utils.mjs + # With coverage (in Chromium, in `coverage/lcov.info` of each package # with tests; see `web-test.runner-config.mjs`) for the uploaded run - name: Run tests diff --git a/apps/documentation/pages/docs/contributing.mdx b/apps/documentation/pages/docs/contributing.mdx index 9129da88..53be10af 100644 --- a/apps/documentation/pages/docs/contributing.mdx +++ b/apps/documentation/pages/docs/contributing.mdx @@ -10,9 +10,10 @@ This page covers what is specific to this repository. Keep the checks green: `CI passed` sums up the PR title, linting, tests (Node.js 22 and 24) and the other CI jobs. Run `npm run lint`, -`npm run build` and `npm test` (and -`node --test scripts/release.test.js` after changes to -`scripts/release.js`) locally before you push. CI runs the tests with +`npm run build` and `npm test` locally before you push. After changes to +`scripts/release.js`, also run `node --test scripts/release.test.js`; after +changes to `scripts/utils.js` or `scripts/build.js`, also run +`node --test scripts/utils.test.js`. CI runs the tests with coverage (`npm run test -- -- --coverage`, which passes `--coverage` through npm and turbo to the test runner; Chromium only; each package's report is in its `coverage/` folder) and reports it to @@ -20,6 +21,16 @@ report is in its `coverage/` folder) and reports it to coverage of the packages with tests must not drop by more than 1 % (`codecov.yml`). +`scripts/build.js` builds every package from the entries that +`getBuildEntries()` in `scripts/utils.js` lists: `src/index.ts`, the +components, providers and React wrappers, and `src/utils/index.ts` if a +package has one. That file is the entry of the package's `./utils` subpath +(add it to `exports` and, for TypeScript's `node` module resolution, to +`typesVersions` in the package's `package.json`): utilities that +apps can import without registering the components, so it must not import +them. For the design package, CI checks this after the build +(`node scripts/check-design-utils.mjs`). + Changesets are not used; versions are set in the version bump pull request of each release (see "Releases"). diff --git a/apps/documentation/pages/docs/design/_meta.json b/apps/documentation/pages/docs/design/_meta.json index 22cfc8b2..bc577822 100644 --- a/apps/documentation/pages/docs/design/_meta.json +++ b/apps/documentation/pages/docs/design/_meta.json @@ -1,5 +1,6 @@ { "introduction": "Introduction", "architecture": "Architecture", - "components": "Components" + "components": "Components", + "field-errors": "Field errors" } diff --git a/apps/documentation/pages/docs/design/components/checkbox.mdx b/apps/documentation/pages/docs/design/components/checkbox.mdx index 35594170..494fd435 100644 --- a/apps/documentation/pages/docs/design/components/checkbox.mdx +++ b/apps/documentation/pages/docs/design/components/checkbox.mdx @@ -127,6 +127,7 @@ This example shows a checkbox in a disabled state. | `disabled` | `boolean` | `false` | When `true`, disables the checkbox. | | `name` | `string` | `""` | The name of the checkbox for form submission. | | `value` | `string` | `""` | The value of the checkbox for form submission. | +| `invalid` | `boolean` | `false` | When `true`, marks the checkbox as invalid, e.g. after an API rejected its value. | ### `checked` Controls the checked state of the checkbox. @@ -192,6 +193,22 @@ The value attribute, used when submitting the checkbox as part of a form. +### `invalid` +Marks the checkbox as invalid, e.g. when an API rejected its value (see [Field errors](/docs/design/field-errors)): it gets a red border and `aria-invalid="true"`. Clear it when the user changes the value or submits again. Unlike the other form controls, the checkbox has no `error` property: a message under the box wouldn't wrap next to its label, e.g. when the checkbox is at the end of a row. Render the message yourself, e.g. next to the label or above the form: with `mapFieldErrors()`, set `invalid` when `fieldErrors` has a message for the checkbox's field key, and show that message (it is not part of `generalError`, which holds only the errors that match no field). + + + + ```tsx copy + + ``` + + + ```html copy + + ``` + + + ## Events | Event Name | React Event Name | Detail Type | Description | diff --git a/apps/documentation/pages/docs/design/components/input.mdx b/apps/documentation/pages/docs/design/components/input.mdx index 45ef5f3b..c0d809f3 100644 --- a/apps/documentation/pages/docs/design/components/input.mdx +++ b/apps/documentation/pages/docs/design/components/input.mdx @@ -116,6 +116,8 @@ This example shows an input field associated with a label. | `placeholder` | `string` | `""` | Placeholder text for the input. | | `value` | `string` | `""` | The current value of the input. | | `disabled` | `boolean` | `false` | When `true`, disables the input. | +| `invalid` | `boolean` | `false` | When `true`, marks the input as invalid (also implied by `error`). | +| `error` | `string` | `""` | Error message shown under the input, e.g. from an API. | ### `type` Specifies the input type, determining the data format and keyboard layout on mobile devices. @@ -181,6 +183,22 @@ When `true`, the input is non-interactive and visually de-emphasized. +### `invalid` and `error` +`error` shows a message under the input, e.g. one that an API returned for this field (see [Field errors](/docs/design/field-errors)), and marks the input as invalid: it gets a red border, `aria-invalid="true"` and `aria-describedby` pointing to the message, and screen readers announce the message (`aria-live="polite"`). `invalid` marks the input as invalid without a message, e.g. when the message is shown elsewhere. Clear both when the user changes the value or submits again. The message has the part `error` for styling (`ecc-utils-design-input::part(error)`). + + + + ```tsx copy + + ``` + + + ```html copy + + ``` + + + ## Events | Event Name | React Event Name | Detail Type | Description | diff --git a/apps/documentation/pages/docs/design/components/multi-select.mdx b/apps/documentation/pages/docs/design/components/multi-select.mdx index e957fefa..36d332c5 100644 --- a/apps/documentation/pages/docs/design/components/multi-select.mdx +++ b/apps/documentation/pages/docs/design/components/multi-select.mdx @@ -191,6 +191,8 @@ This example shows a multi-select with some values pre-selected. | `placeholder` | `string` | `"Select options..."` | Placeholder text when no options are selected. | | `disabled` | `boolean` | `false` | When `true`, disables the multi-select. | | `name` | `string` | `""` | The name attribute for form submissions. | +| `invalid` | `boolean` | `false` | When `true`, marks the multi-select as invalid (also implied by `error`). | +| `error` | `string` | `""` | Error message shown under the multi-select, e.g. from an API. | #### `value` Controls the selected values in the multi-select. This should be an array of strings matching the values of selected items. @@ -274,6 +276,26 @@ The name attribute used when submitting the multi-select as part of a form. +#### `invalid` and `error` +`error` shows a message under the multi-select, e.g. one that an API returned for this field (see [Field errors](/docs/design/field-errors)), and marks the multi-select as invalid: its trigger gets a red border, `aria-invalid="true"` and `aria-describedby` pointing to the message, and screen readers announce the message (`aria-live="polite"`). `invalid` marks the multi-select as invalid without a message, e.g. when the message is shown elsewhere. Clear both when the user changes the value or submits again. The message has the part `error` for styling (`ecc-utils-design-multi-select::part(error)`). + + + + ```tsx copy + + ... + + ``` + + + ```html copy + + ... + + ``` + + + ### `EccUtilsDesignMultiSelectTrigger` | Property | Type | Default | Description | diff --git a/apps/documentation/pages/docs/design/components/select.mdx b/apps/documentation/pages/docs/design/components/select.mdx index 6a49435c..5d8a181a 100644 --- a/apps/documentation/pages/docs/design/components/select.mdx +++ b/apps/documentation/pages/docs/design/components/select.mdx @@ -219,6 +219,8 @@ The `Select` is built by combining these components: |-------------|-----------|---------|-------------------------------------------| | `value` | `string` | `""` | The value of the currently selected option. | | `disabled` | `boolean` | `false` | When `true`, disables the select. | +| `invalid` | `boolean` | `false` | When `true`, marks the select as invalid (also implied by `error`). | +| `error` | `string` | `""` | Error message shown under the select, e.g. from an API. | #### `value` Controls the currently selected option. Should match the `value` of one of the SelectItem components. @@ -260,6 +262,26 @@ When `true`, disables the entire select, preventing user interaction. +#### `invalid` and `error` +`error` shows a message under the select, e.g. one that an API returned for this field (see [Field errors](/docs/design/field-errors)), and marks the select as invalid: its trigger gets a red border, `aria-invalid="true"` and `aria-describedby` pointing to the message, and screen readers announce the message (`aria-live="polite"`). `invalid` marks the select as invalid without a message, e.g. when the message is shown elsewhere. Clear both when the user changes the value or submits again. The message has the part `error` for styling (`ecc-utils-design-select::part(error)`). + + + + ```tsx copy + + ... + + ``` + + + ```html copy + + ... + + ``` + + + ### `EccUtilsDesignSelectItem` | Property | Type | Default | Description | diff --git a/apps/documentation/pages/docs/design/components/textarea.mdx b/apps/documentation/pages/docs/design/components/textarea.mdx index 0f028c03..b9141a02 100644 --- a/apps/documentation/pages/docs/design/components/textarea.mdx +++ b/apps/documentation/pages/docs/design/components/textarea.mdx @@ -115,6 +115,8 @@ This example shows a textarea with a corresponding label. | `placeholder` | `string` | `""` | Placeholder text for the textarea. | | `value` | `string` | `""` | The current value of the textarea. | | `disabled` | `boolean` | `false` | When `true`, disables the textarea. | +| `invalid` | `boolean` | `false` | When `true`, marks the textarea as invalid (also implied by `error`). | +| `error` | `string` | `""` | Error message shown under the textarea, e.g. from an API. | | `rows` | `number` | `3` | The number of visible text lines. | | `cols` | `number` | `undefined` | The visible width of the text control. | @@ -198,6 +200,22 @@ Specifies the visible width of the textarea in average character widths. +### `invalid` and `error` +`error` shows a message under the textarea, e.g. one that an API returned for this field (see [Field errors](/docs/design/field-errors)), and marks the textarea as invalid: it gets a red border, `aria-invalid="true"` and `aria-describedby` pointing to the message, and screen readers announce the message (`aria-live="polite"`). `invalid` marks the textarea as invalid without a message, e.g. when the message is shown elsewhere. Clear both when the user changes the value or submits again. The message has the part `error` for styling (`ecc-utils-design-textarea::part(error)`). + + + + ```tsx copy + + ``` + + + ```html copy + + ``` + + + ## Events | Event Name | React Event Name | Detail Type | Description | diff --git a/apps/documentation/pages/docs/design/field-errors.mdx b/apps/documentation/pages/docs/design/field-errors.mdx new file mode 100644 index 00000000..a00538ff --- /dev/null +++ b/apps/documentation/pages/docs/design/field-errors.mdx @@ -0,0 +1,170 @@ +import { Callout } from 'nextra/components'; + +# Field errors + +When an API rejects a request, components that submit data can show its +messages at the fields they belong to, instead of one general error. The +design package has the two parts for this: + +- The form controls ([input](/docs/design/components/input), + [textarea](/docs/design/components/textarea), + [select](/docs/design/components/select) and + [multi-select](/docs/design/components/multi-select)) have the properties + `error` and `invalid`: `error` shows a message under the control and marks it + as invalid (red border, `aria-invalid`, `aria-describedby`, announced to + screen readers), `invalid` marks it as invalid without a message. The + [checkbox](/docs/design/components/checkbox) has only `invalid`, as a + message under the box wouldn't wrap next to its label: give the checkbox a + field key, set `invalid` when `fieldErrors` has a message for it, and render + that message yourself, e.g. next to the label or above the form. +- Utilities that read the errors of a response in the standard format, + problem details ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457), + `application/problem+json`), and map them to the component's fields. + +Components don't need validation rules of their own: the API keeps the final +say, and the components show what it says. + +## Response format + +Field errors are read in the form of the example of +[RFC 9457, section 3](https://www.rfc-editor.org/rfc/rfc9457#section-3): an +extension member `errors`, each entry with a readable `detail` and the +location of the field, a JSON Pointer +([RFC 6901](https://www.rfc-editor.org/rfc/rfc6901)) to the field in the +request body (`pointer`, e.g. `#/organization/url`) or the name of a request +parameter (`parameter`, e.g. `limit`): + +```json +{ + "type": "https://foca.readthedocs.io/en/latest/problems/invalid-request/", + "title": "Bad Request", + "status": 400, + "detail": "Invalid fields: #/name (too-long); #/organization/url (invalid-format)", + "code": "invalid-request", + "errors": [ + { "detail": "Must be at most 3 characters long.", "pointer": "#/name", "code": "too-long" }, + { "detail": "Must be a valid URI.", "pointer": "#/organization/url", "code": "invalid-format" } + ] +} +``` + +Pointers in URI fragment form (`#/organization/url`, percent-encoded) and plain +pointers (`/organization/url`) are both accepted. Nothing is specific to one +server: the example is the response of an API built with +[FOCA](https://github.com/elixir-cloud-aai/foca), which reports invalid +fields in this format (see FOCA's +[invalid request](https://foca.readthedocs.io/en/latest/problems/invalid-request/) +problem type), and any other API can respond the same way. + +## Utilities + +Import them from `@elixir-cloud/design/utils`, which loads only the +utilities. The package root (`@elixir-cloud/design`) exports them too, but +importing it registers every component (including the code editor): + +```ts +import { mapFieldErrors, readProblemDetails } from '@elixir-cloud/design/utils'; +``` + +| Function | Description | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `readProblemDetails(response)` | Reads the problem details of a `fetch` response (JSON bodies only; consumes the body). Returns `undefined` if the body isn't problem details. | +| `parseProblemDetails(body)` | Parses a JSON body that was read already. Returns the standard members (`type`, `title`, `status`, `detail`, `instance`), the field errors (`errors`, each with `detail`, `pointer` or `parameter`, `code` and the entry as received in `raw`) and the other members in `extensions` (e.g. `code`), or `undefined` if the body isn't problem details. | +| `mapFieldErrors(problem, fields, fallback?)` | Maps the field errors to the component's fields. `problem` is the result of `readProblemDetails()` or `parseProblemDetails()` (`undefined` for responses without problem details). `fields` maps the component's keys for its fields to their locations: a pointer (`"/organization/url"` or `"#/organization/url"`) or `{ parameter: "limit" }`. Returns `fieldErrors` (a message per key), `unmatched` (the errors that match no field), `generalError` and `problem`. | +| `normalizePointer(pointer)` | Converts a pointer in URI fragment form to a plain pointer. | + +A field error belongs to the field with the same pointer or parameter or, +failing that, to the closest field that contains it (e.g. an error at +`/tags/0` to a field at `/tags`). Several messages for the same field are +joined. Errors are not passed down: an error at an object (e.g. +`#/organization`, "Is required.") doesn't mark the fields inside it (e.g. +`/organization/url`); give the object a field key of its own to show it +somewhere, or it becomes part of the general error. + +`generalError` is the message to show above or below the form, or `undefined` +if every error is shown at a field: + +- errors that match no field, each with its location (e.g. + `organization/url: Must be a valid URI.`); +- for problem details without field errors, their `detail`, or else their + `title`; +- for responses that aren't problem details, the `fallback` + (default: `The request failed.`). + +## Example + +A component that registers a service shows the errors of a rejected request +at its fields: + +```ts copy filename="service-form.ts" +import { html, LitElement } from 'lit'; +import { state } from 'lit/decorators.js'; +import { mapFieldErrors, readProblemDetails } from '@elixir-cloud/design/utils'; +import '@elixir-cloud/design/components/input/index.js'; + +// The component's fields and their locations in the request body +const FIELDS = { + name: '/name', + url: '/url', + organizationUrl: '/organization/url', +}; + +export class ServiceForm extends LitElement { + @state() private fieldErrors: Record = {}; + @state() private generalError = ''; + + // Only the latest submission shows its errors + private submission = 0; + + private async submit(body: unknown) { + this.submission += 1; + const submission = this.submission; + this.fieldErrors = {}; + this.generalError = ''; + let response: Response; + try { + response = await fetch('/ga4gh/registry/v1/services', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch { + if (submission === this.submission) { + this.generalError = 'The registry could not be reached.'; + } + return; + } + if (response.ok) return; + const problem = await readProblemDetails(response); + if (submission !== this.submission) return; + const mapped = mapFieldErrors( + problem, + FIELDS, + `The service could not be registered (${response.status}).`, + ); + this.fieldErrors = mapped.fieldErrors; + this.generalError = mapped.generalError ?? ''; + } + + render() { + return html` + + + + ${this.generalError ? html`

${this.generalError}

` : ''} + `; + } +} +``` + + + Components that call their API through a provider can read the problem details there, e.g. with + `readProblemDetails()`, and pass them on, e.g. in the error they throw, so that the component can + call `mapFieldErrors()`. + diff --git a/packages/ecc-utils-design/package.json b/packages/ecc-utils-design/package.json index 4de95ebd..cdc6374c 100644 --- a/packages/ecc-utils-design/package.json +++ b/packages/ecc-utils-design/package.json @@ -16,9 +16,20 @@ "./components/*": "./dist/components/*", "./react": "./dist/react/index.js", "./react/*": "./dist/react/*", + "./utils": { + "types": "./dist/utils/index.d.ts", + "import": "./dist/utils/index.js" + }, "./events": "./dist/events/index.js", "./events/*": "./dist/events/*" }, + "typesVersions": { + "*": { + "utils": [ + "./dist/utils/index.d.ts" + ] + } + }, "repository": { "type": "git", "url": "https://github.com/elixir-cloud-aai/cloud-components.git" diff --git a/packages/ecc-utils-design/src/components/checkbox/checkbox.ts b/packages/ecc-utils-design/src/components/checkbox/checkbox.ts index 8351f498..0c2d4ad4 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,23 @@ 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, e.g. after an API rejected it. Unlike the + * other form controls, the checkbox has no `error` message: it wouldn't + * wrap next to the box's label, so show the message elsewhere. + */ + @property({ type: Boolean, reflect: true }) invalid = false; @state() private _focused = false; render() { 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", + this.invalid + ? "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 && + (this.invalid ? "ring-1 ring-destructive" : "ring-1 ring-ring") ); const indicatorClasses = cn( @@ -42,6 +53,7 @@ export class EccUtilsDesignCheckbox extends LitElement { role="checkbox" aria-checked=${this.checked ? "true" : "false"} aria-disabled=${this.disabled ? "true" : "false"} + aria-invalid=${ifDefined(this.invalid ? "true" : undefined)} tabindex=${this.disabled ? "-1" : "0"} @click=${this._handleClick} @keydown=${this._handleKeydown} diff --git a/packages/ecc-utils-design/src/components/input/input.ts b/packages/ecc-utils-design/src/components/input/input.ts index 5922c3b8..b2b3bc02 100644 --- a/packages/ecc-utils-design/src/components/input/input.ts +++ b/packages/ecc-utils-design/src/components/input/input.ts @@ -41,10 +41,20 @@ export class EccUtilsDesignInput extends LitElement { @property({ type: String }) label?: string; @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` @@ -56,8 +66,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..aab77a5c 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 hidden copy for + // `aria-describedby`, which can't refer to another shadow root return html` +