authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-07 19:13:04-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 19:13:04-03:00
logc8ec7df12415140c6413372f53e3be4fe2e959f8
tree7434d3e9259f34c7f67094e46de97768c5eb1c00
parent577b6cc221d411cfed31cba0e6d3861de2640a55
signature Signed by PGP key B5690EEEBB952194

fix(web): make settings project scopes searchable and scrollable (#10570)


3 files changed, 97 insertions(+), 72 deletions(-)

apps/web/src/components/Sidebar.tsx+29-43
......@@ -208,7 +208,7 @@ import { Input } from "./ui/input";
208208import {
209209 Combobox,
210210 ComboboxEmpty,
211 ComboboxInput,
211 ComboboxSearchInput,
212212 ComboboxItem,
213213 ComboboxList,
214214 ComboboxPopup,
......@@ -4395,48 +4395,34 @@ export default function Sidebar() {
43954395 align="start"
43964396 className="w-(--anchor-width) min-w-0 overflow-hidden"
43974397 >
4398 <div className="shrink-0 px-3 pt-2.5">
4399 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
4400 <SearchIcon
4401 aria-hidden="true"
4402 className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
4403 />
4404 <ComboboxInput
4405 aria-label="Search projects"
4406 className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
4407 inputClassName="rounded-none bg-transparent text-sm"
4408 placeholder="Search projects..."
4409 showTrigger={false}
4410 size="sm"
4411 unstyled
4412 value={projectScopeMenuState.query}
4413 onKeyDown={(event) => {
4414 if (
4415 event.defaultPrevented ||
4416 event.nativeEvent.isComposing ||
4417 event.ctrlKey ||
4418 event.altKey ||
4419 event.metaKey ||
4420 (event.key !== "ContextMenu" &&
4421 !(event.shiftKey && event.key === "F10"))
4422 ) {
4423 return;
4424 }
4425 // Combobox items use virtual focus: keyboard events
4426 // stay on this input, not on the highlighted option.
4427 const scopeKey = highlightedProjectScopeKeyRef.current;
4428 const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null;
4429 if (project) handleProjectSettings(event, project);
4430 }}
4431 onChange={(event) =>
4432 dispatchProjectScopeMenu({
4433 type: "query-changed",
4434 query: event.target.value,
4435 })
4436 }
4437 />
4438 </div>
4439 </div>
4398 <ComboboxSearchInput
4399 aria-label="Search projects"
4400 placeholder="Search projects..."
4401 value={projectScopeMenuState.query}
4402 onKeyDown={(event) => {
4403 if (
4404 event.defaultPrevented ||
4405 event.nativeEvent.isComposing ||
4406 event.ctrlKey ||
4407 event.altKey ||
4408 event.metaKey ||
4409 (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
4410 ) {
4411 return;
4412 }
4413 // Combobox items use virtual focus: keyboard events
4414 // stay on this input, not on the highlighted option.
4415 const scopeKey = highlightedProjectScopeKeyRef.current;
4416 const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null;
4417 if (project) handleProjectSettings(event, project);
4418 }}
4419 onChange={(event) =>
4420 dispatchProjectScopeMenu({
4421 type: "query-changed",
4422 query: event.target.value,
4423 })
4424 }
4425 />
44404426 <ComboboxEmpty>No matching projects.</ComboboxEmpty>
44414427 <ComboboxList>
44424428 {(item: (typeof projectScopeItems)[number]) => {
apps/web/src/components/settings/ProjectsSettings.tsx+45-28
......@@ -1,12 +1,22 @@
11import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { FolderIcon } from "lucide-react";
3import type { ReactNode } from "react";
2import { ChevronDownIcon, FolderIcon } from "lucide-react";
3import { type ReactNode, useState } from "react";
44import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
55import { ProjectFavicon } from "../ProjectFavicon";
66import { WorkspacePageContainer } from "../WorkspacePageContainer";
77import { useEnvironments } from "../../state/environments";
88import { Toggle, ToggleGroup } from "../ui/toggle-group";
9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
9import {
10 Combobox,
11 ComboboxEmpty,
12 ComboboxSearchInput,
13 ComboboxItem,
14 ComboboxList,
15 ComboboxPopup,
16 ComboboxTrigger,
17} from "../ui/combobox";
18import { selectTriggerVariants } from "../ui/select";
19import { cn } from "../../lib/utils";
1020import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel";
1121import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
1222
......@@ -21,44 +31,51 @@ function ScopePicker({
2131 options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>;
2232 onChange: (value: string | null) => void;
2333}) {
34 const [query, setQuery] = useState("");
2435 const selected = options.find((option) => option.value === value);
2536 const allIcon =
2637 label === "project" ? <FolderIcon aria-hidden className="size-3.5 shrink-0" /> : null;
38 const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options];
2739 return (
28 <Select
29 value={value ?? "all"}
40 <Combobox
41 items={items}
42 value={items.find((item) => item.value === (value ?? "all")) ?? null}
43 inputValue={query}
44 onInputValueChange={setQuery}
45 onOpenChange={() => setQuery("")}
3046 onValueChange={(next) => {
31 if (next) onChange(next === "all" ? null : next);
47 if (next) onChange(next.value === "all" ? null : next.value);
3248 }}
3349 >
34 <SelectTrigger
35 size="compact"
50 <ComboboxTrigger
3651 aria-label={`${label === "project" ? "Project" : "Machine"} scope`}
37 className="w-auto min-w-0 max-w-52"
52 className={cn(selectTriggerVariants({ size: "compact" }), "w-auto min-w-0 max-w-52")}
3853 >
39 <SelectValue className="flex min-w-0 items-center gap-1.5">
54 <span className="flex min-w-0 items-center gap-1.5">
4055 {value === null ? allIcon : selected?.icon}
4156 <span className="truncate">
4257 {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)}
4358 </span>
44 </SelectValue>
45 </SelectTrigger>
46 <SelectPopup align="start" alignItemWithTrigger={false}>
47 <SelectItem value="all">
48 <span className="flex items-center gap-2">
49 {allIcon}All {label}s
50 </span>
51 </SelectItem>
52 {options.map((option) => (
53 <SelectItem key={option.value} value={option.value}>
54 <span className="flex items-center gap-2">
55 {option.icon}
56 {option.label}
57 </span>
58 </SelectItem>
59 ))}
60 </SelectPopup>
61 </Select>
59 </span>
60 <ChevronDownIcon aria-hidden className="-me-1 size-3 opacity-50" />
61 </ComboboxTrigger>
62 <ComboboxPopup align="start" className="w-64">
63 <ComboboxSearchInput aria-label={`Search ${label}s`} placeholder={`Search ${label}s...`} />
64 <ComboboxEmpty>No matching {label}s.</ComboboxEmpty>
65 <ComboboxList>
66 {(item: (typeof items)[number]) => (
67 <ComboboxItem
68 key={item.value}
69 value={item}
70 contentClassName="flex min-w-0 items-center gap-2"
71 >
72 {item.icon}
73 <span className="truncate">{item.label}</span>
74 </ComboboxItem>
75 )}
76 </ComboboxList>
77 </ComboboxPopup>
78 </Combobox>
6279 );
6380}
6481
apps/web/src/components/ui/combobox.tsx+23-1
......@@ -1,7 +1,7 @@
11"use client";
22
33import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
4import { ChevronsUpDownIcon, XIcon } from "lucide-react";
4import { ChevronsUpDownIcon, SearchIcon, XIcon } from "lucide-react";
55import * as React from "react";
66
77import { cn } from "~/lib/utils";
......@@ -130,6 +130,27 @@ function ComboboxInput({
130130 );
131131}
132132
133function ComboboxSearchInput(props: React.ComponentProps<typeof ComboboxInput>) {
134 return (
135 <div className="shrink-0 px-3 pt-2.5">
136 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
137 <SearchIcon
138 aria-hidden="true"
139 className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
140 />
141 <ComboboxInput
142 {...props}
143 className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
144 inputClassName="rounded-none bg-transparent text-sm"
145 showTrigger={false}
146 size="sm"
147 unstyled
148 />
149 </div>
150 </div>
151 );
152}
153
133154function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
134155 return (
135156 <ComboboxPrimitive.Trigger className={className} data-slot="combobox-trigger" {...props}>
......@@ -393,6 +414,7 @@ export {
393414 Combobox,
394415 ComboboxChipsInput,
395416 ComboboxInput,
417 ComboboxSearchInput,
396418 ComboboxTrigger,
397419 ComboboxPopup,
398420 ComboboxItem,