alright
This commit is contained in:
parent
c645b0f1d4
commit
39ef5c6646
79 changed files with 5660 additions and 2199 deletions
221
frontend/src/components/ui/ExportMenu.tsx
Normal file
221
frontend/src/components/ui/ExportMenu.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue