live update
This commit is contained in:
235
src/components/SheetDropzone.tsx
Normal file
235
src/components/SheetDropzone.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useRef, useState, type DragEvent } from 'react';
|
||||
import { FileSpreadsheet, Trash2, Upload } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The workbook dropzone.
|
||||
*
|
||||
* Astryx's `FileInput` in dropzone mode is a white slab with a dashed rule and
|
||||
* a bare arrow — correct, and completely mute about what it wants or whether it
|
||||
* is even available. This one says all three things: what it takes, whether it
|
||||
* can take it right now, and what it is holding.
|
||||
*
|
||||
* Three states, and each looks different at a glance rather than on reading:
|
||||
*
|
||||
* - **Waiting** — a tinted panel with a brand-lit icon and an explicit
|
||||
* "Choose file" affordance, because a dropzone that only accepts a drag is
|
||||
* unusable to anyone on a laptop with the file already in a dialog.
|
||||
* - **Dragging** — the brand colour comes up and the panel lifts. A dropzone
|
||||
* that does not visibly react is one people drop next to.
|
||||
* - **Filled** — the file itself, with its size and a way to swap it. The old
|
||||
* control kept saying "Drop an .xlsx here" after you already had.
|
||||
*
|
||||
* It stays a real `<input type="file">` under the surface, so the keyboard, the
|
||||
* screen reader and the OS file dialog all behave as they should — the div is
|
||||
* decoration over an input, not a replacement for one.
|
||||
*/
|
||||
export interface SheetDropzoneProps {
|
||||
file: File | null;
|
||||
onFile: (file: File | null) => void;
|
||||
/** Comma-separated extensions, passed straight to the input. */
|
||||
accept?: string;
|
||||
/** Why the control is unavailable. Present means disabled. */
|
||||
blockedReason?: string;
|
||||
}
|
||||
|
||||
const ACCEPTED = ['.xlsx', '.xls', '.csv'];
|
||||
|
||||
export function SheetDropzone({
|
||||
file,
|
||||
onFile,
|
||||
accept = ACCEPTED.join(','),
|
||||
blockedReason,
|
||||
}: SheetDropzoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isDisabled = Boolean(blockedReason);
|
||||
|
||||
function open() {
|
||||
if (!isDisabled) inputRef.current?.click();
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (isDisabled) return;
|
||||
const dropped = event.dataTransfer.files?.[0];
|
||||
if (dropped) onFile(dropped);
|
||||
}
|
||||
|
||||
/* ── Filled ───────────────────────────────────────────────────────────── */
|
||||
|
||||
if (file) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
flex: 'none',
|
||||
borderRadius: 11,
|
||||
background: 'color-mix(in oklab, var(--color-brand) 12%, transparent)',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
<FileSpreadsheet size={19} />
|
||||
</span>
|
||||
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
|
||||
{formatSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={open} style={ghostButtonStyle}>
|
||||
Replace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFile(null)}
|
||||
aria-label="Remove file"
|
||||
style={{ ...ghostButtonStyle, padding: '7px 9px' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Waiting / dragging ───────────────────────────────────────────────── */
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={open}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
if (!isDisabled) setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
role="button"
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
aria-disabled={isDisabled}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '16px 20px',
|
||||
borderRadius: 13,
|
||||
border: `1.5px dashed ${
|
||||
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
|
||||
}`,
|
||||
background: isDragging
|
||||
? 'color-mix(in oklab, var(--color-brand) 8%, transparent)'
|
||||
: 'color-mix(in oklab, var(--color-brand) 3%, transparent)',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.55 : 1,
|
||||
textAlign: 'center',
|
||||
outline: 'none',
|
||||
transition: 'background .16s ease, border-color .16s ease, transform .16s ease',
|
||||
transform: isDragging ? 'scale(1.004)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
background: isDragging
|
||||
? 'var(--color-brand)'
|
||||
: 'color-mix(in oklab, var(--color-brand) 11%, transparent)',
|
||||
color: isDragging ? '#fff' : 'var(--color-brand)',
|
||||
transition: 'background .16s ease, color .16s ease',
|
||||
}}
|
||||
>
|
||||
<Upload size={16} />
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
|
||||
{isDisabled
|
||||
? blockedReason
|
||||
: isDragging
|
||||
? 'Drop it here'
|
||||
: 'Drop your workbook, or click to browse'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
|
||||
{ACCEPTED.join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ghostButtonStyle: React.CSSProperties = {
|
||||
flex: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '7px 12px',
|
||||
borderRadius: 9,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-ink-2)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
/** kB up to a megabyte, then MB. Nobody needs "1420800 bytes". */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${Math.round(kb)} kB`;
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
Reference in New Issue
Block a user