feat: add country combobox (#43)

* feat: add country combobox

* refactor: change to a switch statement
This commit is contained in:
Fishlex0 2026-06-28 23:29:53 +03:00 committed by GitHub
parent e9955b46cb
commit e4a90d6767
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 193 additions and 56 deletions

View File

@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(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 (
<div ref={containerRef} className={`relative ${className}`}>
<button
type="button"
className="select select-bordered flex w-full items-center justify-between gap-2 text-left font-normal"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((prev) => !prev)}
>
<span className="truncate">{selected?.label ?? "Select country"}</span>
</button>
{open ? (
<div className="fixed z-30 mt-2 w-full max-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<label className="flex items-center gap-2 rounded-lg border border-base-300 px-3 py-2 focus-within:border-primary">
<Search className="size-4 shrink-0 text-base-content/50" />
<input
ref={inputRef}
type="text"
className="grow min-w-0 bg-transparent text-sm outline-none placeholder:text-base-content/40"
placeholder="Search countries"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActiveIndex(0);
}}
onKeyDown={handleKeyDown}
/>
</label>
<ul
ref={listRef}
role="listbox"
className="menu mt-2 max-h-64 w-full flex-nowrap overflow-y-auto p-0"
>
{filtered.length === 0 ? (
<li className="w-full break-all px-3 py-2 text-sm text-base-content/50">
No countries match {query.trim()}
</li>
) : (
filtered.map((option, index) => {
const isSelected = option.code === value;
return (
<li
key={option.code}
role="option"
aria-selected={isSelected}
>
<button
type="button"
className={`w-full ${index === activeIndex ? "menu-focus" : ""}`}
onClick={() => select(option)}
onMouseEnter={() => setActiveIndex(index)}
>
<span className="flex-1 truncate">{option.label}</span>
{isSelected ? (
<Check className="size-4 shrink-0 text-primary" />
) : null}
</button>
</li>
);
})
)}
</ul>
</div>
) : null}
</div>
);
}

View File

@ -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({
<controlsForm.Field name="locationCode">
{(field) => (
<select
className="select select-bordered shrink-0"
<LocationSelect
value={field.state.value}
onChange={(event) => {
const next = Number(event.target.value);
field.handleChange(next);
onLocationChange(next);
options={LABS_LOCATION_OPTIONS}
className="w-full lg:w-44 lg:shrink-0"
onChange={(code) => {
field.handleChange(code);
onLocationChange(code);
}}
>
{LABS_LOCATION_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
/>
)}
</controlsForm.Field>

View File

@ -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) {
<div className="grid grid-cols-2 gap-2 lg:contents">
<controlsForm.Field name="locationCode">
{(field) => (
<select
className="select select-bordered w-full lg:w-auto lg:shrink-0"
<LocationSelect
value={field.state.value}
onChange={(event) =>
field.handleChange(Number(event.target.value))
}
>
{LOCATION_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
onChange={(code) => field.handleChange(code)}
className="w-full lg:w-44 lg:shrink-0"
/>
)}
</controlsForm.Field>

View File

@ -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 }) {
<span className="text-sm font-medium">
This is the country we will use when getting SEO data.
</span>
<select
className="select select-bordered w-full"
value={locationCode}
onChange={(event) => setLocationCode(Number(event.target.value))}
>
{LOCATION_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
<LocationSelect value={locationCode} onChange={setLocationCode} />
</label>
<button

View File

@ -17,10 +17,10 @@ import {
estimateRankCheckCredits,
} from "@/shared/rank-tracking";
import {
LOCATION_OPTIONS,
DEFAULT_LOCATION_CODE,
getLanguageCode,
} from "@/client/features/keywords/locations";
import { LocationSelect } from "@/client/components/LocationSelect";
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
type Props = {
@ -188,17 +188,7 @@ export function RankTrackingConfigModal({
<label className="label">
<span className="label-text font-medium">Country</span>
</label>
<select
className="select select-bordered w-full"
value={locationCode}
onChange={(e) => setLocationCode(Number(e.target.value))}
>
{LOCATION_OPTIONS.map((loc) => (
<option key={loc.code} value={loc.code}>
{loc.label}
</option>
))}
</select>
<LocationSelect value={locationCode} onChange={setLocationCode} />
</div>
<div className="form-control">