| 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,7 +208,7 @@ import { Input } from "./ui/input"; |
| 208 | import { | 208 | import { |
| 209 | Combobox, | 209 | Combobox, |
| 210 | ComboboxEmpty, | 210 | ComboboxEmpty, |
| 211 | ComboboxInput, | 211 | ComboboxSearchInput, |
| 212 | ComboboxItem, | 212 | ComboboxItem, |
| 213 | ComboboxList, | 213 | ComboboxList, |
| 214 | ComboboxPopup, | 214 | ComboboxPopup, |
| ... | @@ -4395,48 +4395,34 @@ export default function Sidebar() { | ... | @@ -4395,48 +4395,34 @@ export default function Sidebar() { |
| 4395 | align="start" | 4395 | align="start" |
| 4396 | className="w-(--anchor-width) min-w-0 overflow-hidden" | 4396 | className="w-(--anchor-width) min-w-0 overflow-hidden" |
| 4397 | > | 4397 | > |
| 4398 | <div className="shrink-0 px-3 pt-2.5"> | 4398 | <ComboboxSearchInput |
| 4399 | <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring"> | 4399 | aria-label="Search projects" |
| 4400 | <SearchIcon | 4400 | placeholder="Search projects..." |
| 4401 | aria-hidden="true" | 4401 | value={projectScopeMenuState.query} |
| 4402 | className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55" | 4402 | onKeyDown={(event) => { |
| 4403 | /> | 4403 | if ( |
| 4404 | <ComboboxInput | 4404 | event.defaultPrevented || |
| 4405 | aria-label="Search projects" | 4405 | event.nativeEvent.isComposing || |
| 4406 | className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5" | 4406 | event.ctrlKey || |
| 4407 | inputClassName="rounded-none bg-transparent text-sm" | 4407 | event.altKey || |
| 4408 | placeholder="Search projects..." | 4408 | event.metaKey || |
| 4409 | showTrigger={false} | 4409 | (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) |
| 4410 | size="sm" | 4410 | ) { |
| 4411 | unstyled | 4411 | return; |
| 4412 | value={projectScopeMenuState.query} | 4412 | } |
| 4413 | onKeyDown={(event) => { | 4413 | // Combobox items use virtual focus: keyboard events |
| 4414 | if ( | 4414 | // stay on this input, not on the highlighted option. |
| 4415 | event.defaultPrevented || | 4415 | const scopeKey = highlightedProjectScopeKeyRef.current; |
| 4416 | event.nativeEvent.isComposing || | 4416 | const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; |
| 4417 | event.ctrlKey || | 4417 | if (project) handleProjectSettings(event, project); |
| 4418 | event.altKey || | 4418 | }} |
| 4419 | event.metaKey || | 4419 | onChange={(event) => |
| 4420 | (event.key !== "ContextMenu" && | 4420 | dispatchProjectScopeMenu({ |
| 4421 | !(event.shiftKey && event.key === "F10")) | 4421 | type: "query-changed", |
| 4422 | ) { | 4422 | query: event.target.value, |
| 4423 | return; | 4423 | }) |
| 4424 | } | 4424 | } |
| 4425 | // Combobox items use virtual focus: keyboard events | 4425 | /> |
| 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> | ||
| 4440 | <ComboboxEmpty>No matching projects.</ComboboxEmpty> | 4426 | <ComboboxEmpty>No matching projects.</ComboboxEmpty> |
| 4441 | <ComboboxList> | 4427 | <ComboboxList> |
| 4442 | {(item: (typeof projectScopeItems)[number]) => { | 4428 | {(item: (typeof projectScopeItems)[number]) => { |
apps/web/src/components/settings/ProjectsSettings.tsx+45-28| ... | @@ -1,12 +1,22 @@ | ... | @@ -1,12 +1,22 @@ |
| 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 2 | import { FolderIcon } from "lucide-react"; | 2 | import { ChevronDownIcon, FolderIcon } from "lucide-react"; |
| 3 | import type { ReactNode } from "react"; | 3 | import { type ReactNode, useState } from "react"; |
| 4 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | 4 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 5 | import { ProjectFavicon } from "../ProjectFavicon"; | 5 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 6 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; | 6 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 7 | import { useEnvironments } from "../../state/environments"; | 7 | import { useEnvironments } from "../../state/environments"; |
| 8 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | 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 | import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel"; | 20 | import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel"; |
| 11 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; | 21 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; |
| 12 | 22 | ||
| ... | @@ -21,44 +31,51 @@ function ScopePicker({ | ... | @@ -21,44 +31,51 @@ function ScopePicker({ |
| 21 | options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>; | 31 | options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>; |
| 22 | onChange: (value: string | null) => void; | 32 | onChange: (value: string | null) => void; |
| 23 | }) { | 33 | }) { |
| 34 | const [query, setQuery] = useState(""); | ||
| 24 | const selected = options.find((option) => option.value === value); | 35 | const selected = options.find((option) => option.value === value); |
| 25 | const allIcon = | 36 | const allIcon = |
| 26 | label === "project" ? <FolderIcon aria-hidden className="size-3.5 shrink-0" /> : null; | 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 | return ( | 39 | return ( |
| 28 | <Select | 40 | <Combobox |
| 29 | value={value ?? "all"} | 41 | items={items} |
| 42 | value={items.find((item) => item.value === (value ?? "all")) ?? null} | ||
| 43 | inputValue={query} | ||
| 44 | onInputValueChange={setQuery} | ||
| 45 | onOpenChange={() => setQuery("")} | ||
| 30 | onValueChange={(next) => { | 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 | 50 | <ComboboxTrigger |
| 35 | size="compact" | ||
| 36 | aria-label={`${label === "project" ? "Project" : "Machine"} scope`} | 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 | {value === null ? allIcon : selected?.icon} | 55 | {value === null ? allIcon : selected?.icon} |
| 41 | <span className="truncate"> | 56 | <span className="truncate"> |
| 42 | {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} | 57 | {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} |
| 43 | </span> | 58 | </span> |
| 44 | </SelectValue> | 59 | </span> |
| 45 | </SelectTrigger> | 60 | <ChevronDownIcon aria-hidden className="-me-1 size-3 opacity-50" /> |
| 46 | <SelectPopup align="start" alignItemWithTrigger={false}> | 61 | </ComboboxTrigger> |
| 47 | <SelectItem value="all"> | 62 | <ComboboxPopup align="start" className="w-64"> |
| 48 | <span className="flex items-center gap-2"> | 63 | <ComboboxSearchInput aria-label={`Search ${label}s`} placeholder={`Search ${label}s...`} /> |
| 49 | {allIcon}All {label}s | 64 | <ComboboxEmpty>No matching {label}s.</ComboboxEmpty> |
| 50 | </span> | 65 | <ComboboxList> |
| 51 | </SelectItem> | 66 | {(item: (typeof items)[number]) => ( |
| 52 | {options.map((option) => ( | 67 | <ComboboxItem |
| 53 | <SelectItem key={option.value} value={option.value}> | 68 | key={item.value} |
| 54 | <span className="flex items-center gap-2"> | 69 | value={item} |
| 55 | {option.icon} | 70 | contentClassName="flex min-w-0 items-center gap-2" |
| 56 | {option.label} | 71 | > |
| 57 | </span> | 72 | {item.icon} |
| 58 | </SelectItem> | 73 | <span className="truncate">{item.label}</span> |
| 59 | ))} | 74 | </ComboboxItem> |
| 60 | </SelectPopup> | 75 | )} |
| 61 | </Select> | 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,7 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"; | 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 | import * as React from "react"; | 5 | import * as React from "react"; |
| 6 | 6 | ||
| 7 | import { cn } from "~/lib/utils"; | 7 | import { cn } from "~/lib/utils"; |
| ... | @@ -130,6 +130,27 @@ function ComboboxInput({ | ... | @@ -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 | function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) { | 154 | function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) { |
| 134 | return ( | 155 | return ( |
| 135 | <ComboboxPrimitive.Trigger className={className} data-slot="combobox-trigger" {...props}> | 156 | <ComboboxPrimitive.Trigger className={className} data-slot="combobox-trigger" {...props}> |
| ... | @@ -393,6 +414,7 @@ export { | ... | @@ -393,6 +414,7 @@ export { |
| 393 | Combobox, | 414 | Combobox, |
| 394 | ComboboxChipsInput, | 415 | ComboboxChipsInput, |
| 395 | ComboboxInput, | 416 | ComboboxInput, |
| 417 | ComboboxSearchInput, | ||
| 396 | ComboboxTrigger, | 418 | ComboboxTrigger, |
| 397 | ComboboxPopup, | 419 | ComboboxPopup, |
| 398 | ComboboxItem, | 420 | ComboboxItem, |