perfect-postcode/frontend/src/components/ui/ExportMenu.tsx
2026-05-26 19:45:13 +01:00

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>
</>
);
}