diff --git a/src/client/components/LocationSelect.tsx b/src/client/components/LocationSelect.tsx new file mode 100644 index 0000000..a175c32 --- /dev/null +++ b/src/client/components/LocationSelect.tsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Check, Search } from "lucide-react"; +import { LOCATION_OPTIONS } from "@/shared/keyword-locations"; + +type LocationOption = (typeof LOCATION_OPTIONS)[number]; + +type Props = { + value: number; + onChange: (locationCode: number) => void; + /** Defaults to the full country list. Pass a subset (e.g. Labs-only). */ + options?: readonly LocationOption[]; + /** Width utilities for the wrapper/trigger. Defaults to full width. */ + className?: string; +}; + +function matches(option: LocationOption, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + return ( + option.label.toLowerCase().includes(needle) || + option.shortLabel.toLowerCase().includes(needle) + ); +} + +/** + * Searchable country picker. Allows users to filter the country list instead of + * scrolling it. The scrollable list is preserved below the search input. + */ +export function LocationSelect({ + value, + onChange, + options = LOCATION_OPTIONS, + className = "w-full", +}: Props) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + + const containerRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + + const selected = options.find((option) => option.code === value) ?? null; + + const filtered = useMemo( + () => options.filter((option) => matches(option, query)), + [options, query], + ); + + // Reset transient state and focus the search input each time the menu opens. + useEffect(() => { + if (!open) return; + setQuery(""); + setActiveIndex(0); + inputRef.current?.focus(); + }, [open]); + + // Close on outside click so it behaves like the surrounding native selects. + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Node && !containerRef.current?.contains(target)) { + setOpen(false); + } + }; + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [open]); + + // Keep the highlighted option in view as the user arrows through results. + useEffect(() => { + if (!open) return; + const activeItem = listRef.current?.children[activeIndex]; + activeItem?.scrollIntoView({ block: "nearest" }); + }, [activeIndex, open]); + + const select = (option: LocationOption) => { + onChange(option.code); + setOpen(false); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setActiveIndex((index) => Math.min(index + 1, filtered.length - 1)); + break; + case "ArrowUp": + event.preventDefault(); + setActiveIndex((index) => Math.max(index - 1, 0)); + break; + case "Enter": { + event.preventDefault(); + const option = filtered[activeIndex]; + if (option) select(option); + break; + } + case "Escape": + event.preventDefault(); + setOpen(false); + break; + } + }; + + return ( +
+ + + {open ? ( +
+ + +
    + {filtered.length === 0 ? ( +
  • + No countries match “{query.trim()}” +
  • + ) : ( + filtered.map((option, index) => { + const isSelected = option.code === value; + return ( +
  • + +
  • + ); + }) + )} +
+
+ ) : null} +
+ ); +} diff --git a/src/client/features/domain/components/DomainSearchCard.tsx b/src/client/features/domain/components/DomainSearchCard.tsx index 5d1011b..2a77c64 100644 --- a/src/client/features/domain/components/DomainSearchCard.tsx +++ b/src/client/features/domain/components/DomainSearchCard.tsx @@ -5,6 +5,7 @@ import type { DomainOverviewControlsForm } from "@/client/features/domain/Domain import { toSortMode } from "@/client/features/domain/utils"; import type { DomainSortMode } from "@/client/features/domain/types"; import { LABS_LOCATION_OPTIONS } from "@/client/features/keywords/locations"; +import { LocationSelect } from "@/client/components/LocationSelect"; type Props = { controlsForm: DomainOverviewControlsForm; @@ -54,21 +55,15 @@ export function DomainSearchCard({ {(field) => ( - + /> )} diff --git a/src/client/features/keywords/page/KeywordResearchSearchBar.tsx b/src/client/features/keywords/page/KeywordResearchSearchBar.tsx index c8d9d5e..6349ac3 100644 --- a/src/client/features/keywords/page/KeywordResearchSearchBar.tsx +++ b/src/client/features/keywords/page/KeywordResearchSearchBar.tsx @@ -8,10 +8,8 @@ import { MAX_KEYWORDS_PER_SUBMIT, RESULT_LIMITS, } from "@/client/features/keywords/keywordResearchTypes"; -import { - LOCATION_OPTIONS, - isLabsLocationCode, -} from "@/client/features/keywords/locations"; +import { isLabsLocationCode } from "@/client/features/keywords/locations"; +import { LocationSelect } from "@/client/components/LocationSelect"; import type { KeywordResearchControllerState } from "./types"; type Props = { @@ -73,19 +71,11 @@ export function KeywordResearchSearchBar({ controller }: Props) {
{(field) => ( - + onChange={(code) => field.handleChange(code)} + className="w-full lg:w-44 lg:shrink-0" + /> )} diff --git a/src/client/features/onboarding/OnboardingChat.tsx b/src/client/features/onboarding/OnboardingChat.tsx index d427b6f..b02191f 100644 --- a/src/client/features/onboarding/OnboardingChat.tsx +++ b/src/client/features/onboarding/OnboardingChat.tsx @@ -2,10 +2,8 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { AutumnProvider } from "autumn-js/react"; import { useState } from "react"; import { Loader2 } from "lucide-react"; -import { - DEFAULT_LOCATION_CODE, - LOCATION_OPTIONS, -} from "@/shared/keyword-locations"; +import { DEFAULT_LOCATION_CODE } from "@/shared/keyword-locations"; +import { LocationSelect } from "@/client/components/LocationSelect"; import { useSession } from "@/lib/auth-client"; import { saveOnboardingSite } from "@/serverFunctions/onboardingChat"; import { OnboardingAccountMenu } from "./OnboardingAccountMenu"; @@ -116,17 +114,7 @@ function SiteForm({ projectId }: { projectId: string }) { This is the country we will use when getting SEO data. - +