diff --git a/package.json b/package.json index 8c375f9..7c614ac 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.2.4", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3fa20d..66a4277 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tailwindcss/vite': specifier: ^4.2.4 version: 4.2.4(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)) @@ -640,6 +643,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1964,6 +1980,26 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1)': dependencies: react: 18.3.1 diff --git a/src/components/Combobox.tsx b/src/components/Combobox.tsx new file mode 100644 index 0000000..93f45d8 --- /dev/null +++ b/src/components/Combobox.tsx @@ -0,0 +1,184 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/Popover"; + +type ComboboxOption = { + label: string; + value: string; +}; + +type ComboboxProps = { + value: string; + onValueChange: (value: string) => void; + options: ComboboxOption[]; + ariaLabel: string; + placeholder?: string; + triggerClassName?: string; +}; + +export function Combobox({ + value, + onValueChange, + options, + ariaLabel, + placeholder = "Search", + triggerClassName, +}: ComboboxProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + + const selectedOption = options.find((option) => option.value === value); + const filteredOptions = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + + if (!normalizedQuery) { + return options; + } + + return options.filter((option) => + option.label.toLowerCase().includes(normalizedQuery), + ); + }, [options, query]); + + useEffect(() => { + if (!open) { + return; + } + + inputRef.current?.focus(); + }, [open]); + + useEffect(() => { + const selectedIndex = filteredOptions.findIndex((option) => option.value === value); + + if (selectedIndex >= 0) { + setActiveIndex(selectedIndex); + return; + } + + setActiveIndex(0); + }, [filteredOptions, value]); + + function selectOption(nextValue: string) { + onValueChange(nextValue); + setOpen(false); + setQuery(""); + } + + function moveActiveIndex(nextIndex: number) { + if (filteredOptions.length === 0) { + return; + } + + const clampedIndex = (nextIndex + filteredOptions.length) % filteredOptions.length; + setActiveIndex(clampedIndex); + } + + return ( + { + setOpen(nextOpen); + if (!nextOpen) { + setQuery(""); + } + }} + > + + + + + +
+
+ + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + moveActiveIndex(activeIndex + 1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + moveActiveIndex(activeIndex - 1); + return; + } + + if (event.key === "Enter") { + const activeOption = filteredOptions[activeIndex]; + + if (!activeOption) { + return; + } + + event.preventDefault(); + selectOption(activeOption.value); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + } + }} + placeholder={placeholder} + className="w-full bg-transparent text-[12px] text-content outline-none placeholder:text-content-subtle" + /> +
+ +
+ {filteredOptions.length === 0 ? ( +
+ No results +
+ ) : ( + filteredOptions.map((option, index) => { + const isSelected = option.value === value; + const isActive = index === activeIndex; + + return ( + + ); + }) + )} +
+
+
+
+ ); +} diff --git a/src/components/RouteFilterBar.tsx b/src/components/RouteFilterBar.tsx index 6f1edd7..26098d2 100644 --- a/src/components/RouteFilterBar.tsx +++ b/src/components/RouteFilterBar.tsx @@ -1,13 +1,16 @@ -import { cn } from "@/lib/cn"; +import { Combobox } from "@/components/Combobox"; +import { Select } from "@/components/Select"; type FilterOption = { label: string; - active?: boolean; + value: string; }; type FilterGroup = { label: string; + value: string; options: FilterOption[]; + kind?: "select" | "combobox"; }; type RouteFilterBarProps = { @@ -16,35 +19,32 @@ type RouteFilterBarProps = { export function RouteFilterBar({ groups }: RouteFilterBarProps) { return ( -
+
{groups.map((group) => ( -
- +
+ {group.label} - {group.options.map((option) => ( - - ))} + {group.kind === "combobox" ? ( + undefined} + options={group.options} + ariaLabel={group.label} + placeholder="Search feeds" + triggerClassName="h-8 min-w-36 rounded-[10px] bg-surface-raised text-[12px]" + /> + ) : ( +