ConfirmDialog trapped focus and closed on Escape but its container was a plain
div, so screen readers did not announce it as a modal dialog. Its text input
also had only a placeholder, giving no persistent accessible name. Add
role=dialog / aria-modal / aria-label / tabindex to the dialog surface and an
aria-label to the textarea.
Relates to #2790
Co-authored-by: Tim Baek <tim@openwebui.com>
Adds a multiselect input type for Valves and UserValves so plugin authors can let users pick multiple values from static or runtime-resolved options instead of maintaining comma-separated text fields with hardcoded allowed-value lists in the description.
ENABLED_ITEMS: list[str] = Field(
default=["foo"],
json_schema_extra={"input": {"type": "multiselect", "options": "get_item_options"}},
)
@classmethod
def get_item_options(cls):
return [{"value": "foo", "label": "Foo"}, {"value": "bar", "label": "Bar"}]
Options accept the same shapes as the existing select input: either a static list (strings or {value, label} dicts) or a classmethod name resolved at request time (including __user__ context for UserValves). No backend changes are needed because resolve_valves_schema_options already resolves options independently of the input type.
The new MultiSelect component follows the existing Select portal dropdown pattern and renders checkbox rows that stay open while toggling, with the selected labels shown in the trigger. Values bind as a real string array end to end: the array-to-comma-string conversions in the chat controls valves panel and the valves modal are skipped for multiselect fields, so the stored valve is a native list[str] validated by Pydantic.
Requested in #26848.
On latest `dev`, the `Select` trigger sets `aria-label={placeholder}`. In the accessible name computation `aria-label` is evaluated before the element's contents, so on a button that renders visible text it **replaces** that text instead of adding to it.
The trigger's content is `selectedLabel`, which resolves to the selected item's label and only falls back to `placeholder` when nothing is selected. So a control visually reading "Week" is exposed to assistive technology as "Select view", permanently, no matter what is selected. The dropdown items expose no `aria-selected` either, the current one is marked with a check icon only, so there is no path by which a screen reader user can find out what the control is set to.
Breaks WCAG 2.5.3 Label in Name (Level A), because the accessible name does not contain the visible label, so voice control cannot target the control by what it says on screen. Also 4.1.2 Name, Role, Value (Level A), because the value is never exposed.
Fix: drop the overriding `aria-label` so the name is computed from the visible text, and expose `aria-expanded` so the open state is conveyed. All 7 call sites plus the `DropdownOptions` wrapper override `slot="trigger"` and every one of them renders `selectedLabel` (or `placeholder`) as text, so no trigger is left unnamed. The two call sites that pass no `placeholder` were already emitting an empty `aria-label`, which is skipped by the name computation, so they are unaffected.
`aria-haspopup` is deliberately not added: the popup is a plain `DropdownMenu` of buttons with no `listbox` role, so claiming one would misdescribe it.
`placeholder` is still used, it drives the `selectedLabel` fallback and `TagSelector` renders it directly.
Severity: Serious. Affects every custom select in Admin Settings, Workspace and the calendar and automations pages.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
On latest `dev`, `SensitiveInput` defaults to `export let id = 'password-input'`. The id is used both for the input itself and as the `for` target of the screen reader label rendered just above it.
There are 80 `<SensitiveInput>` usages in `src/` and only 4 pass an explicit id, so the remaining 76 all render `id="password-input"` together with `<label for="password-input">`. These collide on the same page in completely ordinary configurations: `admin/Settings/Audio.svelte` renders 4 at once with `STT_ENGINE === 'openai'` and 4 more with `TTS_ENGINE === 'openai'`, `admin/Settings/Documents.svelte` has 11, and `admin/Settings/WebSearch.svelte` has 33.
`for` resolves to the first matching element, so every label after the first points at the wrong input. In practice a screen reader user tabbing to the OpenAI TTS API key field hears the label belonging to the STT key field from a different section, and every one of those fields announces the same name. Browser password managers and any `getElementById` lookup collapse onto the first element the same way.
Breaks WCAG 1.3.1 Info and Relationships (Level A), because the programmatic label/field relationship is wrong, and 4.1.2 Name, Role, Value (Level A), because the fields do not expose their correct accessible name.
Fix: default the id to a per instance unique value. A Svelte prop default is evaluated per component instance, so each `SensitiveInput` gets its own stable id, and the 4 call sites that pass an explicit id are unaffected. `uuid` is already a direct dependency and `import { v4 as uuidv4 } from 'uuid'` is the existing pattern in the codebase, including `common/Collapsible.svelte`, which already generates a DOM id this way.
Note for self hosted setups: a `#password-input` selector in `static/custom.css` would stop matching. That selector already matched up to 8 elements at once on the Audio settings page, so it was never a reliable hook.
Severity: Serious. Every API key field in Admin Settings is mislabelled for assistive technology.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
On latest `dev`, the `title !== null` branch of `Collapsible` renders its header as a bare `<div>` whose only handler is `on:pointerup`, with the two Svelte a11y warnings suppressed above it.
`pointerup` is never dispatched by keyboard activation, and the `<div>` has no `role`, no `tabindex` and no `aria-expanded`. The header is therefore not focusable, not activatable and not announced as a control. This is the header for "Thinking..." / "Thought for N seconds", "Analyzing..." / "Analyzed", and every `<details>` block rendered from model output, via `Messages/Markdown/MarkdownTokens.svelte`, `Messages/StructuredOutputRenderer.svelte` and `chat/Controls/Controls.svelte`.
In practice a keyboard or screen reader user cannot expand any model reasoning trace, tool call detail or code interpreter block, and a screen reader reads the header as static text with no hint that anything is collapsed behind it.
Breaks WCAG 2.1.1 Keyboard (Level A), since the disclosure has no keyboard operation at all, and 4.1.2 Name, Role, Value (Level A), since it exposes neither a button role nor its expanded state.
Fix: render that header as a real `<button type="button">` with `aria-expanded` and the native `disabled` attribute, and toggle on `click`, which fires for both pointer and keyboard activation. This branch contains no `<slot />` and no interactive descendants, so a button is valid here. `block text-start` keeps the previous box and alignment behaviour, since a `<button>` otherwise defaults to `inline-block` and centred text. `disabled:cursor-default` replaces the old `{disabled ? '' : 'cursor-pointer'}` ternary, which became a no-op once this was a button, because `src/tailwind.css` applies `cursor-pointer` to every `button`. Verified in a browser that display, text alignment and rendered height match the previous `<div>`, and that a disabled header no longer shows a pointer cursor.
Switching from `pointerup` to `click` also means the header no longer toggles on right click, or when a drag starts outside it and ends inside.
The `{:else}` branch is deliberately left alone. Its `<slot />` receives buttons from `Sidebar/Section.svelte`, `common/Folder.svelte` and `Sidebar/RecursiveFolder.svelte`, so it cannot legally become a `<button>` and needs a different fix.
Severity: Critical. Model reasoning output is entirely unreachable without a mouse.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
On latest `dev`, `ConfirmDialog` registers `handleKeyDown` on `window` and treats **every** Enter keypress as Confirm, calling `event.preventDefault()` first so the focused control never gets its native activation.
The dialog also activates a focus trap with no `initialFocus`, so focus-trap falls back to the first tabbable node, which is the **Cancel** button. So the dialog opens with Cancel focused, and pressing Enter runs Confirm.
This is the confirm surface for Delete chat, Delete folder, Delete model, Delete knowledge base and ~40 other call sites. A keyboard user who tabs to Cancel and presses Enter deletes the thing they were trying to keep. Screen reader users are hit hardest, since they cannot see which button they are on and the control that means "back out safely" performs the irreversible action instead.
Two related paths have the same cause: Enter in the `input=true` textarea submits instead of inserting a newline, and a markdown link inside `message` (reachable via `eventConfirmationMessage` from tool `__event_call__` payloads, and via `web_search_confirmation_content`) becomes the first tabbable node, so Enter on that link confirms instead of following it.
Breaks WCAG 3.2.2 On Input (Level A): changing the focused control changes what the Enter key does, and activating a control performs a different action than the one it is labelled with. Also 2.1.1 Keyboard (Level A), since Cancel has no working keyboard activation.
Fix: let the focused control act on Enter itself, and only fall back to Confirm otherwise. Uses the same `target instanceof Element && target.closest(...)` guard already used in `Functions.svelte`, `Knowledge.svelte`, `Models.svelte`, `Prompts.svelte`, `Skills.svelte` and `Tools.svelte`. `select` is deliberately not in the list, because a native `select` does not act on Enter and excluding it would silently break confirm for the `inputType === 'select'` variant. Two stray `console.log` calls in the same function are removed.
Behaviour after this change: Enter on Cancel cancels, Enter on Confirm confirms, Enter in the textarea inserts a newline, Enter on a link follows it, and Enter anywhere else still confirms as before.
Severity: Critical. Silent, unrecoverable data loss triggered by the most ordinary keyboard interaction there is.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
On latest `dev`, `common/Checkbox.svelte` renders a `<button type="button">` containing only `aria-hidden="true"` SVGs. It has no `role`, no `aria-checked` and no accessible name, and the component has no `$$restProps` spread, so a caller cannot supply a name either.
Assistive technology announces every one of these as an unnamed "button". A screen reader user cannot tell that the control is a checkbox, cannot tell whether it is on or off, and cannot tell what it toggles. The visible label is always an unassociated sibling element, for example `Capabilities.svelte` puts it in a preceding `<div>` with no `id`, and `Groups/Users.svelte` puts it in a different table cell from the checkbox.
Breaks WCAG 4.1.2 Name, Role, Value (Level A) on all three counts at once.
Fix: expose `role="checkbox"` and `aria-checked` on the control, add an `ariaLabel` prop, and pass the label text that is already in scope at each call site. `aria-checked` mirrors the component's existing icon logic exactly, so the indeterminate dash reports `mixed` rather than `false`. The `ariaLabel={ariaLabel || undefined}` shape matches the sibling `common/Switch.svelte`. Every label expression is the same one that renders the visible text next to the checkbox, so the accessible name always matches what is on screen.
Three call sites are deliberately left out of this PR, because they nest `Checkbox` inside another `<button>`, which is invalid HTML and independently broken:
- `workspace/Knowledge/KnowledgeBase.svelte` — the Checkbox's `on:change` sets `includeContent = true` and then the same click bubbles to the outer button, which flips it back with `includeContent = !includeContent`. Clicking the checkbox square is a no-op today, only the text label works. Giving it a confident name would advertise a control that does nothing.
- `workspace/common/MemberSelector.svelte` (two instances) — the inner Checkbox has no `on:change` at all and only works because its click bubbles to the row button. Naming it would create two focusable controls per row with the same name.
Both need the nesting resolved first, so that the row button carries the checkbox semantics. That is a behavioural fix and belongs in its own PR.
Severity: Serious. Affects model capabilities, default features, builtin tools, tool/filter/skill/action selectors and group membership.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
On latest `dev`, `RichTextInput` passes only `attributes: { id }` to tiptap, so the rendered contenteditable has an implicit `textbox` role and **no accessible name at all**.
The only label is the tiptap placeholder, which renders as CSS generated content in `src/app.css` via `content: attr(data-placeholder)`. Generated content never becomes an element's accessible name, so assistive technology announces the field as "edit text, blank".
This is the chat composer, the channel and thread composers, and the note editor, so it is the most used control in the product.
Breaks WCAG 4.1.2 Name, Role, Value (Level A), and 3.3.2 Labels or Instructions (Level A), since the only instruction is invisible to assistive technology.
Fix: expose the placeholder as `aria-label` on the editor element.
`attributes` is passed as a **function** rather than an object literal. The object form is evaluated once when the `Editor` is constructed and never rebuilt, but `placeholder` is deliberately runtime mutable: `channel/MessageInput.svelte` and `channel/Thread.svelte` swap it between "You do not have permission to send messages in this thread." and "Reply to thread..." once `channel` resolves, and it also changes when the interface language changes. With the object form the field would have been permanently named with whatever string happened to be set at mount, which for a channel the user *can* write to is the no-permission message. That would be worse than no name at all. ProseMirror supports the function form and re-evaluates it on every state update, and the component's existing `setPlaceholder` already dispatches an empty transaction, so the label now tracks the visible placeholder. It binds to `_placeholder`, the same value that feeds the visible text, so the two cannot diverge.
`aria-multiline` is deliberately not set. It is only valid on an explicit `textbox`/`searchbox` role, and adding `role="textbox"` would flatten the editor's inner structure so headings, lists and links inside rich text stop being exposed.
Severity: Critical. The application's primary input announces as an unnamed edit field.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run