+ {/* One picker, where there used to be two selects differing only in their
+ option text -- CSS showed one and hid the other per breakpoint. The
+ short labels now live in the option list, which stays readable at the
+ 72px rail because the list is ours and is not clipped to the trigger. */}
+
{t("语言")}
- setLocale(event.target.value as "zh-CN" | "en")} aria-label={t("语言")}>
- 中文
- English
-
- setLocale(event.target.value as "zh-CN" | "en")} aria-label={t("语言")}>
- 中
- EN
-
-
+ setLocale(next as "zh-CN" | "en")}
+ options={[
+ { value: "zh-CN", label: "中文" },
+ { value: "en", label: "English" },
+ ]}
+ />
+
{/* The task centre is fixed to the viewport's lower-left corner. */}
diff --git a/frontend/src/components/ProviderSegment.tsx b/frontend/src/components/ProviderSegment.tsx
index 653dcf35..32228c0c 100644
--- a/frontend/src/components/ProviderSegment.tsx
+++ b/frontend/src/components/ProviderSegment.tsx
@@ -3,6 +3,7 @@ import { Plus } from "lucide-react";
import { useI18n } from "../i18n";
import { byProviderCreatedAt } from "../state/ranking";
import type { ProviderId, StatusResponse } from "../types/api";
+import { SelectField } from "./SelectField";
export function ProviderSegment({
value,
@@ -18,12 +19,17 @@ export function ProviderSegment({
const { t } = useI18n();
return (
-
{t("模型服务")}
+ {/* A span, not a label: htmlFor only associates with form elements, and the
+ trigger is a button. SelectField carries the accessible name itself. */}
+
{t("模型服务")}
-
onChange(event.target.value)}>
- {byProviderCreatedAt(providers)
- .map(([id, provider]) => {provider.name} )}
-
+
({ value: id, label: provider.name }))}
+ />
diff --git a/frontend/src/components/SelectField.test.tsx b/frontend/src/components/SelectField.test.tsx
new file mode 100644
index 00000000..f999b31e
--- /dev/null
+++ b/frontend/src/components/SelectField.test.tsx
@@ -0,0 +1,120 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it } from "vitest";
+
+import { SelectField } from "./SelectField";
+
+const OPTIONS = [
+ { value: "system", label: "跟随系统" },
+ { value: "light", label: "浅色" },
+ { value: "dark", label: "深色" },
+];
+
+/** Controlled, like every real caller, so a commit has to round-trip through props. */
+function Harness({ initial = "system" }: { initial?: string }) {
+ const [value, setValue] = useState(initial);
+ return (
+
+
+ outside
+
+ );
+}
+
+const trigger = () => screen.getByRole("combobox", { name: "外观" });
+
+describe("SelectField", () => {
+ it("keeps the combobox role a native select had", () => {
+ // The three call sites were , and their tests and the e2e suite find
+ // them by role. Replacing the element must not change how it is addressed.
+ render( );
+ expect(trigger()).toBeTruthy();
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
+ expect(screen.queryByRole("listbox")).toBeNull();
+ });
+
+ it("commits a choice by pointer", async () => {
+ render( );
+ await userEvent.click(trigger());
+ await userEvent.click(screen.getByRole("option", { name: "深色" }));
+ expect(trigger()).toHaveTextContent("深色");
+ // Closing is part of committing; leaving the list open would trap the next click.
+ expect(screen.queryByRole("listbox")).toBeNull();
+ });
+
+ it("opens, moves and commits by keyboard", async () => {
+ // The whole reason a native select is worth replacing carefully: none of this
+ // is free once the OS is no longer providing it.
+ render( );
+ trigger().focus();
+ await userEvent.keyboard("{ArrowDown}");
+ expect(screen.getByRole("listbox")).toBeTruthy();
+ await userEvent.keyboard("{ArrowDown}{Enter}");
+ expect(trigger()).toHaveTextContent("浅色");
+ });
+
+ it("does not change the value while arrowing through the list", async () => {
+ // Committing on every keystroke would apply each option in passing, which for
+ // the theme picker means the whole app flashing through palettes.
+ render( );
+ trigger().focus();
+ await userEvent.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}");
+ expect(trigger()).toHaveTextContent("跟随系统");
+ await userEvent.keyboard("{Escape}");
+ expect(trigger()).toHaveTextContent("跟随系统");
+ });
+
+ it("names the active option for assistive technology", async () => {
+ // Focus stays on the trigger, so without aria-activedescendant a screen
+ // reader would announce nothing as the user arrows down.
+ render( );
+ trigger().focus();
+ await userEvent.keyboard("{ArrowDown}{ArrowDown}");
+ const active = trigger().getAttribute("aria-activedescendant");
+ expect(active).toBeTruthy();
+ expect(document.getElementById(active!)).toHaveTextContent("浅色");
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("marks the current value as selected, not merely visible", async () => {
+ render( );
+ await userEvent.click(trigger());
+ const selected = screen.getAllByRole("option").filter((option) => option.getAttribute("aria-selected") === "true");
+ expect(selected).toHaveLength(1);
+ expect(selected[0]).toHaveTextContent("深色");
+ });
+
+ it("closes without committing on Escape and returns focus", async () => {
+ render( );
+ trigger().focus();
+ await userEvent.keyboard("{ArrowDown}{ArrowDown}{Escape}");
+ expect(screen.queryByRole("listbox")).toBeNull();
+ expect(trigger()).toHaveTextContent("跟随系统");
+ // Focus has to come back, or Escape strands keyboard users at the document.
+ expect(document.activeElement).toBe(trigger());
+ });
+
+ it("closes when a click lands outside", async () => {
+ render( );
+ await userEvent.click(trigger());
+ await userEvent.click(screen.getByRole("button", { name: "outside" }));
+ expect(screen.queryByRole("listbox")).toBeNull();
+ });
+
+ it("jumps to an option by typing its first letters", async () => {
+ render( );
+ trigger().focus();
+ await userEvent.keyboard("{ArrowDown}");
+ await userEvent.keyboard("浅");
+ const active = trigger().getAttribute("aria-activedescendant");
+ expect(document.getElementById(active!)).toHaveTextContent("浅色");
+ });
+
+ it("renders no stale value when the current one is not in the list", () => {
+ // A Provider can be deleted while its id is still the selected value; the
+ // trigger should read empty rather than invent a label or crash.
+ render( {}} />);
+ expect(screen.getByRole("combobox", { name: "模型服务" }).textContent?.trim()).toBe("");
+ });
+});
diff --git a/frontend/src/components/SelectField.tsx b/frontend/src/components/SelectField.tsx
new file mode 100644
index 00000000..ce54a21b
--- /dev/null
+++ b/frontend/src/components/SelectField.tsx
@@ -0,0 +1,219 @@
+import { Check, ChevronDown } from "lucide-react";
+import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
+
+export interface SelectOption {
+ value: string;
+ label: string;
+}
+
+interface SelectFieldProps {
+ value: string;
+ options: readonly SelectOption[];
+ onChange: (value: string) => void;
+ /** The accessible name. Required: the trigger shows only the current value. */
+ label: string;
+ id?: string;
+ className?: string;
+ /** Rendered inside the trigger before the value, e.g. the theme's icon. */
+ leading?: React.ReactNode;
+ /** Hides the value text, for the 72px icon rail. */
+ compact?: boolean;
+}
+
+/**
+ * A select whose open list is ours to style.
+ *
+ * A native cannot do this: the popup is drawn by the operating system
+ * and CSS cannot reach it, so on macOS it appeared as a system control in the
+ * middle of a UI that looks nothing like one. That is the only reason this
+ * exists -- a native select is otherwise the better element, and everything
+ * below is the cost of replacing it.
+ *
+ * Implements the ARIA combobox pattern with aria-activedescendant: focus stays
+ * on the trigger while the active option is named by id. Moving real DOM focus
+ * into the list is the other legal shape, but it means restoring focus on every
+ * close path, which is easier to get wrong.
+ *
+ * role="combobox" is kept so getByRole("combobox", { name }) still finds this,
+ * as it did for the native element. userEvent.selectOptions does not work here;
+ * tests click the trigger and then the option.
+ */
+export function SelectField({ value, options, onChange, label, id, className = "", leading, compact = false }: SelectFieldProps) {
+ const generatedId = useId();
+ const listId = `${id ?? generatedId}-listbox`;
+ const [open, setOpen] = useState(false);
+ // Which option the keyboard is on. Separate from `value`: arrowing through the
+ // list must not commit a change, or a user browsing options with the keyboard
+ // would apply each one in passing.
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const [dropUp, setDropUp] = useState(false);
+ const triggerRef = useRef(null);
+ const listRef = useRef(null);
+ const typeahead = useRef({ query: "", at: 0 });
+
+ const selectedIndex = options.findIndex((option) => option.value === value);
+ const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;
+
+ const close = (returnFocus: boolean) => {
+ setOpen(false);
+ setActiveIndex(-1);
+ if (returnFocus) triggerRef.current?.focus();
+ };
+
+ const commit = (index: number) => {
+ const option = options[index];
+ if (option) onChange(option.value);
+ close(true);
+ };
+
+ // Opens upward when there is not room below. The list is positioned by CSS
+ // relative to the trigger; .app-window sets overflow: hidden, so a list that
+ // ran past the sidebar's bottom edge would be clipped rather than scrolled.
+ useLayoutEffect(() => {
+ if (!open) return;
+ const trigger = triggerRef.current;
+ const list = listRef.current;
+ if (!trigger || !list) return;
+ const box = trigger.getBoundingClientRect();
+ // The margin covers the 5px CSS offset plus room for the shadow, and gives
+ // the sidebar pickers -- which sit near the bottom edge -- a reason to flip
+ // before the list is merely technically on screen with nothing to spare.
+ setDropUp(box.bottom + list.offsetHeight + 16 > window.innerHeight);
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ // Pointer down, not click: a click listener would fire after the trigger's
+ // own handler had already toggled, reopening what the user meant to close.
+ //
+ // Not capturing, and bubbling from document: on the capture phase this runs
+ // before React's synthetic handlers, so the pointerdown that opened the list
+ // was still in flight and closed it again on the way through. The contains()
+ // checks are what make it safe to listen on the bubble phase instead.
+ const onPointerDown = (event: PointerEvent) => {
+ const target = event.target as Node;
+ if (!triggerRef.current?.contains(target) && !listRef.current?.contains(target)) close(false);
+ };
+ // Any scroll or resize invalidates the measured position above.
+ const onReflow = () => close(false);
+ document.addEventListener("pointerdown", onPointerDown);
+ window.addEventListener("resize", onReflow);
+ window.addEventListener("scroll", onReflow, true);
+ return () => {
+ document.removeEventListener("pointerdown", onPointerDown);
+ window.removeEventListener("resize", onReflow);
+ window.removeEventListener("scroll", onReflow, true);
+ };
+ }, [open]);
+
+ const openWith = (index: number) => {
+ setOpen(true);
+ setActiveIndex(index < 0 ? 0 : index);
+ };
+
+ const step = (delta: number) => {
+ const from = activeIndex < 0 ? selectedIndex : activeIndex;
+ const next = Math.min(options.length - 1, Math.max(0, (from < 0 ? 0 : from) + delta));
+ setActiveIndex(next);
+ };
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ switch (event.key) {
+ case "ArrowDown":
+ case "ArrowUp":
+ event.preventDefault();
+ if (!open) openWith(selectedIndex);
+ else step(event.key === "ArrowDown" ? 1 : -1);
+ return;
+ case "Home":
+ case "End":
+ if (!open) return;
+ event.preventDefault();
+ setActiveIndex(event.key === "Home" ? 0 : options.length - 1);
+ return;
+ case "Enter":
+ event.preventDefault();
+ if (!open) openWith(selectedIndex);
+ else commit(activeIndex < 0 ? selectedIndex : activeIndex);
+ return;
+ case " ":
+ // Space opens, but must not also select: it is the character a typeahead
+ // query can contain, so it only commits when the list is already open.
+ event.preventDefault();
+ if (!open) openWith(selectedIndex);
+ else commit(activeIndex < 0 ? selectedIndex : activeIndex);
+ return;
+ case "Escape":
+ if (!open) return;
+ event.preventDefault();
+ close(true);
+ return;
+ case "Tab":
+ // Tab commits nothing and moves on, matching a native select.
+ if (open) close(false);
+ return;
+ default:
+ break;
+ }
+ if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return;
+ // Typeahead: consecutive keystrokes within a second extend the query, so
+ // "de" reaches "深色" rather than restarting at every letter.
+ const now = Date.now();
+ typeahead.current.query = now - typeahead.current.at < 1000 ? typeahead.current.query + event.key : event.key;
+ typeahead.current.at = now;
+ const query = typeahead.current.query.toLowerCase();
+ const hit = options.findIndex((option) => option.label.toLowerCase().startsWith(query));
+ if (hit < 0) return;
+ if (open) setActiveIndex(hit);
+ else onChange(options[hit].value);
+ };
+
+ return (
+
+
= 0 ? `${listId}-${activeIndex}` : undefined}
+ onClick={() => (open ? close(false) : openWith(selectedIndex))}
+ onKeyDown={onKeyDown}
+ >
+ {leading}
+ {compact ? null : {selected?.label ?? ""} }
+
+
+ {open ? (
+
+ {options.map((option, index) => (
+ setActiveIndex(index)}
+ onClick={() => commit(index)}
+ >
+
+ {option.label}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/ThemePicker.tsx b/frontend/src/components/ThemePicker.tsx
index 733a5b09..83118c9e 100644
--- a/frontend/src/components/ThemePicker.tsx
+++ b/frontend/src/components/ThemePicker.tsx
@@ -2,6 +2,7 @@ import { Moon, Sun, SunMoon } from "lucide-react";
import { useI18n } from "../i18n";
import { type ThemePreference, useTheme } from "../state/ThemeContext";
+import { SelectField } from "./SelectField";
const icons: Record = {
system: SunMoon,
@@ -22,19 +23,24 @@ export function ThemePicker() {
const { preference, setPreference, resolved } = useTheme();
const Icon = preference === "system" ? icons[resolved] : icons[preference];
+ // A div, not a label: label only associates implicitly with form elements, and
+ // wrapping a button in one makes every click on the row activate it. The
+ // accessible name comes from SelectField's own aria-label instead.
return (
-
+
{t("外观")}
- setPreference(event.target.value as ThemePreference)}
- aria-label={t("外观")}
- >
- {t("跟随系统")}
- {t("浅色")}
- {t("深色")}
-
-
+ onChange={(next) => setPreference(next as ThemePreference)}
+ options={[
+ { value: "system", label: t("跟随系统") },
+ { value: "light", label: t("浅色") },
+ { value: "dark", label: t("深色") },
+ ]}
+ />
+
);
}
diff --git a/frontend/src/state/ThemeContext.test.tsx b/frontend/src/state/ThemeContext.test.tsx
index 5be4e11a..775cdf42 100644
--- a/frontend/src/state/ThemeContext.test.tsx
+++ b/frontend/src/state/ThemeContext.test.tsx
@@ -33,6 +33,19 @@ function mount() {
return screen.getByRole("combobox", { name: "外观" });
}
+/**
+ * Opens the picker and clicks an option.
+ *
+ * Replaces userEvent.selectOptions, which only drives a native . The
+ * picker is now a custom listbox, so these tests go through the same two steps a
+ * user does -- which is also what makes them cover the component's open/commit
+ * path rather than just the provider's reducer.
+ */
+async function choose(trigger: HTMLElement, label: string) {
+ await userEvent.click(trigger);
+ await userEvent.click(screen.getByRole("option", { name: label }));
+}
+
const classes = () => document.documentElement.className;
beforeEach(() => {
@@ -56,7 +69,7 @@ describe("ThemeProvider", () => {
it("forces a palette independently of the desktop", async () => {
stubSystem(true);
const select = mount();
- await userEvent.selectOptions(select, "light");
+ await choose(select, "浅色");
// theme-light on a dark desktop is the case that needs the :not() in the
// media query, otherwise the dark block still wins.
expect(document.documentElement.classList.contains("theme-light")).toBe(true);
@@ -65,7 +78,7 @@ describe("ThemeProvider", () => {
it("persists the choice", async () => {
stubSystem(false);
- await userEvent.selectOptions(mount(), "dark");
+ await choose(mount(), "深色");
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark");
expect(storedPreference()).toBe("dark");
});
@@ -80,7 +93,7 @@ describe("ThemeProvider", () => {
it("keeps an explicit choice when the desktop flips", async () => {
const flip = stubSystem(false);
- await userEvent.selectOptions(mount(), "light");
+ await choose(mount(), "浅色");
flip(true);
expect(document.documentElement.classList.contains("theme-light")).toBe(true);
});
@@ -88,8 +101,8 @@ describe("ThemeProvider", () => {
it("returns to the system palette when asked", async () => {
stubSystem(true);
const select = mount();
- await userEvent.selectOptions(select, "dark");
- await userEvent.selectOptions(select, "system");
+ await choose(select, "深色");
+ await choose(select, "跟随系统");
expect(classes()).toBe("");
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe("system");
});
@@ -98,7 +111,7 @@ describe("ThemeProvider", () => {
// index.html's two theme-color tags are media-driven and cannot see a forced
// palette, so the window chrome would keep the desktop's colour.
stubSystem(false);
- await userEvent.selectOptions(mount(), "dark");
+ await choose(mount(), "深色");
expect(document.head.querySelector("meta#theme-color-resolved")?.content).toBe("#151517");
});
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index b1aba27d..f7eb6fe3 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -128,40 +128,36 @@
flex: 1;
}
-/* background-color, not the background shorthand: the shorthand resets the arrow
- image base.css sets on every select. Same below for the language picker and
- the Provider picker. */
-.theme-picker select {
- width: 82px;
- min-width: 0;
- padding: 5px 22px 5px 6px;
- border: 1px solid var(--border);
- border-radius: var(--radius-control);
- background-color: var(--window-bg);
- background-position: right 5px center;
- background-size: 11px 11px;
- color: var(--text-primary);
- font: inherit;
-}
-
.language-picker span {
flex: 1;
}
-.language-picker select {
+/* The two sidebar pickers are the same control at the same size. A lighter
+ border than SelectField's default, because these sit on the sidebar's tinted
+ background rather than on a form. */
+.theme-select,
+.language-select {
width: 82px;
- min-width: 0;
- padding: 5px 22px 5px 6px;
- border: 1px solid var(--border);
- border-radius: var(--radius-control);
- background-color: var(--window-bg);
- background-position: right 5px center;
- background-size: 11px 11px;
- color: var(--text-primary);
- font: inherit;
+ flex: 0 0 auto;
}
-.language-select-compact { display: none; }
+.theme-select .select-field-trigger,
+.language-select .select-field-trigger {
+ min-height: 26px;
+ padding: 0 6px;
+ border-color: var(--border);
+ font-size: 12px;
+}
+
+/* The trigger is 82px, but a list constrained to it would clip "跟随系统". It
+ grows rightward instead, which the sidebar has room for. */
+.theme-select .select-field-list,
+.language-select .select-field-list {
+ right: auto;
+ min-width: 100%;
+ width: max-content;
+ max-width: 160px;
+}
.app-main {
min-width: 0;
@@ -635,7 +631,7 @@
gap: 7px;
}
-.provider-picker > label {
+.provider-picker-label {
font-size: 13px;
font-weight: 600;
}
@@ -646,20 +642,11 @@
gap: 8px;
}
-.provider-picker select {
- width: 100%;
+/* 42px to match the add button beside it and the text fields on the same form,
+ rather than SelectField's 38px default. */
+.provider-picker .select-field-trigger {
min-height: 42px;
- padding: 0 30px 0 12px;
- border: 1px solid var(--border-strong);
- border-radius: var(--radius-control);
- color: var(--text-primary);
- background-color: var(--window-bg);
-}
-
-.provider-picker select:focus {
- border-color: var(--blue);
- box-shadow: 0 0 0 3px var(--blue-soft);
- outline: none;
+ padding: 0 12px;
}
.provider-add-button {
@@ -1026,27 +1013,30 @@
.theme-picker > svg { display: none; }
.sidebar-link { justify-content: center; padding: 0; }
.sidebar-link.is-active::before { left: -12px; }
- .language-picker { padding: 0; }
- .language-select-wide { display: none; }
- .language-select-compact { display: block; }
-
/* Centre what is left, now that the flex: 1 spacer spans are gone. */
+ .language-picker,
.theme-picker { padding: 0; justify-content: center; }
- /* 48px of column has no room for both the value and the arrow, so the arrow
- goes and the value keeps the width. Position and behaviour still identify
- the control as a select, and its aria-label is unchanged.
-
- Both selectors are (0,1,1), matching `.language-picker select` in the base
- layer. Written as `.language-picker .language-select-compact` rather than the
- bare class because that base rule would otherwise win on specificity and
- keep the wide padding, leaving the arrow's 22px gap with no arrow in it. */
- .language-picker .language-select-compact,
- .theme-picker select {
- width: 48px;
- padding-inline: 3px;
- font-size: 10px;
- background-image: none;
+ /* 48px of column fits the arrow alone. The value is dropped from the trigger
+ rather than truncated to an unreadable sliver -- the option list still shows
+ it in full, and each control keeps its aria-label either way. */
+ .theme-select,
+ .language-select {
+ width: 40px;
+ }
+
+ .theme-select .select-field-value,
+ .language-select .select-field-value { display: none; }
+
+ .theme-select .select-field-trigger,
+ .language-select .select-field-trigger { justify-content: center; padding: 0; }
+
+ /* The trigger is now narrower than the list needs, and the sidebar is only
+ 72px wide, so the list breaks out to the right of the rail instead. */
+ .theme-select .select-field-list,
+ .language-select .select-field-list {
+ left: 0;
+ max-width: none;
}
}
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css
index ac794bbb..9781499a 100644
--- a/frontend/src/styles/base.css
+++ b/frontend/src/styles/base.css
@@ -127,39 +127,6 @@ button:active:not(:disabled) {
outline: none;
}
-/* Every select in the app, so the closed control matches the buttons and text
- fields beside it instead of whatever the OS draws. Without this the pickers
- rendered as native macOS controls here and as something else again on Windows
- and Linux, while the rest of the UI stayed consistent.
-
- The open list stays OS-drawn -- CSS cannot reach it -- so this
- unifies the closed state only. Matching the open list too would mean a custom
- component reimplementing keyboard navigation and the accessibility tree,
- which is not worth it for three pickers.
-
- The arrow is a background image because does not render pseudo
- elements. Its stroke is a literal colour: a data URI cannot read a custom
- property, so the value is repeated per theme below and tracks --icon-fg. */
-select {
- appearance: none;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%233a3a3c' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6.5 8 10.5l4-4'/%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 7px center;
- background-size: 13px 13px;
- cursor: pointer;
- transition: border-color 140ms ease, box-shadow 140ms ease;
-}
-
-@media (prefers-color-scheme: dark) {
- :root:not(.theme-light) select {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23d1d1d6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6.5 8 10.5l4-4'/%3E%3C/svg%3E");
- }
-}
-
-:root.theme-dark select {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23d1d1d6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6.5 8 10.5l4-4'/%3E%3C/svg%3E");
-}
-
.field-stack {
display: grid;
gap: 7px;
@@ -198,6 +165,124 @@ select {
to { transform: rotate(360deg); }
}
+/* SelectField: the trigger borrows the text field's shape so a picker and an
+ input on the same form read as one set. The list below it is the reason the
+ component exists at all -- a native select's popup is drawn by the OS and
+ cannot be styled. */
+.select-field {
+ position: relative;
+ min-width: 0;
+}
+
+.select-field-trigger {
+ width: 100%;
+ min-height: 38px;
+ padding: 0 9px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-control);
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--text-primary);
+ background: var(--window-bg);
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
+}
+
+.select-field-trigger:hover {
+ background: var(--surface-subtle);
+}
+
+.select-field-trigger[aria-expanded="true"] {
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px var(--blue-soft);
+}
+
+/* Grows to fill so the arrow stays pinned right, and truncates rather than
+ widening the control when a Provider has a long name. */
+.select-field-value {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.select-field-arrow {
+ flex: 0 0 auto;
+ margin-left: auto;
+ color: var(--icon-fg);
+ transition: transform 140ms ease;
+}
+
+.select-field-trigger[aria-expanded="true"] .select-field-arrow {
+ transform: rotate(180deg);
+}
+
+/* The part a native select cannot give us. z-index sits above the task centre's
+ 20 so a sidebar picker's list is not covered by it -- the same overlay that
+ made the language picker unclickable. */
+.select-field-list {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: calc(100% + 5px);
+ z-index: 40;
+ max-height: 240px;
+ margin: 0;
+ padding: 4px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-panel);
+ list-style: none;
+ background: var(--window-bg);
+ box-shadow: var(--shadow-window);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+/* Set by measurement, not by a breakpoint: .app-window is overflow: hidden, so
+ a list that ran past the sidebar's bottom edge would be clipped, not scrolled. */
+.select-field-list.is-above {
+ top: auto;
+ bottom: calc(100% + 5px);
+}
+
+.select-field-option {
+ min-height: 32px;
+ padding: 0 8px;
+ border-radius: var(--radius-control);
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--text-primary);
+ font-size: 13px;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+/* One highlight, driven by JS on both mouse move and arrow keys, so the pointer
+ and the keyboard can never show two different active rows. */
+.select-field-option.is-active {
+ background: var(--surface-subtle);
+}
+
+.select-field-check {
+ flex: 0 0 auto;
+ color: var(--blue);
+ /* Reserves the tick's width for every row, so the labels do not shift
+ sideways as the selection moves. */
+ visibility: hidden;
+}
+
+.select-field-option[aria-selected="true"] {
+ font-weight: 600;
+}
+
+.select-field-option[aria-selected="true"] .select-field-check {
+ visibility: visible;
+}
+
.notice {
min-height: 38px;
padding: 9px 12px;