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 { toSortMode } from "@/client/features/domain/utils";
|
||||||
import type { DomainSortMode } from "@/client/features/domain/types";
|
import type { DomainSortMode } from "@/client/features/domain/types";
|
||||||
import { LABS_LOCATION_OPTIONS } from "@/client/features/keywords/locations";
|
import { LABS_LOCATION_OPTIONS } from "@/client/features/keywords/locations";
|
||||||
|
import { LocationSelect } from "@/client/components/LocationSelect";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
controlsForm: DomainOverviewControlsForm;
|
controlsForm: DomainOverviewControlsForm;
|
||||||
@ -54,21 +55,15 @@ export function DomainSearchCard({
|
|||||||
|
|
||||||
<controlsForm.Field name="locationCode">
|
<controlsForm.Field name="locationCode">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<select
|
<LocationSelect
|
||||||
className="select select-bordered shrink-0"
|
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => {
|
options={LABS_LOCATION_OPTIONS}
|
||||||
const next = Number(event.target.value);
|
className="w-full lg:w-44 lg:shrink-0"
|
||||||
field.handleChange(next);
|
onChange={(code) => {
|
||||||
onLocationChange(next);
|
field.handleChange(code);
|
||||||
|
onLocationChange(code);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{LABS_LOCATION_OPTIONS.map((option) => (
|
|
||||||
<option key={option.code} value={option.code}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
</controlsForm.Field>
|
</controlsForm.Field>
|
||||||
|
|
||||||
|
|||||||
@ -8,10 +8,8 @@ import {
|
|||||||
MAX_KEYWORDS_PER_SUBMIT,
|
MAX_KEYWORDS_PER_SUBMIT,
|
||||||
RESULT_LIMITS,
|
RESULT_LIMITS,
|
||||||
} from "@/client/features/keywords/keywordResearchTypes";
|
} from "@/client/features/keywords/keywordResearchTypes";
|
||||||
import {
|
import { isLabsLocationCode } from "@/client/features/keywords/locations";
|
||||||
LOCATION_OPTIONS,
|
import { LocationSelect } from "@/client/components/LocationSelect";
|
||||||
isLabsLocationCode,
|
|
||||||
} from "@/client/features/keywords/locations";
|
|
||||||
import type { KeywordResearchControllerState } from "./types";
|
import type { KeywordResearchControllerState } from "./types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -73,19 +71,11 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
|||||||
<div className="grid grid-cols-2 gap-2 lg:contents">
|
<div className="grid grid-cols-2 gap-2 lg:contents">
|
||||||
<controlsForm.Field name="locationCode">
|
<controlsForm.Field name="locationCode">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<select
|
<LocationSelect
|
||||||
className="select select-bordered w-full lg:w-auto lg:shrink-0"
|
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) =>
|
onChange={(code) => field.handleChange(code)}
|
||||||
field.handleChange(Number(event.target.value))
|
className="w-full lg:w-44 lg:shrink-0"
|
||||||
}
|
/>
|
||||||
>
|
|
||||||
{LOCATION_OPTIONS.map((option) => (
|
|
||||||
<option key={option.code} value={option.code}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
</controlsForm.Field>
|
</controlsForm.Field>
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,8 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
import { AutumnProvider } from "autumn-js/react";
|
import { AutumnProvider } from "autumn-js/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import {
|
import { DEFAULT_LOCATION_CODE } from "@/shared/keyword-locations";
|
||||||
DEFAULT_LOCATION_CODE,
|
import { LocationSelect } from "@/client/components/LocationSelect";
|
||||||
LOCATION_OPTIONS,
|
|
||||||
} from "@/shared/keyword-locations";
|
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { saveOnboardingSite } from "@/serverFunctions/onboardingChat";
|
import { saveOnboardingSite } from "@/serverFunctions/onboardingChat";
|
||||||
import { OnboardingAccountMenu } from "./OnboardingAccountMenu";
|
import { OnboardingAccountMenu } from "./OnboardingAccountMenu";
|
||||||
@ -116,17 +114,7 @@ function SiteForm({ projectId }: { projectId: string }) {
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
This is the country we will use when getting SEO data.
|
This is the country we will use when getting SEO data.
|
||||||
</span>
|
</span>
|
||||||
<select
|
<LocationSelect value={locationCode} onChange={setLocationCode} />
|
||||||
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>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -17,10 +17,10 @@ import {
|
|||||||
estimateRankCheckCredits,
|
estimateRankCheckCredits,
|
||||||
} from "@/shared/rank-tracking";
|
} from "@/shared/rank-tracking";
|
||||||
import {
|
import {
|
||||||
LOCATION_OPTIONS,
|
|
||||||
DEFAULT_LOCATION_CODE,
|
DEFAULT_LOCATION_CODE,
|
||||||
getLanguageCode,
|
getLanguageCode,
|
||||||
} from "@/client/features/keywords/locations";
|
} from "@/client/features/keywords/locations";
|
||||||
|
import { LocationSelect } from "@/client/components/LocationSelect";
|
||||||
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
|
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -188,17 +188,7 @@ export function RankTrackingConfigModal({
|
|||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Country</span>
|
<span className="label-text font-medium">Country</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<LocationSelect value={locationCode} onChange={setLocationCode} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user