221 lines
8.1 KiB
TypeScript
221 lines
8.1 KiB
TypeScript
import { useEffect, useId, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { CloseIcon } from './icons/CloseIcon';
|
|
import { DownloadIcon } from './icons/DownloadIcon';
|
|
import { SpinnerIcon } from './icons/SpinnerIcon';
|
|
|
|
export type ExportMode = 'filters' | 'list';
|
|
|
|
interface ExportMenuProps {
|
|
open: boolean;
|
|
exporting: boolean;
|
|
onClose: () => void;
|
|
onExport: (options?: { postcodes?: string[] }) => void;
|
|
}
|
|
|
|
const EMPTY_LIST: string[] = [''];
|
|
|
|
export default function ExportMenu({ open, exporting, onClose, onExport }: ExportMenuProps) {
|
|
const { t } = useTranslation();
|
|
const [mode, setMode] = useState<ExportMode>('filters');
|
|
const [postcodes, setPostcodes] = useState<string[]>(EMPTY_LIST);
|
|
const titleId = useId();
|
|
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
|
const focusIndexRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, [open, onClose]);
|
|
|
|
useEffect(() => {
|
|
if (focusIndexRef.current == null) return;
|
|
inputRefs.current[focusIndexRef.current]?.focus();
|
|
focusIndexRef.current = null;
|
|
}, [postcodes.length]);
|
|
|
|
if (!open) return null;
|
|
|
|
const cleaned = postcodes.map((p) => p.trim()).filter(Boolean);
|
|
const canSubmit = !exporting && (mode === 'filters' || cleaned.length > 0);
|
|
|
|
const handleSubmit = () => {
|
|
if (!canSubmit) return;
|
|
if (mode === 'list') {
|
|
onExport({ postcodes: cleaned });
|
|
} else {
|
|
onExport();
|
|
}
|
|
};
|
|
|
|
const updateAt = (idx: number, value: string) => {
|
|
setPostcodes((prev) => prev.map((p, i) => (i === idx ? value : p)));
|
|
};
|
|
|
|
const addRow = () => {
|
|
setPostcodes((prev) => {
|
|
focusIndexRef.current = prev.length;
|
|
return [...prev, ''];
|
|
});
|
|
};
|
|
|
|
const removeAt = (idx: number) => {
|
|
setPostcodes((prev) => {
|
|
if (prev.length <= 1) return [''];
|
|
return prev.filter((_, i) => i !== idx);
|
|
});
|
|
};
|
|
|
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, idx: number) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (idx === postcodes.length - 1 && postcodes[idx].trim()) {
|
|
addRow();
|
|
} else {
|
|
inputRefs.current[idx + 1]?.focus();
|
|
}
|
|
} else if (
|
|
e.key === 'Backspace' &&
|
|
postcodes[idx] === '' &&
|
|
postcodes.length > 1
|
|
) {
|
|
e.preventDefault();
|
|
focusIndexRef.current = Math.max(0, idx - 1);
|
|
removeAt(idx);
|
|
}
|
|
};
|
|
|
|
const cardClass = (selected: boolean) =>
|
|
`w-full text-left cursor-pointer rounded-lg border p-3 transition-colors ${
|
|
selected
|
|
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
|
|
: 'border-warm-200 dark:border-warm-700 hover:border-warm-300 dark:hover:border-warm-600 bg-white dark:bg-warm-800'
|
|
}`;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-[90]"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
className="fixed left-1/2 top-1/2 z-[91] -translate-x-1/2 -translate-y-1/2 w-[92vw] max-w-md bg-white dark:bg-warm-800 rounded-lg shadow-xl flex flex-col max-h-[90vh]"
|
|
>
|
|
<div className="flex items-center justify-between px-4 h-12 border-b border-warm-200 dark:border-warm-700 shrink-0">
|
|
<span id={titleId} className="font-semibold text-navy-950 dark:text-warm-100">
|
|
{t('export.title')}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t('common.close')}
|
|
className="flex cursor-pointer items-center justify-center w-9 h-9 -mr-2 rounded hover:bg-warm-100 dark:hover:bg-warm-700 transition-colors"
|
|
>
|
|
<CloseIcon className="w-5 h-5 text-warm-700 dark:text-warm-300" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-3 overflow-y-auto">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode('filters')}
|
|
className={cardClass(mode === 'filters')}
|
|
>
|
|
<div className="font-medium text-navy-950 dark:text-warm-100">
|
|
{t('export.modeFilters')}
|
|
</div>
|
|
<div className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
|
{t('export.modeFiltersHint')}
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode('list')}
|
|
className={cardClass(mode === 'list')}
|
|
>
|
|
<div className="font-medium text-navy-950 dark:text-warm-100">
|
|
{t('export.modeList')}
|
|
</div>
|
|
<div className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
|
{t('export.modeListHint')}
|
|
</div>
|
|
</button>
|
|
|
|
{mode === 'list' && (
|
|
<div className="flex flex-col gap-2 pt-1">
|
|
<span className="text-xs font-medium text-warm-600 dark:text-warm-300">
|
|
{t('export.listLabel')}
|
|
</span>
|
|
<div className="flex flex-col gap-1.5">
|
|
{postcodes.map((value, idx) => (
|
|
<div key={idx} className="flex items-center gap-1.5">
|
|
<input
|
|
ref={(el) => {
|
|
inputRefs.current[idx] = el;
|
|
}}
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => updateAt(idx, e.target.value)}
|
|
onKeyDown={(e) => handleInputKeyDown(e, idx)}
|
|
placeholder={t('export.listPlaceholder')}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="flex-1 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 px-3 py-1.5 text-sm text-navy-950 dark:text-warm-100 font-mono uppercase placeholder:normal-case placeholder:font-sans placeholder:text-warm-400 focus:outline-none focus:border-teal-500"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeAt(idx)}
|
|
disabled={postcodes.length === 1 && !value}
|
|
aria-label={t('export.removeRow')}
|
|
className="flex cursor-pointer items-center justify-center w-8 h-8 rounded text-warm-500 hover:text-warm-700 hover:bg-warm-100 dark:hover:bg-warm-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<CloseIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={addRow}
|
|
className="self-start flex cursor-pointer items-center gap-1 text-sm text-teal-700 dark:text-teal-300 hover:text-teal-800 dark:hover:text-teal-200 mt-0.5"
|
|
>
|
|
<span aria-hidden="true" className="text-base leading-none">
|
|
+
|
|
</span>
|
|
{t('export.addRow')}
|
|
</button>
|
|
<div className="text-xs text-warm-500 dark:text-warm-400">
|
|
{t('export.listCount', { count: cleaned.length })}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-4 py-3 border-t border-warm-200 dark:border-warm-700 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={!canSubmit}
|
|
className="w-full flex cursor-pointer items-center justify-center gap-2 px-4 py-2 rounded bg-teal-600 hover:bg-teal-700 text-white text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{exporting ? (
|
|
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<DownloadIcon className="w-4 h-4" />
|
|
)}
|
|
{exporting ? t('header.exporting') : t('header.exportLabel')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|