diff --git a/web/src/components/form/ArrayField.test.tsx b/web/src/components/form/ArrayField.test.tsx
new file mode 100644
index 0000000000..bd0af1589a
--- /dev/null
+++ b/web/src/components/form/ArrayField.test.tsx
@@ -0,0 +1,446 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen } from "@testing-library/react";
+import { installerRender } from "~/test-utils";
+import { useAppForm } from "~/hooks/form";
+
+type TestFormProps = {
+ defaultValues?: string[];
+ validateOnChange?: (v: string) => string | undefined;
+ validateOnSubmit?: (v: string) => string | undefined;
+ skipDuplicates?: boolean;
+ helperText?: string;
+ /** Simulates a TanStack Form field-level error returned by onSubmitAsync. */
+ fieldError?: string;
+};
+
+function TestForm({
+ defaultValues = [],
+ validateOnChange,
+ validateOnSubmit,
+ skipDuplicates = false,
+ helperText,
+ fieldError,
+}: TestFormProps) {
+ const form = useAppForm({
+ defaultValues: { tags: defaultValues },
+ validators: {
+ onSubmitAsync: fieldError ? async () => ({ fields: { tags: fieldError } }) : undefined,
+ },
+ });
+
+ return (
+
+
+ {(field) => (
+
+ )}
+
+
+
+
+
+ );
+}
+
+describe("ArrayField", () => {
+ it("renders label and usage", () => {
+ installerRender();
+ screen.getByText("Tags");
+ screen.getByText(/Enter or Tab to add/);
+ });
+
+ it("renders given existing values", () => {
+ installerRender();
+ screen.getByText("alpha");
+ screen.getByText("beta");
+ });
+
+ describe("adding entries", () => {
+ it("adds a value on Enter", async () => {
+ const { user } = installerRender();
+ await user.type(screen.getByRole("textbox", { name: "Tags" }), "gamma");
+ await user.keyboard("{Enter}");
+ screen.getByText("gamma");
+ });
+
+ it("adds a value on Tab", async () => {
+ const { user } = installerRender();
+ await user.type(screen.getByRole("textbox", { name: "Tags" }), "delta");
+ await user.tab();
+ screen.getByText("delta");
+ });
+
+ it("commits the draft on blur", async () => {
+ const { user } = installerRender();
+ await user.type(screen.getByRole("textbox", { name: "Tags" }), "epsilon");
+ await user.click(screen.getByRole("button", { name: "Other" }));
+ screen.getByText("epsilon");
+ });
+
+ it("does not commit an empty draft on blur", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.click(screen.getByRole("button", { name: "Other" }));
+ expect(screen.getAllByRole("option")).toHaveLength(1);
+ });
+ });
+
+ describe("removing entries", () => {
+ it("removes a value via the entry removal button", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("button", { name: "Remove alpha" }));
+ expect(screen.queryByText("alpha")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("editing entries", () => {
+ it("moves an entry to the draft input when clicked", async () => {
+ const { user } = installerRender();
+ const input = screen.getByRole("textbox", { name: "Tags" });
+ await user.click(screen.getByRole("option", { name: "alpha" }));
+ expect(screen.queryByRole("option", { name: "alpha" })).not.toBeInTheDocument();
+ expect(input).toHaveValue("alpha");
+ });
+
+ it("puts the active entry back in the input on Enter", async () => {
+ const { user } = installerRender();
+ const input = screen.getByRole("textbox", { name: "Tags" });
+ await user.click(input);
+ await user.keyboard("{ArrowLeft}{Enter}");
+ expect(screen.queryByRole("option", { name: "alpha" })).not.toBeInTheDocument();
+ expect(input).toHaveValue("alpha");
+ });
+
+ it("puts the active entry back in the input on Space", async () => {
+ const { user } = installerRender();
+ const input = screen.getByRole("textbox", { name: "Tags" });
+ await user.click(input);
+ await user.keyboard("{ArrowLeft}");
+ await user.keyboard(" ");
+ expect(screen.queryByRole("option", { name: "alpha" })).not.toBeInTheDocument();
+ expect(input).toHaveValue("alpha");
+ });
+ });
+
+ describe("validateOnChange", () => {
+ const validateOnChange = (v: string) =>
+ v.startsWith("x") ? "Must not start with x" : undefined;
+
+ it("marks an invalid entry immediately after adding", () => {
+ installerRender();
+ expect(screen.getByRole("option", { name: /invalid/ })).toBeInTheDocument();
+ });
+
+ it("shows the error block when there are invalid entries", () => {
+ installerRender();
+ screen.getByText(/Select entries to edit or remove them/);
+ });
+
+ it("removes all invalid entries when clicking the clear button", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /remove all invalid entries/i }));
+ screen.getByText("ok");
+ expect(screen.queryByText("xbad")).not.toBeInTheDocument();
+ });
+
+ it("opens an invalid entry for editing on Delete instead of removing it", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ const input = screen.getByRole("textbox", { name: "Tags" });
+ await user.click(input);
+ await user.keyboard("{ArrowLeft}{Delete}");
+ expect(screen.queryByRole("option", { name: "xbad" })).not.toBeInTheDocument();
+ expect(input).toHaveValue("xbad");
+ });
+ });
+
+ describe("validateOnSubmit", () => {
+ const validateOnSubmit = (v: string) =>
+ v.startsWith("x") ? "Must not start with x" : undefined;
+
+ it("does not mark entries as invalid before submitting", () => {
+ installerRender();
+ expect(screen.queryByRole("option", { name: /invalid/ })).not.toBeInTheDocument();
+ });
+
+ it("marks entries as invalid after a failed submit", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ await screen.findByRole("option", { name: /invalid/ });
+ });
+
+ it("shows the error block after a failed submit", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ await screen.findByText(/Select entries to edit or remove them/);
+ });
+
+ it("removes all invalid entries after a failed submit", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ await user.click(await screen.findByRole("button", { name: /remove all invalid entries/i }));
+ screen.getByText("ok");
+ expect(screen.queryByText("xbad")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("helperText", () => {
+ it("does not show helper text when there are no errors", () => {
+ installerRender();
+ expect(screen.queryByText(/Some hint/)).not.toBeInTheDocument();
+ });
+
+ it("shows helper text alongside the error block when there are invalid entries", () => {
+ const validateOnChange = (v: string) => (v === "bad" ? "Invalid" : undefined);
+ installerRender(
+ ,
+ );
+ screen.getByText(/Some hint/);
+ });
+ });
+
+ describe("skipDuplicates", () => {
+ it("does not add an entry already in the list", async () => {
+ const { user } = installerRender();
+ await user.type(screen.getByRole("textbox", { name: "Tags" }), "alpha");
+ await user.keyboard("{Enter}");
+ expect(screen.getAllByText("alpha")).toHaveLength(1);
+ });
+
+ it("skips duplicates when pasting", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.paste("alpha beta");
+ expect(screen.getAllByRole("option")).toHaveLength(2);
+ screen.getByText("beta");
+ });
+ });
+
+ describe("paste", () => {
+ it("adds multiple entries from a paste", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.paste("alpha beta gamma");
+ screen.getByText("alpha");
+ screen.getByText("beta");
+ screen.getByText("gamma");
+ });
+
+ it("does not intercept a single-token paste", async () => {
+ const { user } = installerRender();
+ const input = screen.getByRole("textbox", { name: "Tags" });
+ await user.click(input);
+ await user.paste("alpha");
+ expect(input).toHaveValue("alpha");
+ expect(screen.queryByRole("option", { name: "alpha" })).not.toBeInTheDocument();
+ });
+ });
+
+ describe("keyboard navigation", () => {
+ it("does not activate navigation on Backspace when the draft is not empty", async () => {
+ const { user } = installerRender();
+ await user.type(screen.getByRole("textbox", { name: "Tags" }), "partial");
+ await user.keyboard("{Backspace}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ });
+
+ it("does not activate navigation on ArrowLeft when the draft is not empty", async () => {
+ const { user } = installerRender();
+ await user.type(screen.getByRole("textbox", { name: "Tags" }), "partial");
+ await user.keyboard("{ArrowLeft}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ });
+
+ it("activates the last entry on Backspace when the draft is empty", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{Backspace}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
+ it("activates the last entry on ArrowLeft when the draft is empty", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{ArrowLeft}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
+ it("removes the active entry on Delete", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{ArrowLeft}{Delete}");
+ expect(screen.queryByText("alpha")).not.toBeInTheDocument();
+ });
+
+ it("removes the active entry on Backspace", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{ArrowLeft}{Backspace}");
+ expect(screen.queryByText("alpha")).not.toBeInTheDocument();
+ });
+
+ it("moves to the previous entry on ArrowLeft", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{Backspace}");
+ expect(screen.getByRole("option", { name: "beta" })).toHaveAttribute("aria-selected", "true");
+ await user.keyboard("{ArrowLeft}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
+ it("moves to the previous entry on ArrowUp", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{Backspace}{ArrowUp}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
+ it("moves to the next entry on ArrowRight", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{Backspace}{ArrowLeft}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await user.keyboard("{ArrowRight}");
+ expect(screen.getByRole("option", { name: "beta" })).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("moves to the next entry on ArrowDown", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{Backspace}{ArrowLeft}{ArrowDown}");
+ expect(screen.getByRole("option", { name: "beta" })).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("deactivates navigation on ArrowRight at the last entry", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{Backspace}{ArrowRight}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ });
+
+ it("jumps to the first entry on Home", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{Backspace}{Home}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
+ it("jumps to the last entry on End", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{ArrowLeft}{Home}{End}");
+ expect(screen.getByRole("option", { name: "beta" })).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("deactivates navigation on Escape", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{Backspace}{Escape}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ });
+
+ it("deactivates navigation on Tab", async () => {
+ const { user } = installerRender();
+ await user.click(screen.getByRole("textbox", { name: "Tags" }));
+ await user.keyboard("{Backspace}");
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await user.tab();
+ expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ });
+ });
+});
diff --git a/web/src/components/form/ArrayField.tsx b/web/src/components/form/ArrayField.tsx
new file mode 100644
index 0000000000..528bbaf9de
--- /dev/null
+++ b/web/src/components/form/ArrayField.tsx
@@ -0,0 +1,636 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React, { useState, useRef } from "react";
+import { fork, sift, unique } from "radashi";
+import { sprintf } from "sprintf-js";
+import {
+ FormGroup,
+ Label,
+ TextInputGroup,
+ TextInputGroupMain,
+ FormHelperText,
+ HelperText,
+ HelperTextItem,
+ Button,
+} from "@patternfly/react-core";
+import { useFieldContext } from "~/hooks/form-contexts";
+import { _ } from "~/i18n";
+
+/**
+ * Keys owned by the entry navigation handler when an entry is active.
+ *
+ * Space is included alongside Enter to match the ARIA listbox pattern
+ * (https://www.w3.org/WAI/ARIA/apg/patterns/listbox/), where both keys
+ * activate the focused option. Any key outside this set exits navigation
+ * without consuming the event, so Tab moves focus away and regular characters
+ * land in the draft input normally.
+ */
+const NAVIGATION_KEYS = new Set([
+ " ",
+ "ArrowLeft",
+ "ArrowUp",
+ "ArrowRight",
+ "ArrowDown",
+ "Home",
+ "End",
+ "Enter",
+ "Delete",
+ "Backspace",
+]);
+
+/** Applies `normalize` to `value` if provided; otherwise returns `value` unchanged. */
+function normalizeValue(value: string, normalize?: (v: string) => string): string {
+ return normalize ? normalize(value) : value;
+}
+
+/**
+ * Trims, normalizes, and optionally validates a raw draft string.
+ *
+ * Returns `null` for empty or whitespace-only input so callers can skip
+ * adding an empty entry. Otherwise returns the normalized value and any
+ * validation error.
+ */
+function processDraft(
+ raw: string,
+ normalize?: (v: string) => string,
+ validate?: (v: string) => string | undefined,
+): { normalized: string; error: string | undefined } | null {
+ const trimmed = raw.trim();
+ if (!trimmed) return null;
+ const normalized = normalizeValue(trimmed, normalize);
+ return { normalized, error: validate?.(normalized) };
+}
+
+/** Builds the screen-reader announcement for a multi-entry paste. Pure function. */
+function pasteAnnouncement(
+ added: number,
+ skipped: number,
+ valid: string[],
+ invalid: string[],
+): string {
+ if (added === 0) return sprintf(_("%d duplicates skipped."), skipped);
+ if (skipped === 0)
+ return invalid.length === 0
+ ? sprintf(_("%d entries added."), valid.length)
+ : sprintf(_("%d entries added, %d invalid."), added, invalid.length);
+ return invalid.length === 0
+ ? sprintf(_("%d entries added, %d duplicates skipped."), valid.length, skipped)
+ : sprintf(
+ _("%d entries added, %d invalid, %d duplicates skipped."),
+ added,
+ invalid.length,
+ skipped,
+ );
+}
+
+/** Splits pasted text on whitespace and commas, returning non-empty entries. */
+function parsePasteEntries(text: string): string[] {
+ return sift(text.split(/[\s,]+/).map((t) => t.trim()));
+}
+
+/**
+ * Returns entries from `normalized` not already in `existing`,
+ * also deduplicating within `normalized` itself.
+ *
+ * Prepends `existing` before deduplication so `unique` sees existing entries
+ * first and drops any later occurrence of the same value. Slicing off the
+ * first `existing.length` elements then yields only the genuinely new entries.
+ */
+function filterNew(existing: string[], normalized: string[]): string[] {
+ return unique([...existing, ...normalized]).slice(existing.length);
+}
+
+type EntryProps = {
+ /** Raw stored value, not necessarily the display form. */
+ item: string;
+ index: number;
+ /** Whether this entry is currently focused during keyboard navigation. */
+ isActive: boolean;
+ /** Validation error message; undefined means the entry is valid. */
+ error?: string;
+ /** Formats the raw value for display and aria labels. */
+ toLabel: (v: string) => string;
+ onEdit: (index: number) => void;
+ onRemove: (index: number) => void;
+ /** Returns a stable DOM id used for aria-activedescendant. */
+ valueId: (index: number) => string;
+};
+
+/**
+ * A single committed entry, rendered as a listbox option.
+ *
+ * Both the visual color and the aria-label carry validation state, so
+ * sighted and assistive-technology users receive the same information.
+ */
+function Entry({ item, index, isActive, error, toLabel, onEdit, onRemove, valueId }: EntryProps) {
+ // preventDefault keeps focus on the input; the edit moves the value back to draft.
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ onEdit(index);
+ };
+
+ // preventDefault avoids blur; stopPropagation prevents the span from triggering edit.
+ const handleCloseMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ const handleRemove = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onRemove(index);
+ };
+
+ const labelText = toLabel(item);
+
+ return (
+
+
+
+ );
+}
+
+type MultiValueFieldProps = {
+ /**
+ * Label rendered by PatternFly's FormGroup.
+ *
+ * Can be a plain string or a ReactNode (e.g. `LabelText` with a suffix).
+ * When a ReactNode is passed, also provide `inputAriaLabel` so assistive
+ * technologies receive a plain-text version of the label.
+ */
+ label: React.ReactNode;
+
+ /**
+ * Plain-text label for assistive technologies.
+ *
+ * Used as the accessible name of the text input and as the base for the
+ * listbox accessible name. Inferred from `label` when it is a plain string;
+ * required when `label` is a ReactNode.
+ */
+ inputAriaLabel?: string;
+
+ /**
+ * Per-entry validator that runs on every commit.
+ *
+ * Invalid entries are marked and announced immediately, before the form is
+ * submitted. Use for format checks that are always safe to run eagerly,
+ * such as address or URL format.
+ */
+ validateOnChange?: (value: string) => string | undefined;
+
+ /**
+ * Per-entry validator that runs only after the first failed form submit.
+ *
+ * Stays silent until TanStack Form sets a field-level error on this field.
+ * Use for checks that would be distracting before the user attempts to
+ * submit, such as cross-field or server-validated rules.
+ */
+ validateOnSubmit?: (value: string) => string | undefined;
+
+ /**
+ * Normalizes user input before it is committed.
+ *
+ * Runs on every added entry, including pasted ones. Use for trimming,
+ * casing, or any formatting rule applied at commit time.
+ */
+ normalize?: (value: string) => string;
+
+ /**
+ * Formats a stored value for display and accessible labels.
+ *
+ * When omitted, the stored value is used as-is. Useful when the stored
+ * form differs from the human-readable form, e.g. a code vs. a name.
+ */
+ displayValue?: (value: string) => string;
+
+ /**
+ * Converts a stored value back to a draft string for editing.
+ *
+ * Called when an entry is moved into the text input for modification.
+ * When omitted, the stored value is used as-is.
+ */
+ toDraft?: (value: string) => string;
+
+ /**
+ * When true, skips entries that are already in the list.
+ *
+ * Applies to both single commits and multi-token pastes.
+ */
+ skipDuplicates?: boolean;
+
+ /**
+ * Additional guidance shown alongside the error messages.
+ *
+ * Only rendered when the field has errors. Use to explain the expected
+ * format or other context that helps the user fix invalid entries.
+ */
+ helperText?: React.ReactNode;
+
+ /** Disables the text input and all entry interactions. */
+ isDisabled?: boolean;
+};
+
+/**
+ * A form field for entering and managing a list of string values.
+ *
+ * Users type in a text input and commit entries one at a time via Enter,
+ * Tab, or blur. Each committed entry appears as a labeled token inside the
+ * field. Entries can be edited by clicking them or selecting them with the
+ * keyboard, and removed individually via the close button or Backspace/Delete.
+ * A clear-invalid button removes all invalid entries at once when any are present.
+ *
+ * Keyboard navigation follows the ARIA listbox pattern: ArrowLeft/Right/Up/Down
+ * move between entries, Enter and Space activate the focused one, Home/End jump
+ * to the first or last, and Escape exits navigation. Pasting a whitespace- or
+ * comma-separated string adds all tokens at once.
+ *
+ * Two validation modes are available:
+ * - `validateOnChange`: runs on every commit; marks invalid entries right away.
+ * - `validateOnSubmit`: runs only after TanStack Form sets a field-level error
+ * (i.e. after the first failed submit attempt); stays silent until then.
+ *
+ * Both sighted and assistive-technology users receive equivalent feedback:
+ * invalid entries carry an aria-label describing the error; add, edit, and
+ * remove actions are announced via a live region.
+ *
+ * Must be used inside a TanStack Form `AppField` context that provides a
+ * `string[]` field value.
+ *
+ * @remarks
+ * **Keyboard focus model**
+ *
+ * This component uses `aria-activedescendant` instead of roving tabIndex.
+ *
+ * The MDN guide on keyboard-navigable widgets
+ * (https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Keyboard-navigable_JavaScript_widgets)
+ * describes two approaches: roving tabIndex (move DOM focus between elements)
+ * and `aria-activedescendant` (keep DOM focus on one element and point to the
+ * active descendant by id).
+ *
+ * Roving tabIndex was tried first but did not work reliably here. When an
+ * `onClick` prop is passed to PatternFly's `Label` component, PF renders the
+ * label content inside a `