feat: add country combobox (#43)
* feat: add country combobox * refactor: change to a switch statement
This commit is contained in:
parent
e9955b46cb
commit
e4a90d6767
174
src/client/components/LocationSelect.tsx
Normal file
174
src/client/components/LocationSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user