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
158 changes: 158 additions & 0 deletions apps/desktop/src/components/settings/SettingsMenuSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* A menu select for a Settings row.
*
* Settings cards clip their overflow (`.settings-panel` draws the frame with
* `overflow: hidden`), so the option list opens through `AnchoredMenu`. A
* native `<select>` draws its popup at the OS level instead: it ignores the
* menu surface tokens, shows the platform highlight, and has no current-value
* marker. Rows whose list is short still use this control so one Settings
* window does not mix two popup implementations.
*
* Unlike the Appearance pickers this list is not searchable — the longest
* catalog here is the host command-shell list — so the menu opens on the
* current option and keyboard users move with arrows alone.
*/
import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { cx } from "../ui";
import { IconCheck, IconChevronDown } from "../icons";
import { AnchoredMenu } from "./AnchoredMenu";

export type MenuSelectOption = {
id: string;
label: string;
/** Listed but not selectable; the host may report an unavailable shell. */
disabled?: boolean;
};

export function SettingsMenuSelect({
value,
options,
onChange,
label,
disabled = false,
busy = false,
className,
}: {
value: string;
options: MenuSelectOption[];
onChange: (id: string) => void;
/** Accessible name for the trigger and the menu. */
label: string;
disabled?: boolean;
/** Keeps the trigger non-interactive while a write is in flight. */
busy?: boolean;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [activeId, setActiveId] = useState(value);
const optionRefs = useRef(new Map<string, HTMLButtonElement>());

const current = options.find((option) => option.id === value);
const selectable = options.filter((option) => !option.disabled);

const close = () => setOpen(false);

const choose = (option: MenuSelectOption) => {
close();
if (option.disabled || option.id === value) return;
onChange(option.id);
};

const moveActive = (from: string, delta: number) => {
if (selectable.length === 0) return;
const index = selectable.findIndex((option) => option.id === from);
const next =
index === -1
? delta > 0
? 0
: selectable.length - 1
: (index + delta + selectable.length) % selectable.length;
const target = selectable[next];
if (!target) return;
setActiveId(target.id);
optionRefs.current.get(target.id)?.focus();
};

/* Arrow keys wrap over the selectable rows for parity with the Appearance
pickers; Home/End and Enter stay with the focused option button. */
const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
moveActive(activeId, event.key === "ArrowDown" ? 1 : -1);
};

return (
<div className={cx("settings-menu-select-anchor", className)}>
<AnchoredMenu
open={open}
onClose={close}
menuClassName="settings-menu-select-menu"
label={label}
align="end"
onMenuKeyDown={onMenuKeyDown}
trigger={(ref) => (
<button
ref={ref}
type="button"
className="settings-menu-select-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={label}
disabled={disabled || busy}
onClick={() => {
setActiveId(value);
setOpen((current) => !current);
}}
>
<span className="settings-menu-select-trigger-label">
{current?.label ?? value}
</span>
<IconChevronDown size={14} aria-hidden />
</button>
)}
>
<div className="settings-menu-select-results">
<ul className="settings-menu-select-list">
{options.map((option) => {
const isCurrent = option.id === value;
return (
<li key={option.id}>
<button
ref={(node) => {
if (node) optionRefs.current.set(option.id, node);
else optionRefs.current.delete(option.id);
}}
type="button"
role="option"
tabIndex={-1}
aria-selected={isCurrent}
disabled={option.disabled}
className={cx(
"settings-menu-select-option",
isCurrent && "is-current",
option.id === activeId && "is-active",
)}
onMouseEnter={() => setActiveId(option.id)}
onFocus={() => setActiveId(option.id)}
onClick={() => choose(option)}
>
<span className="settings-menu-select-option-label">
{option.label}
</span>
{isCurrent ? (
<IconCheck
size={14}
className="settings-menu-select-check"
aria-hidden
/>
) : null}
</button>
</li>
);
})}
</ul>
</div>
</AnchoredMenu>
</div>
);
}
27 changes: 15 additions & 12 deletions apps/desktop/src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { KeyboardShortcutsSection } from "../../components/settings/KeyboardShor
import { FontFamilyRow } from "../../components/settings/FontFamilyRow";
import { FontSizeRow } from "../../components/settings/FontSizeRow";
import { LanguageRow } from "../../components/settings/LanguageRow";
import { SettingsMenuSelect } from "../../components/settings/SettingsMenuSelect";
import { ThemeRow } from "../../components/settings/ThemeRow";
import { NetworkProxySection } from "../../components/settings/NetworkProxySection";
import { ProjectsPage } from "../../pages/ProjectsPage";
Expand Down Expand Up @@ -344,22 +345,24 @@ export function SettingsPage() {
title={t("settings.permissionMode")}
description={t("settings.permissionModeDesc")}
>
<select
className="field-select"
aria-label={t("settings.permissionMode")}
<SettingsMenuSelect
className="settings-permission-select"
label={t("settings.permissionMode")}
value={settings.defaultPermissionMode ?? "ask"}
onChange={(e) =>
onChange={(mode) =>
void saveSettings({
defaultPermissionMode: e.target.value as GlobalPermissionMode,
defaultPermissionMode: mode as GlobalPermissionMode,
})
}
>
<option value="ask">{t("settings.permissionModeAsk")}</option>
<option value="accept-edits">
{t("settings.permissionModeAcceptEdits")}
</option>
<option value="auto">{t("settings.permissionModeAuto")}</option>
</select>
options={[
{ id: "ask", label: t("settings.permissionModeAsk") },
{
id: "accept-edits",
label: t("settings.permissionModeAcceptEdits"),
},
{ id: "auto", label: t("settings.permissionModeAuto") },
]}
/>
</SettingsRow>
</SettingsCard>

Expand Down
31 changes: 16 additions & 15 deletions apps/desktop/src/features/settings/primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
} from "@pi-desktop/shared";
import { api } from "../../lib/api";
import { resolveContextUsageDisplay } from "../../lib/context-usage";
import { Input, Select, cx } from "../../components/ui";
import { Input, cx } from "../../components/ui";
import { SettingsMenuSelect } from "../../components/settings/SettingsMenuSelect";

export function SettingsRow({
title,
Expand Down Expand Up @@ -157,22 +158,22 @@ export function CommandShellRow({
{t("settings.commandShellNoChoices")}
</span>
) : (
<Select
<SettingsMenuSelect
className="settings-command-shell-select"
label={t("settings.commandShell")}
value={selectedId}
disabled={saving}
aria-label={t("settings.commandShell")}
onChange={(event) => void onChange(event.target.value)}
>
{catalog.choices.map((choice) => (
<option key={choice.id} value={choice.id} disabled={!choice.available}>
{choice.label}
{!choice.available
? ` - ${t("settings.commandShellUnavailable")}`
: ""}
</option>
))}
</Select>
busy={saving}
onChange={(value) => void onChange(value)}
options={catalog.choices.map((choice) => ({
id: choice.id,
label: `${choice.label}${
choice.available
? ""
: ` - ${t("settings.commandShellUnavailable")}`
}`,
disabled: !choice.available,
}))}
/>
)}
{effectiveStatus ? (
<span className="settings-command-shell-status">{effectiveStatus}</span>
Expand Down
Loading