← Back to examples

Architecture: store + satellites

Live demonstration of <web-dropzone> as a headless store with bound <web-dropzone-picker>, <web-dropzone-list>, and <web-dropzone-indicator> satellite renderers — see ARCHITECTURE.md.

AR01 · The topology in one diagram

Every renderer binds to exactly one store via for="<store-id>". The store dispatches CustomEvents with bubbles + composed, so satellites in their own shadow roots receive them without polling.

<web-dropzone id="uploads"> // the store — FileState[], upload loop, persistence ├── <web-dropzone-picker for="uploads"> // drag/click target ├── <web-dropzone-list for="uploads"> // list / detailed / grid / badges └── <web-dropzone-indicator for="uploads"> // floating chip + slide-out drawer

AR02 · Convenience form

USE CASE A

Nothing new — the existing <web-dropzone> with renderer attributes mounts its own internal picker + list. This is the one-element form that still works unchanged after stage 2 + 3 landed.

<web-dropzone selector-appearance="card" list-appearance="detailed" multiple></web-dropzone>

AR03 · Decoupled picker + list

USE CASE C

The picker sits in a form; the file list lives somewhere else on the page. Both bind to the same headless store. Drop files on the left — the list on the right syncs via the store's bubbling events.

Store

Headless. No renderer attributes → renders nothing itself.

Picker

Card appearance, no list — pure file selector.

List

Detailed appearance, free-standing element.

Picker (in a form)

Attachments:

List (elsewhere on the page)
<web-dropzone id="uploads"></web-dropzone>

<form>
  <web-dropzone-picker for="uploads" selector-appearance="card"></web-dropzone-picker>
</form>

<aside>
  <web-dropzone-list for="uploads" list-appearance="detailed"></web-dropzone-list>
</aside>

AR04 · Multiple independent contexts

MODE A

An avatar uploader and a documents uploader on the same page. Each is its own store: own queue, own list, own validation, own (potential) upload endpoint. Nothing flows between them.

Avatar — single image only
Documents — up to 5 files

AR05 · Shared queue, per-picker upload routing

MODE B

One store, one global list, one global indicator — but each picker stamps its own uploadCallback + uploadMetadata onto every file it contributes. The store's upload loop picks file.uploadCallback ?? config.uploadFileCallback per file, so two pickers feeding the same queue can still route to different endpoints / buckets / tenants.

Picker A — public bucket
Picker B — private bucket
Shared queue
Substrate event log (store fan-out)
Drop files into either picker to see file-added / file-progress / file-status-changed dispatched by the store and consumed by every satellite.
<web-dropzone id="uploads"></web-dropzone>

<web-dropzone-picker id="picker-public"  for="uploads" label="Public"></web-dropzone-picker>
<web-dropzone-picker id="picker-private" for="uploads" label="Private"></web-dropzone-picker>

<web-dropzone-list for="uploads"></web-dropzone-list>

<script>
  document.getElementById('picker-public').uploadCallback  = (file, onProgress, signal, ctx) => uploadTo('/public',  file, onProgress, signal);
  document.getElementById('picker-private').uploadCallback = (file, onProgress, signal, ctx) => uploadTo('/private', file, onProgress, signal);
  // Each file remembers which picker contributed it — the store stamps
  // uploadCallback onto FileState at addFiles time. Mode A files inherit
  // the store-level handler; Mode B files use their own. No store-side
  // routing logic required.
</script>

AR06 · Floating status indicator

USE CASE E

The flagship UX: just a button in the form (button-appearance picker), plus a floating chip pinned to the viewport edge that summarizes the queue and opens a slide-out drawer with the full list. Drop files via the button to see the chip update in real time.

Files:

↘ A floating chip is pinned to the right edge of the page. Click it to slide out the queue.

AR07 · Indicator flexibility: inline, drawer-off, renderCallback

USE CASE E+

The same <web-dropzone-indicator> covers a spectrum of UX when you mix position="inline", drawer="off", and a JS renderCallback. All four below bind to one store so a single drop fans out into every variant.

Drop here:
A · Inline counter — pure text, no pill

Inline placement, default drawer (click to expand). The chip's wrapper is stripped via ::part(chip) so the callback's output reads as inline body text — the line below is one continuous sentence.

Status:

B · Filename + speed — [Uploading xyz.zip @ 12.6 MB/s]

Inline + drawer="off". Callback reads currentUploadingFile and tracks per-tick deltas for the speed.

Now:

C · Silent activity spinner

Inline + drawer="off", callback returns just a spinning glyph while anything is uploading and nothing otherwise.

Sync:

D · Default chip (for reference)

Inline + default drawer + no callback. Same element, no JS configuration — built-in icon + label + percent.

Stock:

import { createDropzoneSpinner } from '@keenmate/web-dropzone'

// A · whole-body callback. `false` → hide the surface.
counter.renderBodyCallback = ({ aggregate }) =>
  aggregate.total === 0
    ? false
    : aggregate.uploading > 0
      ? `<strong>Uploading ${aggregate.complete + 1} / ${aggregate.total}</strong>`
      : `${aggregate.complete} / ${aggregate.total} done`

// B · split across the three slots. body decides visibility,
// fileInfo names the file, progress computes the rate.
speed.renderBodyCallback     = ({ aggregate }) => aggregate.uploading > 0 ? null : false
speed.renderFileInfoCallback = ({ currentFile: f }) => f ? `<strong>${esc(f.name)}</strong>` : ''
speed.renderProgressCallback = ({ currentFile: f }) => f ? `@ ${formatRate(rateOf(f))}` : ''

// C · library's polished spinner, cached so the WAAPI rotation
// never restarts. Identity-checked memoization no-ops every tick.
let cached
spinner.renderBodyCallback = ({ aggregate }) => {
  if (aggregate.uploading === 0) return false
  return (cached ??= createDropzoneSpinner({ ariaLabel: 'Syncing' }))
}

AR08 · Image grid — picker + grid list satellite

USE CASE B

The list satellite renders the same polished appearances as the convenience form. Here the picker accepts images and the grid list satellite generates thumbnails on the fly — same DOM, same CSS, same hover-action overlay as list-appearance="grid" on a classic <web-dropzone>.

Picker
Grid list (satellite)
<web-dropzone id="gallery" multiple accept="image/*"></web-dropzone>

<web-dropzone-picker for="gallery" selector-appearance="card" accept="image/*"></web-dropzone-picker>
<web-dropzone-list   for="gallery" list-appearance="grid"></web-dropzone-list>
USE CASE F

AR09 · Bring your own renderer — mode="headless" + files-changed

The most decoupled story: skip satellites entirely and react to the coalesced files-changed CustomEvent. The store fires it once per animation frame with the current snapshot — your renderer (vanilla DOM, Lit, Svelte, anything) diffs and updates whatever UI it owns. Granular events (file-progress / file-status-changed) still fire if you want per-tick fidelity instead.

Hand-rolled list (no satellite)
    const store = document.getElementById('dz-byo')
    const list  = document.getElementById('byo-list')
    
    store.addEventListener('files-changed', (e) => {
        const { files } = e.detail
        list.replaceChildren(...files.map(f => {
            const li = document.createElement('li')
            li.textContent = `${f.name} — ${f.status} ${Math.round(f.progress)}%`
            return li
        }))
    })