feat(ui): add a dropzone when uploading files from system

- add a dropzone on the whole screen
 - correct z-index according design system
This commit is contained in:
Edouard Vanbelle
2026-07-20 22:15:08 +02:00
parent a520afcf7c
commit c286eed3b2
18 changed files with 100 additions and 10 deletions
+6 -2
View File
@@ -872,7 +872,10 @@
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 50;
/* Search suggestions render above `.page-sticky-header` — otherwise the
dropdown clips under the action bar on the content pages. Design-token
`--z-dropdown` (1000) sits above `--z-sticky` (100) by construction. */
z-index: var(--z-dropdown);
list-style: none;
margin: 0;
padding: 0.25rem;
@@ -1117,7 +1120,8 @@
position: absolute;
bottom: calc(100% + 4px);
right: 0;
z-index: 60;
/* Sits above `--z-sticky` for the same reason as `.suggest` above. */
z-index: var(--z-dropdown);
min-width: 12rem;
max-height: 18rem;
overflow: auto;
@@ -902,28 +902,44 @@
// breadcrumb) are handled by the existing `onitemdrop` hooks and use
// a private `application/x-oxi-item` MIME so the `Files` type check
// below never matches them.
let systemDropOver = $state(false);
// Drag-enter/leave chatter is unavoidable when the drag pointer moves
// between the wrapper and its descendants — the browser fires
// `dragleave` on the parent BEFORE firing `dragenter` on the child,
// so a naive `systemDropOver = false` in the leave handler produces
// a false→true flash on every row hover during the drag. Counter
// approach: increment on every dragenter, decrement on every
// dragleave; the overlay is visible when the count is positive. The
// count zeroes only when the drag has truly left the wrapper (or
// hit `drop`/`dragend`), so the overlay stays stable throughout.
let systemDragDepth = $state(0);
const systemDropOver = $derived(systemDragDepth > 0);
function isSystemDrag(e: DragEvent): boolean {
return !!e.dataTransfer?.types?.includes('Files');
}
function onSystemDragEnter(e: DragEvent) {
if (!isSystemDrag(e)) return;
e.preventDefault();
systemDropOver = true;
systemDragDepth++;
}
function onSystemDragOver(e: DragEvent) {
if (!isSystemDrag(e)) return;
// preventDefault on `dragover` is what tells the browser this
// element accepts drops — without it, `drop` never fires and
// the pointer shows the OS "no-drop" cursor.
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = enableSystemDrop ? 'copy' : 'none';
}
function onSystemDragLeave(e: DragEvent) {
if (!isSystemDrag(e)) return;
systemDropOver = false;
if (systemDragDepth > 0) systemDragDepth--;
}
function onSystemDrop(e: DragEvent) {
if (!isSystemDrag(e)) return;
e.preventDefault();
systemDropOver = false;
// Drop ends the drag; force-clear regardless of counter state
// (a stray unbalanced dragenter would otherwise leave the
// overlay stuck on).
systemDragDepth = 0;
if (enableSystemDrop && onsystemdrop) {
onsystemdrop(e);
} else if (!enableSystemDrop) {
@@ -1138,7 +1154,6 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="rl-root"
class:rl-root--drop-over={systemDropOver && enableSystemDrop}
class:rl-root--rubberbanding={rubberband !== null}
bind:this={rlRoot}
onpointerdown={onRootPointerDown}
@@ -1330,6 +1345,24 @@
aria-hidden="true"
></div>
{/if}
<!-- System-drop overlay. Renders on top of the list ONLY while the
user is dragging OS files over the wrapper AND this surface
accepts them (`enableSystemDrop`). Filled tint + centred
icon+message so the drop target is unmistakable — the dashed
border alone read as decoration on busy folders. `pointer-events:
none` on the container keeps it inert (drag events still hit
`.rl-root` underneath so `dragleave`/`drop` fire correctly). -->
{#if systemDropOver && enableSystemDrop}
<div class="rl-drop-overlay" aria-hidden="true">
<div class="rl-drop-overlay__inner">
<Icon name="cloud-arrow-up" class="rl-drop-overlay__icon" />
<span class="rl-drop-overlay__label">
{t('files.drop_to_upload', 'Drop files here to upload')}
</span>
</div>
</div>
{/if}
</div>
<!-- /.rl-root -->
@@ -1419,13 +1452,50 @@
position: relative;
}
.rl-root--drop-over::after {
content: '';
position: absolute;
/* Viewport-fixed drop overlay. `position: fixed` (not absolute) so
it covers the whole visible browser window regardless of the
user's scroll position — an absolute-inset-0 inside `.rl-root`
would center the card at the middle of the FULL list height,
which sits above the fold on a scrolled folder. The dashed border
also gets painted at the true viewport edge, so the sticky
action-bar + breadcrumb are covered rather than clipping the
border. `pointer-events: none` so drag events still fall through
to `.rl-root`'s handlers underneath.
`--z-overlay` beats `--z-sticky` (page chrome) + `--z-dropdown`
(search suggestions); stays below `--z-modal` so a modal opened
concurrently still wins. */
.rl-drop-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
border: 2px dashed var(--color-accent);
border-radius: var(--radius-md);
pointer-events: none;
z-index: var(--z-overlay);
}
.rl-drop-overlay__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-6) var(--space-8);
color: var(--color-accent);
background: var(--color-bg-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.rl-drop-overlay :global(.rl-drop-overlay__icon) {
font-size: 3rem;
}
.rl-drop-overlay__label {
font-weight: var(--weight-semibold);
font-size: var(--text-lg);
}
/* ── Rubberband (marquee) selection ────────────────────────────