| author | |
| committer | |
| log | c8ec7df12415140c6413372f53e3be4fe2e959f8 |
| tree | 7434d3e9259f34c7f67094e46de97768c5eb1c00 |
| parent | 577b6cc221d411cfed31cba0e6d3861de2640a55 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 97 insertions(+), 72 deletions(-)
apps/web/src/components/Sidebar.tsx+29-43| ... | ... | @@ -208,7 +208,7 @@ import { Input } from "./ui/input"; |
| 208 | 208 | import { |
| 209 | 209 | Combobox, |
| 210 | 210 | ComboboxEmpty, |
| 211 | ComboboxInput, | |
| 211 | ComboboxSearchInput, | |
| 212 | 212 | ComboboxItem, |
| 213 | 213 | ComboboxList, |
| 214 | 214 | ComboboxPopup, |
| ... | ... | @@ -4395,48 +4395,34 @@ export default function Sidebar() { |
| 4395 | 4395 | align="start" |
| 4396 | 4396 | className="w-(--anchor-width) min-w-0 overflow-hidden" |
| 4397 | 4397 | > |
| 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 | /> | |
| 4440 | 4426 | <ComboboxEmpty>No matching projects.</ComboboxEmpty> |
| 4441 | 4427 | <ComboboxList> |
| 4442 | 4428 | {(item: (typeof projectScopeItems)[number]) => { |
apps/web/src/components/settings/ProjectsSettings.tsx+45-28| ... | ... | @@ -1,12 +1,22 @@ |
| 1 | 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 2 | import { FolderIcon } from "lucide-react"; | |
| 3 | import type { ReactNode } from "react"; | |
| 2 | import { ChevronDownIcon, FolderIcon } from "lucide-react"; | |
| 3 | import { type ReactNode, useState } from "react"; | |
| 4 | 4 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 5 | 5 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 6 | 6 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 7 | 7 | import { useEnvironments } from "../../state/environments"; |
| 8 | 8 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 9 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 9 | import { | |
| 10 | Combobox, | |
| 11 | ComboboxEmpty, | |
| 12 | ComboboxSearchInput, | |
| 13 | ComboboxItem, | |
| 14 | ComboboxList, | |
| 15 | ComboboxPopup, | |
| 16 | ComboboxTrigger, | |
| 17 | } from "../ui/combobox"; | |
| 18 | import { selectTriggerVariants } from "../ui/select"; | |
| 19 | import { cn } from "../../lib/utils"; | |
| 10 | 20 | import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel"; |
| 11 | 21 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; |
| 12 | 22 | |
| ... | ... | @@ -21,44 +31,51 @@ function ScopePicker({ |
| 21 | 31 | options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>; |
| 22 | 32 | onChange: (value: string | null) => void; |
| 23 | 33 | }) { |
| 34 | const [query, setQuery] = useState(""); | |
| 24 | 35 | const selected = options.find((option) => option.value === value); |
| 25 | 36 | const allIcon = |
| 26 | 37 | label === "project" ? <FolderIcon aria-hidden className="size-3.5 shrink-0" /> : null; |
| 38 | const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options]; | |
| 27 | 39 | 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("")} | |
| 30 | 46 | onValueChange={(next) => { |
| 31 | if (next) onChange(next === "all" ? null : next); | |
| 47 | if (next) onChange(next.value === "all" ? null : next.value); | |
| 32 | 48 | }} |
| 33 | 49 | > |
| 34 | <SelectTrigger | |
| 35 | size="compact" | |
| 50 | <ComboboxTrigger | |
| 36 | 51 | 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")} | |
| 38 | 53 | > |
| 39 | <SelectValue className="flex min-w-0 items-center gap-1.5"> | |
| 54 | <span className="flex min-w-0 items-center gap-1.5"> | |
| 40 | 55 | {value === null ? allIcon : selected?.icon} |
| 41 | 56 | <span className="truncate"> |
| 42 | 57 | {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} |
| 43 | 58 | </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> | |
| 62 | 79 | ); |
| 63 | 80 | } |
| 64 | 81 |
apps/web/src/components/ui/combobox.tsx+23-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"; |
| 4 | import { ChevronsUpDownIcon, XIcon } from "lucide-react"; | |
| 4 | import { ChevronsUpDownIcon, SearchIcon, XIcon } from "lucide-react"; | |
| 5 | 5 | import * as React from "react"; |
| 6 | 6 | |
| 7 | 7 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -130,6 +130,27 @@ function ComboboxInput({ |
| 130 | 130 | ); |
| 131 | 131 | } |
| 132 | 132 | |
| 133 | function 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 | ||
| 133 | 154 | function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) { |
| 134 | 155 | return ( |
| 135 | 156 | <ComboboxPrimitive.Trigger className={className} data-slot="combobox-trigger" {...props}> |
| ... | ... | @@ -393,6 +414,7 @@ export { |
| 393 | 414 | Combobox, |
| 394 | 415 | ComboboxChipsInput, |
| 395 | 416 | ComboboxInput, |
| 417 | ComboboxSearchInput, | |
| 396 | 418 | ComboboxTrigger, |
| 397 | 419 | ComboboxPopup, |
| 398 | 420 | ComboboxItem, |