Skip to content

Commit

Permalink
feat(CurrencySelect): search within popover
Browse files Browse the repository at this point in the history
  • Loading branch information
kripod committed May 20, 2024
1 parent c7890a5 commit 80c9a6f
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 58 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@astrojs/starlight": "0.23.1",
"@astrojs/starlight-tailwind": "2.0.2",
"@astrojs/tailwind": "5.1.0",
"@leeoniya/ufuzzy": "1.0.14",
"astro": "4.8.6",
"clsx": "2.1.1",
"css-homogenizer": "4.0.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

144 changes: 95 additions & 49 deletions src/components/CurrencySelect.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {
Combobox,
ComboboxItem,
ComboboxList,
ComboboxProvider,
Select,
SelectArrow,
SelectItem,
Expand All @@ -9,8 +13,11 @@ import {
} from "@ariakit/react";
import { clsx } from "clsx/lite";
import { getResetClassName } from "css-homogenizer/reset-scoped";
import { startTransition, useMemo, useState } from "react";

import { currencyName } from "../utils/currency";
import { countryFromCurrency } from "../utils/currency";
import { currencyNames, regionNames } from "../utils/intl";
import { fuzzySearch } from "../utils/string";
import { CountryFlag } from "./CountryFlag";
import { useField } from "./Field";

Expand All @@ -28,69 +35,108 @@ export interface CurrencySelectProps<T extends string = string> {
items: readonly T[];
value?: NoInfer<T>;
defaultValue?: NoInfer<T>;
searchPlaceholder?: string;
searchMatcher?: (item: T) => readonly string[];
className?: string;
onChange?: (value: T) => void;
}

function defaultSearchMatcher(item: string) {
return [
item,
currencyNames.of(item) ?? "",
regionNames.of(countryFromCurrency(item)) ?? "",
];
}

export function CurrencySelect<const T extends string>({
name,
items,
value: controlledValue,
defaultValue = controlledValue === undefined ? items[0] : undefined,
searchPlaceholder = "Search by name or country…",
searchMatcher = defaultSearchMatcher,
className,
onChange,
}: CurrencySelectProps<T>) {
const { label } = useField();

const [searchValue, setSearchValue] = useState("");
const matches = useMemo(() => {
const indexes = fuzzySearch(
items.map((item) => searchMatcher(item).join("\n")),
searchValue,
);
return indexes != null ? indexes.map((index) => items[index]!) : null;
}, [items, searchMatcher, searchValue]);

return (
<SelectProvider
defaultValue={defaultValue}
value={controlledValue}
setValue={onChange}
<ComboboxProvider
includesBaseElement={false}
resetValueOnHide
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
>
{label ? (
<SelectLabel render={(props) => <label {...props} />}>
{label}
</SelectLabel>
) : null}
<Select
name={name}
className={clsx(
getResetClassName("button"),
className,
"inline-flex h-10 items-center justify-between gap-x-2 rounded-lg border border-gray-300 px-3 text-start hover:border-gray-700 dark:border-gray-700 dark:hover:border-gray-300",
)}
>
<SelectContextConsumer>
{(store) => {
const value = store?.useState().value;
return typeof value === "string" ? (
<CurrencySelectItemContent currency={value} compact />
) : null;
}}
</SelectContextConsumer>
<SelectArrow />
</Select>
<SelectPopover
portal
gutter={4}
className="z-50 flex max-h-[--popover-available-height] w-80 min-w-[--popover-anchor-width] max-w-[--popover-available-width] flex-col overflow-hidden rounded-lg border border-gray-300 bg-white shadow-lg transition-opacity duration-200 data-[leave]:opacity-0 dark:border-gray-700 dark:bg-gray-900"
<SelectProvider
defaultValue={defaultValue}
value={controlledValue}
setValue={onChange}
>
<div className="scroll-py-1 overflow-auto p-1">
<div className="max-h-64">
{items.map((item) => (
<SelectItem
key={item}
value={item}
className="rounded p-2 data-[active-item]:bg-blue-600 data-[active-item]:text-white dark:data-[active-item]:bg-blue-200 dark:data-[active-item]:text-blue-950"
>
<CurrencySelectItemContent currency={item} />
</SelectItem>
))}
</div>
</div>
</SelectPopover>
</SelectProvider>
{label ? (
<SelectLabel render={(props) => <label {...props} />}>
{label}
</SelectLabel>
) : null}
<Select
name={name}
className={clsx(
getResetClassName("button"),
className,
"inline-flex h-10 items-center justify-between gap-x-2 rounded-lg border border-gray-300 px-3 text-start text-base/none hover:border-gray-700 dark:border-gray-700 dark:hover:border-gray-300",
)}
>
<SelectContextConsumer>
{(store) => {
const value = store?.useState().value;
return typeof value === "string" ? (
<CurrencySelectItemContent currency={value} compact />
) : null;
}}
</SelectContextConsumer>
<SelectArrow />
</Select>
<SelectPopover
portal
gutter={4}
className="z-50 flex max-h-[--popover-available-height] w-80 min-w-[--popover-anchor-width] max-w-[--popover-available-width] flex-col overflow-hidden rounded-lg border border-gray-300 bg-white text-base/none shadow-lg transition-opacity duration-200 data-[leave]:opacity-0 dark:border-gray-700 dark:bg-gray-900"
>
<Combobox
autoSelect
placeholder={searchPlaceholder}
className="m-1 h-8 rounded border border-gray-300 px-2 hover:border-gray-700 dark:border-gray-700 dark:hover:border-gray-300"
/>
<ComboboxList className={"max-h-64 scroll-py-1 overflow-auto p-1"}>
{matches?.length !== 0 ? (
(matches ?? items).map((item) => (
<SelectItem
key={item}
render={(props) => <ComboboxItem {...props} />}
value={item}
className="rounded p-2 data-[active-item]:bg-blue-600 data-[active-item]:text-white dark:data-[active-item]:bg-blue-200 dark:data-[active-item]:text-blue-950"
>
<CurrencySelectItemContent currency={item} />
</SelectItem>
))
) : (
<div className="p-2">No results found</div>
)}
</ComboboxList>
</SelectPopover>
</SelectProvider>
</ComboboxProvider>
);
}

Expand All @@ -103,9 +149,9 @@ function CurrencySelectItemContent({
currency,
compact,
}: CurrencySelectItemContentProps) {
const name = !compact ? currencyName(currency) : null;
const name = !compact ? currencyNames.of(currency) : null;
return (
<span className="flex items-center gap-x-2 text-base/none">
<span className="flex items-center gap-x-2">
<CountryFlag code={currency} intrinsicSize={24} />
{currency} {name != null ? `(${name})` : null}
</span>
Expand Down
11 changes: 2 additions & 9 deletions src/utils/currency.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { SITE_LOCALE } from "./intl";

const CURRENCY_LENGTH = 3;

export const QUOTE_CURRENCY = "HUF";
Expand All @@ -9,13 +7,8 @@ export function isCurrency(value: string) {
return /^[A-Z]{3}$/.test(value);
}

const currencyNames = new Intl.DisplayNames(SITE_LOCALE, {
type: "currency",
fallback: "none",
});

export function currencyName(currency: string) {
return currencyNames.of(currency);
export function countryFromCurrency(currency: string) {
return currency.slice(0, 2);
}

export function currencyPairSymbol(
Expand Down
10 changes: 10 additions & 0 deletions src/utils/intl.ts
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
export const SITE_LOCALE = "en";

export const currencyNames = new Intl.DisplayNames(SITE_LOCALE, {
type: "currency",
fallback: "none",
});

export const regionNames = new Intl.DisplayNames(SITE_LOCALE, {
type: "region",
fallback: "none",
});
13 changes: 13 additions & 0 deletions src/utils/string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import uFuzzy from "@leeoniya/ufuzzy";

const uf = new uFuzzy({ intraMode: 1 satisfies uFuzzy.IntraMode.SingleError });

export function fuzzySearch(haystack: readonly string[], needle: string) {
const [haystackIndexes, , orderIndexes] = uf.search(
haystack as string[], // TODO: Remove assertion
needle,
);
return orderIndexes != null
? orderIndexes.map((index) => haystackIndexes[index]!)
: haystackIndexes;
}

0 comments on commit 80c9a6f

Please sign in to comment.