Skip to content

observe(): cues for elements appearing, leaving, and changing state - #8

Merged
eakbulut merged 2 commits into
mainfrom
observe-dom
Sep 15, 2026
Merged

eakbulut merged 2 commits into
mainfrom
observe-dom

Conversation

@eakbulut

Copy link
Copy Markdown
Owner

bind() covers what a user does. Nothing covered what the interface does on its own — a toast mounting, a dialog unmounting, a drawer flipping aria-expanded. Those still needed lifecycle hooks, effects, or a state watcher, which is exactly the plumbing the declarative attributes exist to remove.

<div role="status" data-foley-enter="bubble">Saved</div>
<div class="modal" data-foley-exit="whoosh">…</div>
<details data-foley-change="switch" open>…</details>
import { bind, observe } from "@foleyjs/core";
bind();      // interaction
observe();   // appearance and state change

One MutationObserver per root. Opt-in, not folded into bind() — it watches for the life of the page, and most apps only want click sounds.

Callers who skip it pay nothing

bundle min+gzip
bind + play + set (before this PR) 5,924 B
bind + play + set (after) 5,924 B
…plus observe 6,274 B
full API 7,587 B (was 7,235)

observe tree-shakes out completely when unused — byte-identical typical bundle. That's the argument for the opt-in export over starting it inside bind().

Decisions worth reviewing

  • Only state attributes are watched — aria-expanded, aria-selected, aria-checked, aria-pressed, open, data-state, via attributeFilter. Deliberately not class: class churns on every hover and focus and would turn the page into a rattle. Components that signal state with a class should add data-state alongside it.
  • Exits play centered. A removed node is already detached, so getBoundingClientRect() reads all zeros and panFor would resolve to -localize — swinging every exit hard left. Found while writing this; there's a guard pinning the literal 0.
  • Only changes after the call fire, so the initial render is silent by construction.
  • No throttle. 100 rows appearing fire one cue, because the 60ms per-cue cooldown already collapses the storm. Adding a second throttle would be duplicate defense.

Verification

7 new guards (94 tests green). Behavior verified in Chrome, since node has no DOM:

case result
markup present before observe() silent
element added fires
match nested inside an added subtree fires
data-foley-enter="" falls back to bubble
element removed fires
aria-expanded / data-state change fires
class change silent
after stop() silent
observe() twice same stop fn, one cue
100 rows at once 1 cue
far-left enter under localize pans to −0.533
its exit no panner created (centered)

No console errors. node --check on the page module, npm test, npm run build all green.

Scope note

This does not cover "enters the viewport" — that's IntersectionObserver, a different primitive. Both examples that motivated this (toast appearing, drawer opening) are DOM mutations, so MutationObserver is the right tool; scroll-into-view cues would be a separate feature if they're ever wanted.

The demo doesn't use observe() yet. Worth adding so the page demonstrates it rather than just shipping it — happy to do it here or as a follow-up.

Version

No bump on the branch, per the usual. This wants 2.9.0 (new export) with the size claim moved 7.2 → 7.6 kB.

bind() covers what someone does. Nothing covered what the interface does on
its own - a toast mounting, a dialog unmounting, a drawer flipping
aria-expanded - so those still needed lifecycle hooks or effects, which is
the plumbing the declarative attributes exist to avoid.

One MutationObserver per root behind data-foley-enter / -exit / -change.
Opt-in rather than folded into bind(), because it watches for the life of
the page and most apps only want interaction sounds: callers who skip it
pay nothing (typical bundle is 5924 bytes before and after this commit).

Decisions worth keeping:
- Only state attributes are watched (aria-expanded, aria-selected,
  aria-checked, aria-pressed, open, data-state) via attributeFilter. Class
  churns on every hover and would turn the page into a rattle.
- Exits play centered. A removed node is detached, so getBoundingClientRect
  reads zeros and panFor would swing every exit hard left - found while
  writing this, now guarded.
- Only changes after the call fire, so the initial render stays silent.
- No throttle: 100 rows appearing fire one cue, because the 60ms per-cue
  cooldown already collapses them.

Verified in Chrome: enter, nested-in-subtree, valueless-attr default, exit,
aria-expanded, data-state, class-ignored, stop(), double-observe, the
100-row storm, and that a far-left enter pans to -0.533 while its exit
creates no panner at all.
@eakbulut eakbulut changed the title observe(): sound the DOM changing, not just the user observe(): cues for elements appearing, leaving, and changing state Sep 15, 2026
The notification buttons called play() and then built a toast. Now the toast
carries data-foley-enter and observe() sounds it - the page no longer knows
which cue fires or when, which is the whole point of the feature. Deletes
the imperative call rather than adding a demo on top of it.

observe() starts next to bind(); it only reacts to changes after that, so
the initial render stays silent. Quickstart sample shows both calls.
@eakbulut

Copy link
Copy Markdown
Owner Author

Demo wired (7153916).

The three notification buttons used to call play(t.cue) and then build a toast. Now the toast carries data-foley-enter and observe() sounds it — so the conversion deletes the imperative call rather than layering a demo on top of it:

 function toast(kind) {
   const t = TOASTS[kind];
-  play(t.cue);
   const box = document.getElementById("toasts");
   const el = document.createElement("div");
   el.className = "toast";
+  el.setAttribute("data-foley-enter", t.cue);

Verified in Chrome against the real page:

check result
page at rest silent
"New message" button ping
"Deploy finished" chime
"Build failed" error
toast() still calls play()? no
toast element carries data-foley-enter? yes

The page no longer knows which cue fires, or when. observe() starts next to bind(), and since it only reacts to changes after that call, the initial render stays silent. The quickstart sample on the page now shows both calls and a data-foley-enter example.

One thing I deliberately did not wire: the mobile menu. It sets aria-expanded and plays on/off directionally, but data-foley-change takes a single cue name and fires the same one in both directions. Converting it would have lost the open/close distinction — data-foley-toggle remains the right tool for directional state. Worth knowing that -change is direction-agnostic by design.

Also skipped data-foley-exit on toasts: they auto-dismiss after 3.4s, and a whoosh on every timeout is the kind of sound that makes people turn the library off.

@eakbulut
eakbulut merged commit 0889972 into main Sep 15, 2026
2 checks passed
@eakbulut
eakbulut deleted the observe-dom branch September 15, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant