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 {
>
+