Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/desktop-tauri/src/components/Dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { Dropdown } from "./Dropdown";

const options = [
{ value: "a", label: "Alpha" },
{ value: "b", label: "Beta" },
{ value: "c", label: "Gamma" },
];

describe("Dropdown", () => {
it("shows the selected label and keeps the popup closed by default", () => {
render(<Dropdown value="b" options={options} onChange={() => {}} ariaLabel="Metric" />);
expect(screen.getByRole("button", { name: "Metric" }).textContent).toContain("Beta");
expect(screen.queryByRole("listbox")).toBeNull();
});

it("opens on click and commits the clicked option", () => {
const onChange = vi.fn();
render(<Dropdown value="a" options={options} onChange={onChange} ariaLabel="Metric" />);
fireEvent.click(screen.getByRole("button"));
expect(screen.getByRole("listbox")).toBeTruthy();
fireEvent.click(screen.getByRole("option", { name: "Gamma" }));
expect(onChange).toHaveBeenCalledWith("c");
expect(screen.queryByRole("listbox")).toBeNull();
});

it("marks the current value as the selected option", () => {
render(<Dropdown value="b" options={options} onChange={() => {}} ariaLabel="Metric" />);
fireEvent.click(screen.getByRole("button"));
expect(screen.getByRole("option", { name: "Beta" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("option", { name: "Alpha" }).getAttribute("aria-selected")).toBe("false");
});

it("commits with the keyboard (open, ArrowDown, Enter)", () => {
const onChange = vi.fn();
render(<Dropdown value="a" options={options} onChange={onChange} ariaLabel="Metric" />);
fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" });
const list = screen.getByRole("listbox");
fireEvent.keyDown(list, { key: "ArrowDown" });
fireEvent.keyDown(list, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("b");
});

it("closes on Escape without changing the value", () => {
const onChange = vi.fn();
render(<Dropdown value="a" options={options} onChange={onChange} ariaLabel="Metric" />);
fireEvent.click(screen.getByRole("button"));
fireEvent.keyDown(screen.getByRole("listbox"), { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
expect(onChange).not.toHaveBeenCalled();
});

it("does not open when disabled", () => {
render(<Dropdown value="a" options={options} onChange={() => {}} ariaLabel="Metric" disabled />);
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByRole("listbox")).toBeNull();
});
});
159 changes: 159 additions & 0 deletions apps/desktop-tauri/src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { useEffect, useId, useRef, useState } from "react";

export type DropdownOption = { value: string; label: string };

/**
* A fully CSS-themed listbox dropdown that replaces the native `<select>`.
*
* Native `<select>` popups on Windows are drawn by WebView2 and do not reliably
* honor the app's `color-scheme` or the Tauri window theme, so they render in
* the OS theme (a light popup inside our dark UI). This control draws its own
* popup, so it matches dark and light exactly (SOU-224). It implements the ARIA
* listbox keyboard model: Up/Down/Home/End move the active option, Enter/Space
* commit, Escape closes, and clicking outside dismisses.
*/
export function Dropdown({
value,
options,
onChange,
disabled,
className,
buttonClassName,
ariaLabel,
style,
}: {
value: string;
options: DropdownOption[];
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
buttonClassName?: string;
ariaLabel?: string;
style?: React.CSSProperties;
}) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const baseId = useId();

const selectedIndex = options.findIndex((option) => option.value === value);
const selectedLabel = options[selectedIndex]?.label ?? value;

// Dismiss on outside pointer press.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);

// On open, focus the list and start on the selected option.
useEffect(() => {
if (!open) return;
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
listRef.current?.focus();
}, [open, selectedIndex]);

const commit = (index: number) => {
const option = options[index];
if (option) onChange(option.value);
setOpen(false);
buttonRef.current?.focus();
};

const onButtonKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpen(true);
}
};

const onListKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setActiveIndex((index) => Math.min(options.length - 1, index + 1));
break;
case "ArrowUp":
event.preventDefault();
setActiveIndex((index) => Math.max(0, index - 1));
break;
case "Home":
event.preventDefault();
setActiveIndex(0);
break;
case "End":
event.preventDefault();
setActiveIndex(options.length - 1);
break;
case "Enter":
case " ":
event.preventDefault();
commit(activeIndex);
break;
case "Escape":
event.preventDefault();
setOpen(false);
buttonRef.current?.focus();
break;
case "Tab":
setOpen(false);
break;
default:
break;
}
};

return (
<div className={`dropdown${className ? ` ${className}` : ""}`} ref={rootRef} style={style}>
<button
type="button"
ref={buttonRef}
className={`dropdown__button${buttonClassName ? ` ${buttonClassName}` : ""}`}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => !disabled && setOpen((value) => !value)}
onKeyDown={onButtonKeyDown}
>
<span className="dropdown__value">{selectedLabel}</span>
<span className="dropdown__chevron" aria-hidden="true" />
</button>
{open && (
<ul
className="dropdown__list"
role="listbox"
ref={listRef}
tabIndex={-1}
aria-label={ariaLabel}
aria-activedescendant={activeIndex >= 0 ? `${baseId}-option-${activeIndex}` : undefined}
onKeyDown={onListKeyDown}
>
{options.map((option, index) => (
<li
key={option.value}
id={`${baseId}-option-${index}`}
role="option"
aria-selected={option.value === value}
className={`dropdown__option${index === activeIndex ? " dropdown__option--active" : ""}${
option.value === value ? " dropdown__option--selected" : ""
}`}
onPointerEnter={() => setActiveIndex(index)}
onClick={() => commit(index)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
18 changes: 7 additions & 11 deletions apps/desktop-tauri/src/components/FormControls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type React from "react";

import { Dropdown } from "./Dropdown";

// ── tiny reusable controls ──────────────────────────────────────────

export function Toggle({
Expand Down Expand Up @@ -63,19 +65,13 @@ export function Select({
const width = Math.min(190, Math.max(78, Math.ceil(longestLabelUnits * 7.2) + 34));

return (
<select
className="select"
style={{ width }}
<Dropdown
value={value}
options={options}
onChange={onChange}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
style={{ width }}
/>
);
}

Expand Down
130 changes: 115 additions & 15 deletions apps/desktop-tauri/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2877,21 +2877,6 @@ body:has(.taskbar-flyout-frame) #root {
color: var(--provider-row-text-secondary);
}

.provider-detail-select {
appearance: none;
background: var(--provider-row-bg);
color: var(--provider-row-text-primary);
border: 1px solid var(--provider-icon-ring);
border-radius: 6px;
padding: 4px 10px;
font-size: 0.82rem;
min-width: 220px;
}

.provider-detail-select:disabled {
opacity: 0.55;
}

.btn.btn--ghost {
background: var(--provider-row-bg);
color: var(--provider-row-text-primary);
Expand Down Expand Up @@ -8189,3 +8174,118 @@ body:has(.dashboard-shell) #root {
[data-theme="light"] .provider-detail-pace__stage[data-stage="ahead"] {
color: var(--status-warning-text);
}

/* ── Themed dropdown — replaces native <select> so the popup matches the app
theme instead of the OS (SOU-224). See components/Dropdown.tsx. ─────────── */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown__button {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 22px;
box-sizing: border-box;
padding: 0 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 5px;
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary);
font: inherit;
font-size: var(--font-body);
line-height: 20px;
cursor: pointer;
text-align: left;
}
.dropdown__value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown__chevron {
flex: none;
width: 8px;
height: 5px;
background: currentColor;
opacity: 0.55;
clip-path: polygon(0 0, 100% 0, 50% 100%);
}
.dropdown__button:focus-visible {
outline: 2px solid var(--focus-ring, var(--accent));
outline-offset: 1px;
border-color: var(--accent);
}
.dropdown__button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dropdown__list {
position: absolute;
z-index: 40;
top: calc(100% + 4px);
left: 0;
min-width: 100%;
max-height: 240px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: #1c2024;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
outline: none;
}
.dropdown__option {
padding: 6px 10px;
border-radius: 5px;
font-size: var(--font-body);
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
}
.dropdown__option--active {
background: color-mix(in srgb, var(--accent) 22%, transparent);
}
.dropdown__option--selected {
color: var(--accent);
font-weight: 600;
}
.dropdown__option--selected.dropdown__option--active {
color: var(--text-primary);
}

/* provider-detail variant (settings provider panes) */
.dropdown--provider-detail {
display: block;
}
.dropdown--provider-detail .dropdown__button {
min-width: 220px;
height: auto;
padding: 4px 10px;
font-size: 0.82rem;
background: var(--provider-row-bg);
color: var(--provider-row-text-primary);
border: 1px solid var(--provider-icon-ring);
border-radius: 6px;
}

/* light theme */
[data-theme="light"] .dropdown__button {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.12);
color: var(--text-primary);
}
[data-theme="light"] .dropdown__list {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.12);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
}
[data-theme="light"] .dropdown--provider-detail .dropdown__button {
background: var(--provider-row-bg);
color: var(--provider-row-text-primary);
border: 1px solid var(--provider-icon-ring);
}
Loading
Loading