OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / web / static / toast.js

63 lines · javascript ref:
// toast — the one place a failure gets SAID.
//
// THE PROBLEM. Every client-side write already handles its own failure correctly: actions.js puts the
// original state back rather than leaving a lie on screen. But the only place it says WHY is a `title`
// attribute and a 1.2s red flash. A `title` needs a hover, so on touch it does not exist at all, and no
// screen reader announces it. So the honest state of things was: the write failed, the UI silently
// reverted, and the user's reasonable reading is "my tap didn't register" — they tap again.
//
// Silent failure is the worst class of bug in this codebase and we keep re-learning it (hyper-social #24,
// #26: "everything we control reports success"). A control that reverts without saying why is the same
// mistake at the UI layer.
//
// SHAPE. One shared live region, appended once, reused. `role="status"` + `aria-live="polite"` so it is
// announced without stealing focus; assertive is reserved for errors, which ARE interruptions.
//
// Deliberately NOT a dependency of the no-JS path: this file only ever runs as progressive enhancement,
// and nothing renders differently when it is absent. If it fails to load, the pre-existing flash + title
// behaviour is still there — this is additive.

let host = null

function ensureHost () {
  if (host && host.isConnected) return host
  host = document.createElement('div')
  host.className = 'toasts'
  // The region must exist in the DOM BEFORE the message is written into it, or assistive tech may not
  // announce a node that appeared already-populated. Hence a persistent host with children swapped in.
  host.setAttribute('role', 'status')
  host.setAttribute('aria-live', 'polite')
  document.body.appendChild(host)
  return host
}

// msg  — plain text, never HTML (it carries server error strings, which are untrusted)
// kind — 'error' | 'info'. Errors are assertive and linger; info is polite and brief.
export function toast (msg, { kind = 'error', ms = null } = {}) {
  const text = String(msg == null ? '' : msg).trim()
  if (!text) return
  const h = ensureHost()
  h.setAttribute('aria-live', kind === 'error' ? 'assertive' : 'polite')

  const el = document.createElement('div')
  el.className = 'toast ' + (kind === 'error' ? 'toast-err' : 'toast-info')
  el.textContent = text // textContent, NOT innerHTML — this string comes off the wire

  // A dismiss control, because an error that vanishes on a timer is unreadable to anyone who reads
  // slowly, and one that never vanishes is litter. Both: it auto-dismisses, and you can kill it early.
  const close = document.createElement('button')
  close.className = 'toast-x'
  close.setAttribute('aria-label', 'dismiss')
  close.textContent = '✕'
  close.addEventListener('click', () => el.remove())
  el.appendChild(close)

  h.appendChild(el)
  const life = Number.isFinite(ms) ? ms : (kind === 'error' ? 6000 : 3000)
  setTimeout(() => { el.remove() }, life)
  // Never let the region grow without bound if something fails in a loop.
  while (h.children.length > 4) h.removeChild(h.firstChild)
}

export default toast