189 lines
5.2 KiB
TypeScript
189 lines
5.2 KiB
TypeScript
import React, { useEffect, useId, useMemo, useState } from "react";
|
|
import { Box, Text } from "ink";
|
|
|
|
import { ScrollableList, type ListItemData } from "./List.js";
|
|
import TextInput from "./TextInput.js";
|
|
import { DialogWrapper } from "./Dialog.js";
|
|
import { useInputLayer, useLayeredInput } from "../hooks/useInputLayer.js";
|
|
import { colors } from "../theme.js";
|
|
|
|
/**
|
|
* Props for the currency selection dialog.
|
|
*/
|
|
interface CurrencySelectionDialogProps {
|
|
/** Current wallet currency from persisted settings. */
|
|
currentCurrency: string;
|
|
/** Available fiat numerator symbols that can be paired with BCH. */
|
|
currencies: string[];
|
|
/** True while the dialog is loading available pairs. */
|
|
isLoading: boolean;
|
|
/** Optional loading/error message for pair discovery. */
|
|
errorMessage: string | null;
|
|
/** Called when the user chooses a currency and confirms. */
|
|
onSelectCurrency: (currencyCode: string) => void;
|
|
/** Called when the dialog should close without applying changes. */
|
|
onCancel: () => void;
|
|
}
|
|
|
|
/**
|
|
* Currency picker dialog.
|
|
*
|
|
* UX requirements:
|
|
* - Arrow keys move the highlighted item.
|
|
* - Typing immediately filters results.
|
|
* - Enter applies current selection.
|
|
* - Escape closes without saving.
|
|
*/
|
|
export function CurrencySelectionDialog({
|
|
currentCurrency,
|
|
currencies,
|
|
isLoading,
|
|
errorMessage,
|
|
onSelectCurrency,
|
|
onCancel,
|
|
}: CurrencySelectionDialogProps): React.ReactElement {
|
|
const layerId = useId();
|
|
const [filterText, setFilterText] = useState("");
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
|
|
// Mount this as a capturing input layer so background screens stop handling keys.
|
|
useInputLayer(layerId);
|
|
|
|
/**
|
|
* Applies the currently selected filtered result.
|
|
*/
|
|
const applySelection = (): void => {
|
|
const selectedCurrency = filteredCurrencies[selectedIndex];
|
|
if (!selectedCurrency) {
|
|
return;
|
|
}
|
|
onSelectCurrency(selectedCurrency);
|
|
};
|
|
|
|
useLayeredInput(layerId, (_input, key) => {
|
|
if (key.escape) {
|
|
onCancel();
|
|
return;
|
|
}
|
|
|
|
if (key.upArrow) {
|
|
setSelectedIndex((prev) =>
|
|
prev <= 0 ? Math.max(filteredCurrencies.length - 1, 0) : prev - 1,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (key.downArrow) {
|
|
setSelectedIndex((prev) =>
|
|
filteredCurrencies.length === 0
|
|
? 0
|
|
: prev >= filteredCurrencies.length - 1
|
|
? 0
|
|
: prev + 1,
|
|
);
|
|
return;
|
|
}
|
|
|
|
});
|
|
|
|
/**
|
|
* Filter currencies as the user types.
|
|
*/
|
|
const filteredCurrencies = useMemo(() => {
|
|
const normalizedFilter = filterText.trim().toUpperCase();
|
|
if (!normalizedFilter) {
|
|
return currencies;
|
|
}
|
|
return currencies.filter((currencyCode) =>
|
|
currencyCode.toUpperCase().includes(normalizedFilter),
|
|
);
|
|
}, [currencies, filterText]);
|
|
|
|
/**
|
|
* Keep selected index valid whenever filtering shrinks the result set.
|
|
*/
|
|
useEffect(() => {
|
|
if (filteredCurrencies.length === 0) {
|
|
setSelectedIndex(0);
|
|
return;
|
|
}
|
|
|
|
if (selectedIndex >= filteredCurrencies.length) {
|
|
setSelectedIndex(filteredCurrencies.length - 1);
|
|
}
|
|
}, [filteredCurrencies, selectedIndex]);
|
|
|
|
/**
|
|
* When the dialog opens or the list updates, default to current currency.
|
|
*/
|
|
useEffect(() => {
|
|
if (filterText.trim().length > 0) {
|
|
return;
|
|
}
|
|
|
|
const currentIndex = filteredCurrencies.findIndex(
|
|
(currencyCode) => currencyCode.toUpperCase() === currentCurrency.toUpperCase(),
|
|
);
|
|
if (currentIndex >= 0) {
|
|
setSelectedIndex(currentIndex);
|
|
}
|
|
}, [filteredCurrencies, currentCurrency, filterText]);
|
|
|
|
const listItems: ListItemData<string>[] = filteredCurrencies.map(
|
|
(currencyCode) => ({
|
|
key: currencyCode,
|
|
label: currencyCode,
|
|
description:
|
|
currencyCode.toUpperCase() === currentCurrency.toUpperCase()
|
|
? "(current)"
|
|
: undefined,
|
|
value: currencyCode,
|
|
}),
|
|
);
|
|
|
|
return (
|
|
<DialogWrapper title="Select Fiat Currency" borderColor={colors.info} width={64}>
|
|
<Text color={colors.textMuted}>
|
|
Available BCH quote pairs are loaded from the live rates adapter.
|
|
</Text>
|
|
|
|
<Box marginTop={1}>
|
|
<Text color={colors.primary}>Filter:</Text>
|
|
</Box>
|
|
<Box borderStyle="single" borderColor={colors.focus} paddingX={1}>
|
|
<TextInput
|
|
value={filterText}
|
|
onChange={setFilterText}
|
|
onSubmit={() => applySelection()}
|
|
placeholder="Type currency code (e.g. USD, AUD)..."
|
|
focus
|
|
/>
|
|
</Box>
|
|
|
|
<Box marginTop={1} flexDirection="column">
|
|
{isLoading ? (
|
|
<Text color={colors.textMuted}>Loading available pairs...</Text>
|
|
) : errorMessage ? (
|
|
<Text color={colors.error}>{errorMessage}</Text>
|
|
) : (
|
|
<ScrollableList
|
|
items={listItems}
|
|
selectedIndex={selectedIndex}
|
|
onSelect={setSelectedIndex}
|
|
onActivate={() => applySelection()}
|
|
focus={false}
|
|
maxVisible={8}
|
|
emptyMessage="No BCH quote pairs match this filter."
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
<Box marginTop={1}>
|
|
<Text color={colors.textMuted}>
|
|
Type to filter • ↑↓ navigate • Enter apply • Esc cancel
|
|
</Text>
|
|
</Box>
|
|
</DialogWrapper>
|
|
);
|
|
}
|