diff --git a/dev/window/bwin-action-hooks.html b/dev/window/bwin-action-hooks.html new file mode 100644 index 0000000..639a3ba --- /dev/null +++ b/dev/window/bwin-action-hooks.html @@ -0,0 +1,14 @@ + + + + + + + + +
+

Debug

+
+
+ + diff --git a/dev/window/bwin-action-hooks.js b/dev/window/bwin-action-hooks.js new file mode 100644 index 0000000..9c86211 --- /dev/null +++ b/dev/window/bwin-action-hooks.js @@ -0,0 +1,46 @@ +import { BinaryWindow, BUILTIN_ACTIONS } from '../../src'; + +const settings = { + width: 444, + height: 333, + children: [ + { position: 'left', size: '40%', actions: BUILTIN_ACTIONS }, + { + children: [ + { position: 'top', size: '30%' }, + { position: 'bottom', size: '70%' }, + ], + }, + ], +}; + +const bwin = new BinaryWindow(settings); +bwin.mount(document.querySelector('#container')); + +bwin.on('detach', (glassEl) => { + console.log('detach:', glassEl); +}); + +bwin.on('close', (glassEl) => { + console.log('close:', glassEl); +}); + +bwin.on('maximize', (glassEl) => { + console.log('maximize:', glassEl); +}); + +bwin.on('unmaximize', (glassEl) => { + console.log('unmaximize:', glassEl); +}); + +bwin.on('minimize', (glassEl) => { + console.log('minimize:', glassEl); +}); + +bwin.on('restore', (glassEl) => { + console.log('restore:', glassEl); +}); + +bwin.on('attach', (glassEl) => { + console.log('attach:', glassEl); +}); diff --git a/dev/window/bwin-add-remove-panes.js b/dev/window/bwin-add-remove-panes.js index 164ae44..7ca9b5c 100644 --- a/dev/window/bwin-add-remove-panes.js +++ b/dev/window/bwin-add-remove-panes.js @@ -21,25 +21,25 @@ const settings = { const bwin = new BinaryWindow(settings); bwin.mount(document.querySelector('#container')); -bwin.onBeforePaneRemove = (paneSash) => { - console.log('onBeforePaneRemove:', paneSash); - console.log('onBeforePaneRemove - domNode: ', paneSash.domNode); - return true; -}; +bwin.on('before-pane-add', (targetPaneSash) => { + console.log('before-pane-add (target):', targetPaneSash); + // return false to veto the add + // return false; +}); -bwin.onPaneRemove = (paneSash) => { - console.log('onPaneRemove:', paneSash); - console.log('onPaneRemove - domNode: ', paneSash.domNode); -}; +bwin.on('pane-add', (newPaneSash) => { + console.log('pane-add (new):', newPaneSash); +}); -bwin.onBeforePaneAdd = (targetPaneSash) => { - console.log('onBeforePaneAdd:', targetPaneSash); - return true; -}; +bwin.on('before-pane-remove', (paneSash) => { + console.log('before-pane-remove:', paneSash); + // return false to veto the removal + // return false; +}); -bwin.onPaneAdd = (targetPaneSash) => { - console.log('onPaneAdd:', targetPaneSash); -}; +bwin.on('pane-remove', (paneSash) => { + console.log('pane-remove:', paneSash); +}); document.querySelector('#add-pane').addEventListener('click', () => { const parentId = document.querySelector('#sash-id').value.trim(); diff --git a/dev/window/bwin-detached-glass.js b/dev/window/bwin-detached-glass.js index 4e61865..41f369b 100644 --- a/dev/window/bwin-detached-glass.js +++ b/dev/window/bwin-detached-glass.js @@ -160,6 +160,7 @@ document.querySelector('#add-fullscreen').addEventListener('click', () => { width: document.documentElement.clientWidth - EDGE * 2, height: document.documentElement.clientHeight - EDGE * 2, content: createGlassContent('fullscreen'), + modal: true, }); }); diff --git a/dev/window/bwin-detached-windowless-glass.html b/dev/window/bwin-detached-windowless-glass.html index 1099961..3d937ba 100644 --- a/dev/window/bwin-detached-windowless-glass.html +++ b/dev/window/bwin-detached-windowless-glass.html @@ -14,6 +14,7 @@

BinaryWindow - windowless glass

+ diff --git a/dev/window/bwin-detached-windowless-glass.js b/dev/window/bwin-detached-windowless-glass.js index bb42d08..4a4c45f 100644 --- a/dev/window/bwin-detached-windowless-glass.js +++ b/dev/window/bwin-detached-windowless-glass.js @@ -19,6 +19,16 @@ document.querySelector('#add-modal').addEventListener('click', () => { }); }); +// Modal that dismisses itself when the backdrop (not the glass) is clicked. +document.querySelector('#add-modal-close-on-backdrop').addEventListener('click', () => { + BinaryWindow.addWindowlessGlass({ + modal: true, + closeOnBackdropClick: true, + title: 'Modal (click backdrop to close)', + content: createContent('click outside me'), + }); +}); + // Placed relative to the body's top-left via offsetX/offsetY. document.querySelector('#add-positioned').addEventListener('click', () => { BinaryWindow.addWindowlessGlass({ @@ -43,6 +53,7 @@ document.querySelector('#add-fullscreen').addEventListener('click', () => { width: document.documentElement.clientWidth - EDGE * 2, height: document.documentElement.clientHeight - EDGE * 2, content: createContent('fullscreen'), + modal: true, }); }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 483ecf4..c7dfbbf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,9 +98,11 @@ BinaryWindow.assemble(glassModule, detachedGlassModule, trimModule, sillModule); `assemble()` uses `strictAssign` (`src/utils.js`), which **throws if a key already exists** on the target prototype. This makes the method namespace a flat, collision-checked shared space. +A sibling `assembleStatic(...modules)` mixes module objects onto the **class itself** (also via `strictAssign`) rather than the prototype, for methods that take no instance. `BinaryWindow.assembleStatic(windowlessGlassStaticModule)` is how `addWindowlessGlass` / `removeWindowlessGlass` (`binary-window/windowless-glass.js`) land as static methods — they manage glasses with no owning window, so they can't be instance methods (see §8.6). + **Consequence — namespace your mixin methods.** Because all module methods share one namespace, a new feature must not reuse an existing method name. `frame/resizable.js` already owns `enableResize`, so the detached-glass resize feature is `enableDetachedGlassResize`, _not_ `enableResize`. When adding a feature, prefix its methods with the feature name. -**Override via composition.** Modules later in the `assemble()` list can't silently clobber (strictAssign throws), so genuine overrides happen through subclassing: `BinaryWindow.onPaneCreate` overrides `Frame`'s, `binary-window/glass/drag.js`'s `onPaneDrop` overrides the empty stub in `frame/droppable.js`, and `trimModule.onMuntinCreate` wraps muntin creation. The base modules leave `onPaneCreate`/`onPaneDrop`/`onMuntinCreate` — plus the pane add/remove lifecycle hooks (`onBeforePaneAdd`/`onPaneAdd`/`onBeforePaneRemove`/`onPaneRemove`, see §7) — as empty "intended to be overridden" hooks. +**Override via composition.** Modules later in the `assemble()` list can't silently clobber (strictAssign throws), so genuine overrides happen through subclassing: `BinaryWindow.onPaneCreate` overrides `Frame`'s, `binary-window/glass/drag.js`'s `onPaneDrop` overrides the empty stub in `frame/droppable.js`, and `trimModule.onMuntinCreate` wraps muntin creation. The base modules leave `onPaneCreate`/`onPaneDrop`/`onMuntinCreate` as empty "intended to be overridden" hooks. (The pane add/remove **lifecycle is observed via events**, not override hooks — see §7.) --- @@ -252,16 +254,26 @@ Because Sash IDs are stable across an operation (e.g. `removePane` promotes a si `createPaneElement`/`updatePaneElement` write `top/left/width/height` styles and `sash-id`/`position` attributes. `addPaneSash` (+ the four `addPaneSashTo{Left,Right,Top,Bottom}` helpers) performs the **tree surgery** for a split: it converts the target leaf into a split parent (target gets a fresh muntin ID, `domNode = null`), creates the two child sashes (one inherits the target's old ID + `domNode`), and returns the new sash. Sizing honors `size` as fraction or px. -### Pane add/remove lifecycle hooks (`frame/pane.js`) +### Pane add/remove lifecycle events (`frame/event.js` + `frame/pane.js`) + +`addPane`/`removePane` (the `paneModule` mixin) **emit events** around the tree mutation + `update()`. The `eventModule` mixin (`frame/event.js`) provides `on`/`off`/`emit` — a minimal per-instance emitter (a `Map` of event name → `Set` of listeners, lazily created on first `on`/`emit`, so each `Frame`/`BinaryWindow` keeps its own listeners). `detail` is the relevant `Sash`. + +- `before-pane-add` — `(targetPaneSash)`, before the split. A listener returning `false` **vetoes** the add: no tree surgery runs and `addPane` returns `null`. `BinaryWindow.addPane` short-circuits to `null` in turn (no placeholder `Glass` is seeded). +- `pane-add` — `(newPaneSash)`, after `update()`, with the new sash rendered. +- `before-pane-remove` — `(paneSash)`, before the removal. A listener returning `false` **vetoes** the remove: `removePane` bails out before touching the tree. +- `pane-remove` — `(paneSash)`, after `update()`. At this point `paneSash.domNode` still exists but has already been **detached from the DOM** during `update()`, so it's a handle to the removed (orphaned) node, not a live element. + +`emit` runs **every** listener, then reports a veto if **any** of them returned `false` (so veto is order-independent and all listeners still see the event). Returning `false` is the only veto signal — any other return value (including `undefined`) proceeds. Only the `before-*` events are vetoable. + +### Action events (`binary-window/**/action.*.js` + `binary-window/sill.js`) -`addPane`/`removePane` (the `paneModule` mixin) fire four empty, overridable hooks around the tree mutation + `update()`. They're no-ops by default, intended to be set by consumer code (or a subclass); like the other lifecycle hooks they live undifferentiated on the instance (see §3). +The same per-instance emitter carries **glass-action notifications** (none are vetoable). Each built-in action calls `binaryWindow.emit(name, glassEl)` after it has mutated the layout; `detail` is the affected `` element (not a sash): -- `onBeforePaneAdd(targetPaneSash)` — before the split. Returning `false` **vetoes** the add: no tree surgery runs and `addPane` returns `null`. `BinaryWindow.addPane` short-circuits to `null` in turn (no placeholder `Glass` is seeded). -- `onPaneAdd(newPaneSash)` — after `update()`, with the new sash rendered. -- `onBeforePaneRemove(paneSash)` — before the removal. Returning `false` **vetoes** the remove: `removePane` bails out before touching the tree. -- `onPaneRemove(paneSash)` — after `update()`. At this point `paneSash.domNode` still exists but has already been **detached from the DOM** during `update()`, so it's a handle to the removed (orphaned) node, not a live element. +- attached-glass actions — `close`, `minimize`, `maximize` / `unmaximize` (the toggle emits one or the other), and `detach`. +- detached-glass actions — `attach`, `minimize`, and `close` (for an **in-window** detached glass only). The detached **close** action on a _windowless_ glass routes through the static `BinaryWindow.removeWindowlessGlass` (no instance), so it emits nothing. +- sill restore (`sill.js`) — `restore`, emitted from both un-pot paths (the click-to-restore listener in `enableSillFeatures` and the programmatic `restorePane`) once the glass is back in place. -A hook returning `false` is the only veto signal — any other return value (including `undefined`, the no-op default) proceeds. +Async teardown is awaited before the event fires where it matters: `detach` awaits `addDetachedGlass` (so `detail` is the settled detached element), and `attach` awaits `removeDetachedGlass` before re-adding the pane. `minimize` (both attached and detached) emits inside the flight animation's `.then()`, after the glass has landed on its pot. ### Muntins (`frame/muntin.js` + `binary-window/trim.js`) @@ -309,26 +321,26 @@ Built-in actions are **pane-centric** (`closest('bw-pane')`), so they don't work Floating `` panels that mimic OS windows, appended directly to `windowElement` (not bound to any pane/sash). The canonical "feature folder," mirrored by `glass/`: -| File | Role | -| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `index.js` | re-exports `DetachedGlass` + the action defaults; the assembled mixin (`enableDetachedGlassStandaloneFeatures` + `crud` spread). See the split below. | -| `detached-glass.js` | `DetachedGlass extends Glass`; positions/sizes the floating node, defaults `actions = DEFAULT_DETACHED_GLASS_ACTIONS`. | -| `crud.js` | `addDetachedGlass` / `removeDetachedGlass` (public). Cascades placement down-right from the active glass; guards size so the constructor's `222` debug default never fires. | -| `manager.js` | `detachedGlassManager` singleton: the shared registry + focus/stacking coordinator for **all** detached glasses (in-window and windowless alike), and the single entry point for their lifecycle — `addDetachedGlass` (build + register + `bringToFront` + open animation), `removeDetachedGlass` (unregister **and** remove the DOM node + backdrop), `updateDetachedGlass` (stub), plus `bringToFront` / `getActiveDetachedGlass`. See the split below. | -| `activate.js` | click-to-focus → `bringToFront`. Document-global; installed by `enableDetachedGlassStandaloneFeatures`. | -| `move.js` | drag the header to reposition (pointer events + `setPointerCapture`). Document-global; installed by `enableDetachedGlassStandaloneFeatures`. Clamps to the viewport so a drag never grows the page; an off-screen glass (e.g. after a browser resize) may drag inward but never further out (`clampAxis` relaxes only the breached edge to the last applied position). | -| `resize.js` | 8 resize handles created **on demand** (on hover). Document-global; installed by `enableDetachedGlassStandaloneFeatures`. | -| `action.attach.js` / `action.close.js` / `action.minimize.js` | the detached action set. | -| `utils.js` | `genStylesByPosition`, resize-handle creation, `removeGlassBackdrop`, `getContainingBlockOrigin`. | +| File | Role | +| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index.js` | re-exports `DetachedGlass` + the action defaults; the assembled mixin (`enableDetachedGlassStandaloneFeatures` + `crud` spread). See the split below. | +| `detached-glass.js` | `DetachedGlass extends Glass`; positions/sizes the floating node, defaults `actions = DEFAULT_DETACHED_GLASS_ACTIONS`. | +| `crud.js` | `addDetachedGlass` / `removeDetachedGlass` (public). Cascades placement down-right from the active glass; guards size so the constructor's `222` debug default never fires. | +| `manager.js` | `detachedGlassManager` singleton: the shared registry + focus/stacking coordinator for **all** detached glasses (in-window and windowless alike). It owns _registration_, not the DOM or animation — `addDetachedGlass` (build + register + `bringToFront`, returns the element), `removeDetachedGlass` (unregister + splice, returns the element), `updateDetachedGlass` (stub), plus `bringToFront` / `getActiveDetachedGlass`. The DOM `append`/`remove` and the open/close animation live one layer up in `crud.js` / `windowless-glass.js`. See the split below. | +| `activate.js` | click-to-focus → `bringToFront`. Document-global; installed by `enableDetachedGlassStandaloneFeatures`. | +| `move.js` | drag the header to reposition (pointer events + `setPointerCapture`). Document-global; installed by `enableDetachedGlassStandaloneFeatures`. Clamps to the viewport so a drag never grows the page; an off-screen glass (e.g. after a browser resize) may drag inward but never further out (`clampAxis` relaxes only the breached edge to the last applied position). | +| `resize.js` | 8 resize handles created **on demand** (on hover). Document-global; installed by `enableDetachedGlassStandaloneFeatures`. | +| `action.attach.js` / `action.close.js` / `action.minimize.js` | the detached action set. | +| `utils.js` | `genStylesByPosition`, resize-handle creation, `removeGlassBackdrop`, `getContainingBlockOrigin`. | -`DEFAULT_DETACHED_GLASS_ACTIONS = [minimize, attach, close]`. **minimize** (`action.minimize.js`) stashes the glass on a sill pot (`bwDetachedGlassElement`), then plays a FLIP-style flight — `animateElementToElement` (`src/animate.js`) shrinks/flies the glass onto its pot before deferring `display:none` into the animation's `onDone`. +`DEFAULT_DETACHED_GLASS_ACTIONS = [minimize, attach, close]`. **minimize** (`action.minimize.js`) stashes the glass on a sill pot (`bwDetachedGlassElement`), then plays a FLIP-style flight — `animateElementToElement` (`src/animate.js`) shrinks/flies the glass onto its pot and resolves a promise; its `.then()` then sets `display:none` and emits the `minimize` event (§7). **Standalone vs. per-instance features.** `activate`/`move`/`resize` attach **document-global, instance-independent** listeners (they find their target via `closest('bw-glass[detached]')` and never read `this`). They're installed by `enableDetachedGlassStandaloneFeatures()`, called **once at module load** in `binary-window.js` (module evaluation is one-time, so no idempotency flag is needed). This is what makes the static `addWindowlessGlass` path work with **no mounted window** — importing `BinaryWindow` is enough to wire move/resize/activate. Un-potting (restore from the sill) is **not** here: it's a per-instance sill feature wired by `enableSillFeatures()` (see `sill.js`), which needs `this.sillElement` (windowless glasses have no sill). _Historical note:_ an earlier `drag.js` offered native-DnD repositioning (docked to panes) as an alternative to `move.js`; it was removed in favor of free-floating `move.js`. -**Manager owns the lifecycle; the caller only owns the DOM `append`.** `detachedGlassManager` is the single point for add/remove/update: +**Manager owns _registration_; the layer above owns DOM + animation.** `detachedGlassManager` is the single registry, but the DOM `append`/`remove` and the open/close animation live in the `crud.js` (in-window) and `windowless-glass.js` (windowless) layers, which both wrap the manager and return a `Promise`: -- `addDetachedGlass(options)` — **builds the `DetachedGlass`, registers + `bringToFront`s it, and plays the open animation** (`animateDetachedGlassOpen`, unless `animateOpen: false`). Returns the glass **element** (`glass.domNode`), not the instance. The **only** thing left to the caller is the DOM `append`, because the parent differs (`crud.js` → `windowElement`, `addWindowlessGlass` → `document.body`); the windowless modal backdrop reads the returned element's `style.zIndex` (set by `bringToFront`) and sits at `z − 1`. `crud.js` pre-computes cascade placement + the size guard, then forwards everything as options. -- `removeDetachedGlass(id, { animateClose = true } = {})` — **owns the full teardown.** Splices from the array **and** removes the DOM node + modal backdrop via `removeDetachedGlassElement` (`utils.js`, `[closing]` attr + deferred `.remove()`). The **close** action, `binaryWindow.removeDetachedGlass`, and `removeWindowlessGlass` all route through it. +- `detachedGlassManager.addDetachedGlass(options)` — **builds the `DetachedGlass`, registers + `bringToFront`s it**, and returns the glass **element** (`glass.domNode`), not the instance. It does **not** touch the DOM or animate. The wrapping layer then `append`s it (parent differs: `crud.js` → `windowElement`, `addWindowlessGlass` → `document.body`) and, unless `animate: false`, plays the open animation via `animateElementByAttribute(glassEl, 'opening', …)` (resolving the returned promise on `animationend`). `crud.js` pre-computes cascade placement + the size guard and forwards them as options; the windowless modal backdrop reads the returned element's `style.zIndex` (set by `bringToFront`) and sits at `z − 1`. +- `detachedGlassManager.removeDetachedGlass(id)` — **unregisters only**: splices from the array and returns the element (or `null`). The wrapping layer then runs `removeDetachedGlassElement` (`utils.js`, `[closing]` attr + deferred `.remove()`, plus the modal backdrop's own `[closing]` fade) — skipped when `animate: false`. The detached **close** action, `binaryWindow.removeDetachedGlass`, and `removeWindowlessGlass` all route through this pair. - `updateDetachedGlass(id, options)` — tentative stub (throws) for a future in-place update path. `bringToFront` / `getActiveDetachedGlass` stay agnostic about whether a glass lives in a `bw-window` or on `document.body`. @@ -337,8 +349,8 @@ Floating `` panels that mimic OS windows, appended directly t **Detach → attach round-trip:** -- **detach** (`glass/action.detach.js`): creates a centered detached glass sized to the window, then **moves the real content/title elements** into it (`replaceWith`), stashes the origin (`bwOriginalSiblingSashId`, `bwOriginalPosition`, `bwOriginalRelativeSize`) on the DOM node, and `removePane`s the source. -- **attach** (`detached-glass/action.attach.js`): reads the stashed origin and rebuilds a pane via `addPane`, moving the inner content nodes back in. If the original sibling is gone, it falls back to splitting the **largest leaf** (right if wide, bottom if tall) at 50%, then `removeDetachedGlass`. +- **detach** (`glass/action.detach.js`): `await`s a centered detached glass sized to the window — passing the pane's glass as `originalGlassElement`, so `crud.js` **moves the real content/title elements** into the new glass (`transferGlass`) as part of the build. It then stashes the origin (`bwOriginalSiblingSashId`, `bwOriginalPosition`, `bwOriginalRelativeSize`) on the DOM node, `removePane`s the source, and emits `detach` (§7). +- **attach** (`detached-glass/action.attach.js`): reads the stashed origin, `await`s `removeDetachedGlass` **first**, then rebuilds a pane via `addPane` and moves the inner content nodes back in (`transferGlass`), emitting `attach`. If the original sibling is gone, it falls back to splitting the **largest leaf** (right if wide, bottom if tall) at 50%. > **Key contrast with the attached-glass split:** detach/attach round-trips data through the **DOM** (extract content nodes, build a fresh `Glass`), whereas the attached-glass drop _moves the same element_. @@ -346,8 +358,8 @@ Floating `` panels that mimic OS windows, appended directly t A **windowless glass** is a detached glass with no owning window: it's appended to `document.body` instead of a `` and isn't bound to any `BinaryWindow` instance. It's still registered with the shared `detachedGlassManager`, so z-index/activation stacking works the same as for an in-window detached glass. Move/resize/activate work **without any mounted window** because those listeners are document-global and installed at module load (see "Standalone vs. per-instance features" in §8.5). -- **Create** (`binary-window.js` `addWindowlessGlass`) — calls `detachedGlassManager.addDetachedGlass({ position: 'center', ... })`, which builds the `DetachedGlass`, registers + `bringToFront`s it, and plays the open animation; then sets the `windowless` attribute and appends it to `document.body`. **Remove** (`removeWindowlessGlass(id)`) — delegates to `detachedGlassManager.removeDetachedGlass(id)`, which unregisters, removes the node, and tears down its modal backdrop (below). -- **Modal backdrop** — passing `modal: true` appends a `` to `document.body` that blocks interaction with everything underneath. Its `z-index` is set inline to `glassZIndex - 1` — the odd slot the `topZIndex += 2` reservation (above) leaves free — so it sits directly below its glass. Both `removeWindowlessGlass` and the detached **close** action remove the matching `bw-glass-backdrop[for="…"]` via the shared `removeGlassBackdrop` helper in `detached-glass/utils.js`. Styled in `detached-glass.css` (`position: fixed; inset: 0`), color from `--bw-glass-backdrop-color`. +- **Create** (`binary-window/windowless-glass.js` `addWindowlessGlass`, a **static** method mixed onto `BinaryWindow` via `assembleStatic` — see §3) — calls `detachedGlassManager.addDetachedGlass({ position: 'center', ... })` to build + register the glass, sets the `windowless` attribute, appends it to `document.body`, then (unless `animate: false`) plays the open animation. Returns a `Promise` that resolves once the open animation completes. **Remove** (`removeWindowlessGlass(id, { animate = true })`, also static) — `detachedGlassManager.removeDetachedGlass(id)` to unregister, then `removeDetachedGlassElement` to animate out + remove the node and its modal backdrop (below). Also returns a `Promise`. +- **Modal backdrop** — passing `modal: true` appends a `` to `document.body` that blocks interaction with everything underneath. Its `z-index` is set inline to `glassZIndex - 1` — the odd slot the `topZIndex += 2` reservation (above) leaves free — so it sits directly below its glass. It fades **in** (via `animateElementByAttribute(backdropEl, 'opening')`, unless `animate: false`) and **out** (the `[closing]` rule, applied by `removeGlassBackdrop`); both `[opening]`/`[closing]` backdrop animations are styled in `detached-glass.css` alongside its base `position: fixed; inset: 0` and `--bw-glass-backdrop-color`. Both `removeWindowlessGlass` and the detached **close** action tear down the matching `bw-glass-backdrop[for="…"]` via the shared `removeGlassBackdrop` helper in `detached-glass/utils.js`. Passing `closeOnBackdropClick: true` (only meaningful with `modal`) wires a one-shot `click` listener on the backdrop that calls `removeWindowlessGlass`. - **Actions** — with no `binaryWindow`, the _minimize_ and _attach_ actions (which need a window to minimize into / attach to) don't apply, so the default set is **close only**: `DEFAULT_WINDOWLESS_GLASS_ACTIONS = [closeAction]` (vs. `[minimize, attach, close]` for an in-window detached glass). - **Containing block** (`detached-glass/utils.js` `getContainingBlockOrigin`) — both kinds are `position: absolute`, but their reference frame differs. An in-window detached glass's `offsetParent` is the positioned `bw-window`, so the origin is that window's rect top-left. A windowless glass on a _static_ `body` has no positioned ancestor → its containing block is the **initial** one (document origin), so the origin is `{ left: -scrollX, top: -scrollY }`. Drag (`move.js`) and resize math both route through this so viewport bounding stays correct regardless of scroll. - **Modal z-index reservation** (`manager.js`) — `bringToFront` increments `topZIndex` by **2** (not 1) and returns the new value, reserving the odd slot just below for a modal overlay on a windowless glass. @@ -362,10 +374,11 @@ Exports: `Frame`, `BinaryWindow`, `Sash`, `SashConfig`, `ConfigRoot`, `Position` Key methods on `BinaryWindow`: - `mount(containerEl)` / `frame()` / `enableFeatures()` — lifecycle. -- `addPane(targetSashId, { position, size, id, ...glassProps })` — split a pane and attach a glass. Returns `null` if the `onBeforePaneAdd` hook vetoed the add (§7). -- `removePane(sashId)` — remove a pane (and clean up a minimized sill pot if present). A no-op if the `onBeforePaneRemove` hook vetoed the remove (§7). -- `addDetachedGlass(options)` / `removeDetachedGlass(id)` — floating panels inside the window. -- `addWindowlessGlass(options)` / `removeWindowlessGlass(id)` — _static_ floating panels on `document.body`, with no owning window (§8.6). +- `addPane(targetSashId, { position, size, id, ...glassProps })` — split a pane and attach a glass. Returns `null` if a `before-pane-add` listener vetoed the add (§7). +- `removePane(sashId)` — remove a pane (and clean up a minimized sill pot if present). A no-op if a `before-pane-remove` listener vetoed the remove (§7). +- `on(eventName, listener)` / `off(eventName, listener)` — subscribe/unsubscribe to lifecycle and action events: the pane events (`before-pane-add`/`pane-add`/`before-pane-remove`/`pane-remove`) and the action events (`close`/`minimize`/`maximize`/`unmaximize`/`detach`/`attach`/`restore`) — see §7. +- `addDetachedGlass(options)` / `removeDetachedGlass(id, { animate })` — floating panels inside the window. Both return a `Promise` that resolves once the open/close animation completes. +- `BinaryWindow.addWindowlessGlass(options)` / `BinaryWindow.removeWindowlessGlass(id, { animate })` — **static** methods (no instance) for floating panels on `document.body` with no owning window; both return a `Promise` (§8.6). - `setTheme(theme)` / `fit()`. **Per-window `actions` config** is normalized by `BinaryWindow.normActions` into `[glassActions, detachedGlassActions]`, supporting forms like `[a1, a2]`, `[[glassActions]]`, `[glassActions, detachedGlassActions]`, `[undefined, detachedGlassActions]`, etc. `undefined` → both defaults; `null`/`[]` → none. diff --git a/docs/context/conventions.md b/docs/context/conventions.md index c115ca1..6d1862a 100644 --- a/docs/context/conventions.md +++ b/docs/context/conventions.md @@ -73,9 +73,9 @@ Preferred patterns for **new** pointer-driven interaction features: ## Animations (enter / exit) - **Enter animations are plain CSS** — an `animation:` on the element's base selector fires once when it's inserted (or un-hidden). No JS needed. Example: `bw-glass[detached] { animation: bw-detached-glass-open 0.18s ease-out; }`. -- **Exit animations need a JS dance** — CSS can't animate a _normal_ element out of the DOM (only popover/dialog get `transition-behavior: allow-discrete`). The pattern: set a **`[closing]` attribute** the CSS keys the exit animation off, then **defer `.remove()` until `animationend`** (`{ once: true }`, so the listener cleans itself up). Keep the duration only in the stylesheet — no JS-side constant to drift. A boolean opt-out removes immediately, skipping the animation. Canonical use: `removeDetachedGlassElement` in `binary-window/detached-glass/utils.js`. Add `pointer-events: none` to the `[closing]` rule so the dying element can't be re-clicked mid-animation. The mirror image is `animateDetachedGlassOpen`: set `[opening]`, clear it on `animationend` so the enter animation can re-run on the next restore from the sill. +- **Enter and exit both run through one shared attribute-driven helper** — `animateElementByAttribute(element, attribute, onComplete)` in `src/animate.js`. It sets the attribute (which the stylesheet keys an `animation:` off), then on `animationend` clears it (so the animation can re-run next time) and runs `onComplete`. Enter uses `[opening]`, exit uses `[closing]`. This is what makes the exit case work: CSS can't animate a _normal_ element out of the DOM (only popover/dialog get `transition-behavior: allow-discrete`), so the `[closing]` rule plays the exit animation while `onComplete` defers the `.remove()` until it ends. Keep the duration only in the stylesheet — no JS-side constant to drift. A boolean opt-out removes immediately, skipping the animation. Add `pointer-events: none` to the `[closing]` rule so the dying element can't be re-clicked mid-animation. Canonical uses: `removeDetachedGlassElement` / `removeGlassBackdrop` in `binary-window/detached-glass/utils.js` (exit), and the `[opening]` calls in `crud.js`/`windowless-glass.js`/`sill.js` (enter on insert and on restore from the sill). - **For genuinely discrete elements (popover/dialog), use the platform** instead of the `[closing]` dance — `@starting-style` for the enter state and `transition-behavior: allow-discrete` on `display`/`overlay` for the exit. Example: the `bw-action-menu` popover in `glass.action.css`. -- **Run-time geometry (one element flying onto another) is WAAPI, not CSS** — when the start/end transforms depend on _measured_ rects (you can't know them at authoring time), use `element.animate(...)` and compute the keyframes from `getBoundingClientRect()`. The shared home is `animateElementToElement(sourceEl, targetEl, onDone)` in `src/animate.js`: a FLIP-style flight that translate/scales `sourceEl` onto `targetEl` with `transform-origin: top left`, fades it out, disables `pointer-events` during the flight, clears the inline styles on finish, then runs `onDone` (e.g. to hide/remove the source). Both elements must be laid out (not `display:none`) so their rects measure. Canonical use: `detached-glass/action.minimize.js` defers `display:none` into `onDone` so the glass shrinks into its sill pot before hiding. +- **Run-time geometry (one element flying onto another) is WAAPI, not CSS** — when the start/end transforms depend on _measured_ rects (you can't know them at authoring time), use `element.animate(...)` and compute the keyframes from `getBoundingClientRect()`. The shared home is `animateElementToElement(sourceEl, targetEl)` in `src/animate.js`: a FLIP-style flight that translate/scales `sourceEl` onto `targetEl` with `transform-origin: top left`, fades it out, disables `pointer-events` during the flight, clears the inline styles on finish, and **returns the `animation.finished` promise** so the caller can chain teardown. Both elements must be laid out (not `display:none`) so their rects measure. Canonical use: `detached-glass/action.minimize.js` does `animateElementToElement(...).then(() => { ... })` to defer `display:none` (and emit the `minimize` event) until the glass has shrunk into its sill pot. - **Animate only `transform`/`opacity`** for enter/exit so the animation never fights features that set `top`/`left`/`width`/`height` (drag/resize write those directly). **Why:** keep the simple case simple (CSS-only enter), and make the unavoidable JS for exit a single predictable shape rather than ad-hoc per feature. Reach for WAAPI only when the geometry can't be known until run time, and route those through the one shared helper rather than re-deriving the FLIP math per feature. diff --git a/src/animate.js b/src/animate.js index 0abfc65..73c6d6b 100644 --- a/src/animate.js +++ b/src/animate.js @@ -1,8 +1,23 @@ +// Drive a CSS animation by toggling `attribute` on `element`: set it (which the +// stylesheet keys the animation off), then clear it on `animationend` and run +// `onComplete`. The cleared attribute lets the animation re-run on the next call. +export function animateElementByAttribute(element, attribute, onComplete) { + element.setAttribute(attribute, ''); + element.addEventListener( + 'animationend', + () => { + element.removeAttribute(attribute); + onComplete?.(); + }, + { once: true } + ); +} + // FLIP-style flight: shrink and fly `sourceEl` onto `targetEl`, then fade out. // Both must be laid out (in the DOM, not `display:none`) so their rects measure. -// Runs `onDone` when the flight ends (e.g. to hide/remove the source). -export function animateElementToElement(sourceEl, targetEl, onDone) { - const SHRINK_FLIGHT_DURATION = 200; +// Resolves when the flight ends (e.g. to then hide/remove the source). +export function animateElementToElement(sourceEl, targetEl) { + const SHRINK_FLIGHT_DURATION = 180; const sourceRect = sourceEl.getBoundingClientRect(); const targetRect = targetEl.getBoundingClientRect(); @@ -28,13 +43,8 @@ export function animateElementToElement(sourceEl, targetEl, onDone) { // top-left origin so the translate/scale maps the source corner onto the target corner sourceEl.style.transformOrigin = 'top left'; - animation.addEventListener( - 'finish', - () => { - sourceEl.style.pointerEvents = ''; - sourceEl.style.transformOrigin = ''; - onDone?.(); - }, - { once: true } - ); + return animation.finished.then(() => { + sourceEl.style.pointerEvents = ''; + sourceEl.style.transformOrigin = ''; + }); } diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index 457faeb..31d380f 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -3,10 +3,10 @@ import glassModule, { Glass } from './glass'; import { createDomNode } from '../utils'; import trimModule from './trim'; import sillModule from './sill'; -import detachedGlassModule, { DEFAULT_WINDOWLESS_GLASS_ACTIONS } from './detached-glass'; -import { detachedGlassManager } from './detached-glass/manager'; +import detachedGlassModule from './detached-glass'; import { normActions } from './utils'; import { updateGlass } from './glass/utils'; +import windowlessGlassStaticModule from './windowless-glass'; export class BinaryWindow extends Frame { sillElement = null; @@ -112,69 +112,10 @@ export class BinaryWindow extends Frame { this.theme = theme; this.windowElement.setAttribute('theme', theme); } - - /** - * Add a windowless glass: a detached glass that floats on `document.body` instead - * of inside a `bw-window`, so it isn't owned by any window instance. Managed by the - * shared glass manager (z-index/activation) like an in-window detached glass. - * - * @param {Object} [options] - * @param {boolean} [options.modal] - When true, append a `` - * behind the glass to block interaction with everything underneath. - * @param {'center'|'top-left'|'top-right'|'bottom-left'|'bottom-right'} [options.position='center'] - Where to anchor the glass. - * @param {number} [options.width] - Glass width in px. - * @param {number} [options.height] - Glass height in px. - * @param {number} [options.offset=0] - Distance in px from the anchored corner/edge (no effect on `center`). - * @param {number} [options.offsetX] - Per-axis override of `offset` on the x-axis. - * @param {number} [options.offsetY] - Per-axis override of `offset` on the y-axis. - * @param {string} [options.id] - Glass id; auto-generated (suffixed `-F`) when omitted. - * @param {Object[]} [options.actions] - Action buttons; defaults to `DEFAULT_WINDOWLESS_GLASS_ACTIONS` (close only). - * @param {string|Node} [options.title] - Header title. - * @param {string|Node} [options.content] - Glass body content. - * @param {Object[]} [options.tabs] - Header tabs (shown instead of `title`). - * @param {boolean} [options.draggable=true] - Whether the header can be dragged to move the glass. - * @param {boolean} [options.resizable=true] - Whether resize handles appear on hover so the glass can be resized. - * @param {boolean} [options.animateOpen=true] - Whether to play the open animation on insert. - * @returns {Element} - The `bw-glass[detached][windowless]` element - */ - static addWindowlessGlass(options = {}) { - const { modal, ...glassOptions } = options; - - const glassEl = detachedGlassManager.addDetachedGlass({ - actions: DEFAULT_WINDOWLESS_GLASS_ACTIONS, - position: 'center', - ...glassOptions, - }); - - glassEl.setAttribute('windowless', ''); - document.body.append(glassEl); - - if (modal) { - const backdropEl = document.createElement('bw-glass-backdrop'); - backdropEl.setAttribute('for', glassEl.id); - // addDetachedGlass reserved the slot just below the glass (`topZIndex += 2`). - backdropEl.style.zIndex = Number(glassEl.style.zIndex) - 1; - document.body.append(backdropEl); - } - - return glassEl; - } - - /** - * Remove a windowless glass by id, unregistering it from the shared glass manager - * and detaching it from `document.body`. Also removes its modal backdrop, if any. - * - * @param {string} windowlessGlassId - The id of the `bw-glass[windowless]` to remove - * @param {Object} [options] - * @param {boolean} [options.animateClose=true] - Whether to play the close animation before removal. - * @returns {Element|null} - The removed element, or null if no glass had that id - */ - static removeWindowlessGlass(windowlessGlassId, { animateClose = true } = {}) { - return detachedGlassManager.removeDetachedGlass(windowlessGlassId, { animateClose }); - } } BinaryWindow.assemble(glassModule, detachedGlassModule, trimModule, sillModule); +BinaryWindow.assembleStatic(windowlessGlassStaticModule); // Enable features that do not need a BinaryWindow instance // e.g. handle pointer events diff --git a/src/binary-window/detached-glass/action.attach.js b/src/binary-window/detached-glass/action.attach.js index 43833df..99f1111 100644 --- a/src/binary-window/detached-glass/action.attach.js +++ b/src/binary-window/detached-glass/action.attach.js @@ -5,7 +5,7 @@ export default { placement: 'bar', label: '', className: 'bw-action--attach', - onClick: (event, binaryWindow) => { + onClick: async (event, binaryWindow) => { const detachedGlassEl = event.target.closest('bw-glass[detached]'); const originalPosition = detachedGlassEl.bwOriginalPosition; const originalSiblingSashId = detachedGlassEl.bwOriginalSiblingSashId; @@ -26,14 +26,14 @@ export default { size = 0.5; } + await binaryWindow.removeDetachedGlass(detachedGlassEl.id); + const paneSash = binaryWindow.addPane(targetSashId, { position, size, }); transferGlass(detachedGlassEl, paneSash.domNode); - - // Skip the close animation: the glass is being moved into a pane, not dismissed. - binaryWindow.removeDetachedGlass(detachedGlassEl.id, false); + binaryWindow.emit('attach', paneSash.domNode.querySelector('bw-glass')); }, }; diff --git a/src/binary-window/detached-glass/action.close.js b/src/binary-window/detached-glass/action.close.js index fb53635..3f3045e 100644 --- a/src/binary-window/detached-glass/action.close.js +++ b/src/binary-window/detached-glass/action.close.js @@ -1,15 +1,20 @@ -import { detachedGlassManager } from './manager'; +import { BinaryWindow } from '../binary-window'; export default { type: 'detached-glass-builtin', placement: 'bar', label: '', className: 'bw-action--close', - onClick: (event) => { + onClick: async (event, binaryWindow) => { const glassEl = event.target.closest('bw-glass[detached]'); if (!glassEl) return; - // Manager handles both detached and windowless glass (no binaryWindow needed). - detachedGlassManager.removeDetachedGlass(glassEl.id, { animateClose: true }); + if (glassEl.hasAttribute('windowless')) { + await BinaryWindow.removeWindowlessGlass(glassEl.id); + } + else { + await binaryWindow.removeDetachedGlass(glassEl.id); + binaryWindow.emit('close', glassEl); + } }, }; diff --git a/src/binary-window/detached-glass/action.minimize.js b/src/binary-window/detached-glass/action.minimize.js index 5658d50..8c15fb7 100644 --- a/src/binary-window/detached-glass/action.minimize.js +++ b/src/binary-window/detached-glass/action.minimize.js @@ -18,8 +18,9 @@ export default { throw new Error(`[bwin] Detached Glass element not found when minimizing`); potEl.bwDetachedGlassElement = detachedGlassEl; - animateElementToElement(detachedGlassEl, potEl, () => { + animateElementToElement(detachedGlassEl, potEl).then(() => { detachedGlassEl.style.display = 'none'; + binaryWindow.emit('minimize', detachedGlassEl); }); }, }; diff --git a/src/binary-window/detached-glass/crud.js b/src/binary-window/detached-glass/crud.js index d89180f..7a8f55e 100644 --- a/src/binary-window/detached-glass/crud.js +++ b/src/binary-window/detached-glass/crud.js @@ -1,4 +1,7 @@ import { detachedGlassManager } from './manager'; +import { removeDetachedGlassElement } from './utils'; +import { transferGlass } from '../glass/utils'; +import { animateElementByAttribute } from '@/animate'; const DEFAULT_GLASS_WIDTH = 200; const DEFAULT_GLASS_HEIGHT = 200; @@ -24,15 +27,12 @@ function getCascadedPlacement(windowEl, { width, height }) { } export default { - addDetachedGlass(options = {}) { - const { width: optWidth, height: optHeight, position: optPosition } = options; - - // Guard size here so the constructor never falls back to its 222 debug default. - const width = optWidth ?? DEFAULT_GLASS_WIDTH; - const height = optHeight ?? DEFAULT_GLASS_HEIGHT; + addDetachedGlass({ animate = true, originalGlassElement, ...glassOptions } = {}) { + const width = glassOptions.width ?? DEFAULT_GLASS_WIDTH; + const height = glassOptions.height ?? DEFAULT_GLASS_HEIGHT; // An explicit position wins; otherwise cascade from the active glass. - const { position, offsetX, offsetY } = optPosition + const { position, offsetX, offsetY } = glassOptions.position ? {} : getCascadedPlacement(this.windowElement, { width, height }); @@ -43,21 +43,33 @@ export default { position, offsetX, offsetY, - ...options, + ...glassOptions, width, height, }); + if (originalGlassElement) { + transferGlass(originalGlassElement, glassEl); + } + this.windowElement.append(glassEl); - return glassEl; + if (!animate) return Promise.resolve(glassEl); + + return new Promise((resolve) => + animateElementByAttribute(glassEl, 'opening', () => resolve(glassEl)) + ); }, - removeDetachedGlass(detachedGlassId, animateClose = true) { - return detachedGlassManager.removeDetachedGlass(detachedGlassId, { animateClose }); + removeDetachedGlass(id, { animate = true } = {}) { + const removedGlassEl = detachedGlassManager.removeDetachedGlass(id); + + return new Promise((resolve) => + removeDetachedGlassElement(removedGlassEl, animate, () => resolve(removedGlassEl)) + ); }, - updateDetachedGlass(detachedGlassId, options) { - return detachedGlassManager.updateDetachedGlass(detachedGlassId, options); + updateDetachedGlass(...args) { + return detachedGlassManager.updateDetachedGlass(...args); }, }; diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js index 01d978b..4f1e690 100644 --- a/src/binary-window/detached-glass/manager.js +++ b/src/binary-window/detached-glass/manager.js @@ -1,5 +1,4 @@ import { DetachedGlass } from './detached-glass'; -import { animateDetachedGlassOpen, removeDetachedGlassElement } from './utils'; class DetachedGlassManager { constructor() { @@ -10,10 +9,8 @@ class DetachedGlassManager { // Caller owns only the DOM `append` (parent differs: `bw-window` vs. `document.body`) // and reads the returned element's `style.zIndex` for the windowless modal backdrop. - addDetachedGlass(options = {}) { - const { animateOpen = true, ...glassOptions } = options; - - const glassEl = new DetachedGlass(glassOptions).domNode; + addDetachedGlass(options) { + const glassEl = new DetachedGlass(options).domNode; // Ids must be unique in the stack: remove/update/backdrop all key off the id. if (this.getDetachedGlassById(glassEl.id)) { @@ -23,8 +20,6 @@ class DetachedGlassManager { this.detachedGlassElements.push(glassEl); this.bringToFront(glassEl); - if (animateOpen) animateDetachedGlassOpen(glassEl); - return glassEl; } @@ -53,14 +48,11 @@ class DetachedGlassManager { return this.topZIndex; } - // Unregister and tear down: splice from the registry AND remove the DOM node - // (animated by default) plus any modal backdrop. - removeDetachedGlass(id, { animateClose = true } = {}) { + removeDetachedGlass(id) { const index = this.detachedGlassElements.findIndex((glassEl) => glassEl.id === id); if (index === -1) return null; const [removedGlassEl] = this.detachedGlassElements.splice(index, 1); - removeDetachedGlassElement(removedGlassEl, animateClose); return removedGlassEl; } diff --git a/src/binary-window/detached-glass/manager.test.js b/src/binary-window/detached-glass/manager.test.js index 6ed0422..59d16b3 100644 --- a/src/binary-window/detached-glass/manager.test.js +++ b/src/binary-window/detached-glass/manager.test.js @@ -107,29 +107,29 @@ describe('bringToFront', () => { }); describe('removeDetachedGlass', () => { - it('unregisters the glass and returns the removed element', () => { + it('unregisters the glass and resolves with the removed element', async () => { const glassEl = addGlass({ id: 'gone' }); - const removed = detachedGlassManager.removeDetachedGlass('gone', { animateClose: false }); + const removed = await detachedGlassManager.removeDetachedGlass('gone', { animate: false }); expect(removed).toBe(glassEl); expect(detachedGlassManager.detachedGlassElements).not.toContain(glassEl); }); - it('removes the node from the DOM when animateClose is false', () => { + it('removes the node from the DOM when animate is false', () => { const glassEl = addGlass({ id: 'gone' }); document.body.append(glassEl); - detachedGlassManager.removeDetachedGlass('gone', { animateClose: false }); + detachedGlassManager.removeDetachedGlass('gone', { animate: false }); expect(glassEl.isConnected).toBe(false); }); - it('returns null when no glass has that id', () => { - expect(detachedGlassManager.removeDetachedGlass('missing')).toBeNull(); + it('resolves with null when no glass has that id', async () => { + await expect(detachedGlassManager.removeDetachedGlass('missing')).resolves.toBeNull(); }); - it('defaults animateClose to true (node stays until the close animation ends)', () => { + it('defaults animate to true (node stays until the close animation ends)', () => { const glassEl = addGlass({ id: 'gone' }); document.body.append(glassEl); diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index ee38846..e38897e 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -1,3 +1,5 @@ +import { animateElementByAttribute } from '@/animate'; + // Edges first, corners last so corner handles paint on top of the edge handles const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']; @@ -17,39 +19,32 @@ export function getResizeHandleOverhang(glassEl) { return (parseFloat(size) || 0) / 2; } -// Remove the modal backdrop tied to a glass id, if one exists (windowless modal glass). -export function removeGlassBackdrop(glassId) { +export function removeGlassBackdrop(glassId, animate = false) { const backdropEl = document.querySelector(`bw-glass-backdrop[for="${glassId}"]`); - backdropEl?.remove(); -} + if (!backdropEl) return; + + if (!animate) { + backdropEl.remove(); + return; + } -// Play the open animation by setting `[opening]` (see detached-glass.css), then -// clear it once the animation ends so it can re-run on the next restore. -export function animateDetachedGlassOpen(detachedGlassEl) { - detachedGlassEl.setAttribute('opening', ''); - detachedGlassEl.addEventListener( - 'animationend', - () => detachedGlassEl.removeAttribute('opening'), - { once: true } - ); + animateElementByAttribute(backdropEl, 'closing', () => backdropEl.remove()); } -// Remove a detached glass element from the DOM (+ any modal backdrop). CSS can't -// animate a normal element out, so `[closing]` drives the close animation and we -// defer the actual removal until it ends. Pass `animateClose: false` to remove now. -export function removeDetachedGlassElement(detachedGlassEl, animateClose = true) { - const remove = () => { +export function removeDetachedGlassElement(detachedGlassEl, animate = true, onComplete) { + removeGlassBackdrop(detachedGlassEl.id, animate); + + const handleRemove = () => { detachedGlassEl.remove(); - removeGlassBackdrop(detachedGlassEl.id); + onComplete?.(); }; - if (!animateClose) { - remove(); + if (!animate) { + handleRemove(); return; } - detachedGlassEl.setAttribute('closing', ''); - detachedGlassEl.addEventListener('animationend', remove, { once: true }); + animateElementByAttribute(detachedGlassEl, 'closing', handleRemove); } // Viewport-space top-left of an absolutely-positioned element's containing block. diff --git a/src/binary-window/glass/action.close.js b/src/binary-window/glass/action.close.js index 4c50ed9..5abf524 100644 --- a/src/binary-window/glass/action.close.js +++ b/src/binary-window/glass/action.close.js @@ -7,6 +7,9 @@ export default { className: 'bw-action--close', onClick: (event, binaryWindow) => { const sashId = getSashIdFromPane(event.target); + const glassEl = binaryWindow.rootSash.getById(sashId).domNode.querySelector('bw-glass'); + binaryWindow.removePane(sashId); + binaryWindow.emit('close', glassEl); }, }; diff --git a/src/binary-window/glass/action.detach.js b/src/binary-window/glass/action.detach.js index ea20a50..e85adf4 100644 --- a/src/binary-window/glass/action.detach.js +++ b/src/binary-window/glass/action.detach.js @@ -1,5 +1,3 @@ -import { transferGlass } from './utils'; - const DETACHED_GLASS_INSET = 15; export default { @@ -7,16 +5,19 @@ export default { placement: 'bar', label: '', className: 'bw-action--detach', - onClick: (event, binaryWindow) => { - if (!binaryWindow.addDetachedGlass) throw new Error('[bwin] Failed to detach glass from pane'); - + onClick: async (event, binaryWindow) => { const paneEl = event.target.closest('bw-pane'); const glassEl = paneEl.querySelector('bw-glass'); const windowRect = binaryWindow.windowElement.getBoundingClientRect(); const width = windowRect.width - DETACHED_GLASS_INSET * 2; const height = windowRect.height - DETACHED_GLASS_INSET * 2; - const detachedGlassEl = binaryWindow.addDetachedGlass({ position: 'center', width, height }); + const detachedGlassEl = await binaryWindow.addDetachedGlass({ + position: 'center', + width, + height, + originalGlassElement: glassEl, + }); const paneSashId = paneEl.getAttribute('sash-id'); const paneSash = binaryWindow.rootSash.getById(paneSashId); @@ -26,8 +27,7 @@ export default { detachedGlassEl.bwOriginalPosition = paneEl.getAttribute('position'); detachedGlassEl.bwOriginalRelativeSize = paneSash.getRelativeSize(); - transferGlass(glassEl, detachedGlassEl); - binaryWindow.removePane(paneSashId); + binaryWindow.emit('detach', detachedGlassEl); }, }; diff --git a/src/binary-window/glass/action.maximize.js b/src/binary-window/glass/action.maximize.js index 5e6f92f..3af1813 100644 --- a/src/binary-window/glass/action.maximize.js +++ b/src/binary-window/glass/action.maximize.js @@ -5,8 +5,9 @@ export default { placement: 'bar', label: '', className: 'bw-action--maximize', - onClick: (event) => { + onClick: (event, binaryWindow) => { const paneEl = event.target.closest('bw-pane'); + const glassEl = paneEl.querySelector('bw-glass'); if (paneEl.hasAttribute('maximized')) { paneEl.removeAttribute('maximized'); @@ -14,6 +15,7 @@ export default { paneEl.style.top = `${paneEl.bwOriginalBoundingRect.top}px`; paneEl.style.width = `${paneEl.bwOriginalBoundingRect.width}px`; paneEl.style.height = `${paneEl.bwOriginalBoundingRect.height}px`; + binaryWindow.emit('unmaximize', glassEl); } else { paneEl.setAttribute('maximized', ''); @@ -22,6 +24,7 @@ export default { paneEl.style.top = '0'; paneEl.style.width = '100%'; paneEl.style.height = '100%'; + binaryWindow.emit('maximize', glassEl); } }, }; diff --git a/src/binary-window/glass/action.minimize.js b/src/binary-window/glass/action.minimize.js index 32699ca..237923e 100644 --- a/src/binary-window/glass/action.minimize.js +++ b/src/binary-window/glass/action.minimize.js @@ -24,5 +24,7 @@ export default { potEl.bwOriginalSashId = paneSashId; sillEl.append(potEl); + + binaryWindow.emit('minimize', glassEl); }, }; diff --git a/src/binary-window/sill.js b/src/binary-window/sill.js index a15b455..8b940b5 100644 --- a/src/binary-window/sill.js +++ b/src/binary-window/sill.js @@ -2,7 +2,7 @@ import { getMetricsFromElement } from '@/utils'; import { getIntersectRect } from '@/rect'; import { Position } from '@/position'; import { detachedGlassManager } from './detached-glass/manager'; -import { animateDetachedGlassOpen } from './detached-glass/utils'; +import { animateElementByAttribute } from '@/animate'; export default { enableSillFeatures() { @@ -31,9 +31,12 @@ export default { if (!detachedGlassEl) return; detachedGlassEl.style.display = ''; - animateDetachedGlassOpen(detachedGlassEl); - potEl.remove(); - detachedGlassManager.bringToFront(detachedGlassEl); + + animateElementByAttribute(detachedGlassEl, 'opening', () => { + potEl.remove(); + detachedGlassManager.bringToFront(detachedGlassEl); + this.emit('restore', detachedGlassEl); + }); }); }, @@ -89,6 +92,7 @@ export default { withGlass: false, }); newSashPane.domNode.append(potEl.bwGlassElement); + this.emit('restore', potEl.bwGlassElement); } }, diff --git a/src/binary-window/windowless-glass.js b/src/binary-window/windowless-glass.js new file mode 100644 index 0000000..5480216 --- /dev/null +++ b/src/binary-window/windowless-glass.js @@ -0,0 +1,81 @@ +import { animateElementByAttribute } from '@/animate'; +import { DEFAULT_WINDOWLESS_GLASS_ACTIONS } from './detached-glass'; +import { detachedGlassManager } from './detached-glass/manager'; +import { removeDetachedGlassElement } from './detached-glass/utils'; + +function removeWindowlessGlass(id, { animate = true } = {}) { + const detachedGlassEl = detachedGlassManager.removeDetachedGlass(id); + + if (!animate) { + return Promise.resolve(detachedGlassEl); + } + + return new Promise((resolve) => + removeDetachedGlassElement(detachedGlassEl, animate, () => resolve(detachedGlassEl)) + ); +} + +export default { + /** + * Add a windowless glass: a detached glass that floats on `document.body` instead + * of inside a `bw-window`, so it isn't owned by any window instance. Managed by the + * shared glass manager (z-index/activation) like an in-window detached glass. + * + * @param {Object} [glassOptions] + * @param {boolean} [glassOptions.animate=true] - Whether to play the open animation (and fade the backdrop in). + * @param {boolean} [glassOptions.modal=false] - When true, append a `` + * behind the glass to block interaction with everything underneath. + * @param {boolean} [glassOptions.closeOnBackdropClick=false] - When `modal`, clicking the backdrop closes the glass. + * @param {'center'|'top-left'|'top-right'|'bottom-left'|'bottom-right'} [glassOptions.position='center'] - Where to anchor the glass. + * @param {number} [glassOptions.width] - Glass width in px. + * @param {number} [glassOptions.height] - Glass height in px. + * @param {number} [glassOptions.offset=0] - Distance in px from the anchored corner/edge (no effect on `center`). + * @param {number} [glassOptions.offsetX] - Per-axis override of `offset` on the x-axis. + * @param {number} [glassOptions.offsetY] - Per-axis override of `offset` on the y-axis. + * @param {string} [glassOptions.id] - Glass id; auto-generated (suffixed `-F`) when omitted. + * @param {Object[]} [glassOptions.actions] - Action buttons; defaults to `DEFAULT_WINDOWLESS_GLASS_ACTIONS` (close only). + * @param {string|Node} [glassOptions.title] - Header title. + * @param {string|Node} [glassOptions.content] - Glass body content. + * @param {Object[]} [glassOptions.tabs] - Header tabs (shown instead of `title`). + * @param {boolean} [glassOptions.draggable=true] - Whether the header can be dragged to move the glass. + * @param {boolean} [glassOptions.resizable=true] - Whether resize handles appear on hover so the glass can be resized. + * @returns {Promise} - Resolves to the `bw-glass[detached][windowless]` element once the open animation completes. + */ + addWindowlessGlass({ + animate = true, + modal = false, + closeOnBackdropClick = false, + ...glassOptions + } = {}) { + const glassEl = detachedGlassManager.addDetachedGlass({ + actions: DEFAULT_WINDOWLESS_GLASS_ACTIONS, + position: 'center', + ...glassOptions, + }); + + glassEl.setAttribute('windowless', ''); + document.body.append(glassEl); + + if (modal) { + const backdropEl = document.createElement('bw-glass-backdrop'); + backdropEl.setAttribute('for', glassEl.id); + // addDetachedGlass reserved the slot just below the glass (`topZIndex += 2`). + backdropEl.style.zIndex = Number(glassEl.style.zIndex) - 1; + document.body.append(backdropEl); + if (animate) animateElementByAttribute(backdropEl, 'opening'); + if (closeOnBackdropClick) { + backdropEl.addEventListener('click', () => removeWindowlessGlass(glassEl.id), { + once: true, + }); + } + } + + if (!animate) return Promise.resolve(glassEl); + + return new Promise((resolve) => + animateElementByAttribute(glassEl, 'opening', () => resolve(glassEl)) + ); + }, + + removeWindowlessGlass, +}; diff --git a/src/css/detached-glass.css b/src/css/detached-glass.css index dd640b3..f02ebfe 100644 --- a/src/css/detached-glass.css +++ b/src/css/detached-glass.css @@ -1,16 +1,10 @@ bw-glass[detached] { box-shadow: var(--bw-detached-glass-shadow); - /* OS-style open: scale up from slightly small + fade in. Only `transform`/ - `opacity` so it never fights move/resize (which set top/left/width/height). - Driven by `[opening]` (set in JS), which is cleared once the animation ends. - Set on insert and on restore from the sill (when display flips back). */ &[opening] { animation: bw-detached-glass-open 0.18s ease-out; } - /* OS-style close: reverse of open. Driven by `[closing]` (set in JS), which - defers DOM removal by the same duration — CSS can't animate an element out. */ &[closing] { animation: bw-detached-glass-close 0.18s ease-in forwards; pointer-events: none; @@ -27,6 +21,7 @@ bw-glass[detached] { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); @@ -38,6 +33,7 @@ bw-glass[detached] { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.9); @@ -55,6 +51,34 @@ bw-glass-backdrop { position: fixed; inset: 0; background: var(--bw-glass-backdrop-color); + + &[opening] { + animation: bw-glass-backdrop-open 0.28s ease-out; + } + + &[closing] { + animation: bw-glass-backdrop-close 0.28s ease-in forwards; + } +} + +@keyframes bw-glass-backdrop-open { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes bw-glass-backdrop-close { + from { + opacity: 1; + } + + to { + opacity: 0; + } } bw-glass-resize-handle { diff --git a/src/frame/event.js b/src/frame/event.js new file mode 100644 index 0000000..dc58fba --- /dev/null +++ b/src/frame/event.js @@ -0,0 +1,26 @@ +export default { + on(eventName, listener) { + this.eventListeners ??= new Map(); + + if (!this.eventListeners.has(eventName)) { + this.eventListeners.set(eventName, new Set()); + } + this.eventListeners.get(eventName).add(listener); + }, + + off(eventName, listener) { + this.eventListeners?.get(eventName)?.delete(listener); + }, + + // Runs every listener; returns `false` when any listener vetoed (returned `false`), else `true`. + emit(eventName, detail) { + const listeners = this.eventListeners?.get(eventName); + if (!listeners) return true; + + let allowed = true; + for (const listener of listeners) { + if (listener(detail) === false) allowed = false; + } + return allowed; + }, +}; diff --git a/src/frame/event.test.js b/src/frame/event.test.js new file mode 100644 index 0000000..903692d --- /dev/null +++ b/src/frame/event.test.js @@ -0,0 +1,212 @@ +import { describe, it, expect } from 'vitest'; +import eventModule from './event'; + +// Each test gets a fresh emitter, mirroring how `eventModule` is assembled onto +// a `Frame`/`BinaryWindow` instance (its methods run with `this` as that instance). +function makeEmitter() { + return Object.create(eventModule); +} + +describe('on / emit', () => { + it('calls a registered listener with the emitted detail', () => { + const e = makeEmitter(); + const detail = { sash: 'a' }; + let received; + e.on('pane-add', (d) => (received = d)); + + e.emit('pane-add', detail); + + expect(received).toBe(detail); // same reference, not a copy + }); + + it('passes `undefined` detail through when none is given', () => { + const e = makeEmitter(); + let called = false; + let received = 'sentinel'; + e.on('pane-add', (d) => { + called = true; + received = d; + }); + + e.emit('pane-add'); + + expect(called).toBe(true); + expect(received).toBeUndefined(); + }); + + it('calls all listeners for an event, in registration order', () => { + const e = makeEmitter(); + const calls = []; + e.on('pane-add', () => calls.push('first')); + e.on('pane-add', () => calls.push('second')); + e.on('pane-add', () => calls.push('third')); + + e.emit('pane-add'); + + expect(calls).toEqual(['first', 'second', 'third']); + }); + + it('only calls listeners for the emitted event name', () => { + const e = makeEmitter(); + let addCalls = 0; + let removeCalls = 0; + e.on('pane-add', () => addCalls++); + e.on('pane-remove', () => removeCalls++); + + e.emit('pane-add'); + + expect(addCalls).toBe(1); + expect(removeCalls).toBe(0); + }); + + it('registers the same listener only once (Set dedupe)', () => { + const e = makeEmitter(); + let count = 0; + const listener = () => count++; + e.on('pane-add', listener); + e.on('pane-add', listener); + + e.emit('pane-add'); + + expect(count).toBe(1); + }); + + it('returns true and does not throw when an event has no listeners', () => { + const e = makeEmitter(); + expect(e.emit('pane-add')).toBe(true); + }); + + it('does not throw when emitting before any listener was ever registered', () => { + const e = makeEmitter(); + // `eventListeners` map is not created until the first `on`/`emit`. + expect(() => e.emit('never-registered')).not.toThrow(); + }); +}); + +describe('emit veto semantics', () => { + it('returns true when no listener returns false', () => { + const e = makeEmitter(); + e.on('before-pane-remove', () => {}); + expect(e.emit('before-pane-remove')).toBe(true); + }); + + it('returns false when a listener returns false', () => { + const e = makeEmitter(); + e.on('before-pane-remove', () => false); + expect(e.emit('before-pane-remove')).toBe(false); + }); + + it('returns false if any one of several listeners returns false', () => { + const e = makeEmitter(); + e.on('before-pane-remove', () => true); + e.on('before-pane-remove', () => false); + e.on('before-pane-remove', () => undefined); + expect(e.emit('before-pane-remove')).toBe(false); + }); + + it('vetoes regardless of which listener returns false (order-independent)', () => { + const veto = makeEmitter(); + veto.on('e', () => false); + veto.on('e', () => true); + + const veto2 = makeEmitter(); + veto2.on('e', () => true); + veto2.on('e', () => false); + + expect(veto.emit('e')).toBe(false); + expect(veto2.emit('e')).toBe(false); + }); + + it('runs every listener even after one has vetoed (no short-circuit)', () => { + const e = makeEmitter(); + const calls = []; + e.on('before-pane-remove', () => { + calls.push('first'); + return false; + }); + e.on('before-pane-remove', () => calls.push('second')); + + e.emit('before-pane-remove'); + + expect(calls).toEqual(['first', 'second']); + }); + + it('only an explicit `false` vetoes — other falsy returns proceed', () => { + for (const value of [undefined, null, 0, '', NaN]) { + const e = makeEmitter(); + e.on('before-pane-remove', () => value); + expect(e.emit('before-pane-remove')).toBe(true); + } + }); + + it('does not treat a truthy return as a veto', () => { + const e = makeEmitter(); + e.on('before-pane-remove', () => 'yes'); + expect(e.emit('before-pane-remove')).toBe(true); + }); +}); + +describe('off', () => { + it('removes a previously registered listener', () => { + const e = makeEmitter(); + let count = 0; + const listener = () => count++; + e.on('pane-add', listener); + + e.off('pane-add', listener); + e.emit('pane-add'); + + expect(count).toBe(0); + }); + + it('leaves other listeners on the same event intact', () => { + const e = makeEmitter(); + let kept = 0; + const removed = () => {}; + const keptListener = () => kept++; + e.on('pane-add', removed); + e.on('pane-add', keptListener); + + e.off('pane-add', removed); + e.emit('pane-add'); + + expect(kept).toBe(1); + }); + + it('is a no-op for a listener that was never registered', () => { + const e = makeEmitter(); + e.on('pane-add', () => {}); + expect(() => e.off('pane-add', () => {})).not.toThrow(); + }); + + it('is a no-op for an unknown event name', () => { + const e = makeEmitter(); + expect(() => e.off('unknown', () => {})).not.toThrow(); + }); + + it('does not throw when called before any listener was registered', () => { + const e = makeEmitter(); + expect(() => e.off('pane-add', () => {})).not.toThrow(); + }); +}); + +describe('instance isolation', () => { + it('does not share listeners between instances', () => { + const a = makeEmitter(); + const b = makeEmitter(); + let aCalls = 0; + a.on('pane-add', () => aCalls++); + + b.emit('pane-add'); // b has no listeners of its own + + expect(aCalls).toBe(0); + }); + + it("a veto on one instance does not affect another instance's emit", () => { + const a = makeEmitter(); + const b = makeEmitter(); + a.on('before-pane-remove', () => false); + + expect(b.emit('before-pane-remove')).toBe(true); + }); +}); diff --git a/src/frame/frame.js b/src/frame/frame.js index abd3d9f..2303e5b 100644 --- a/src/frame/frame.js +++ b/src/frame/frame.js @@ -7,6 +7,7 @@ import muntinModule from './muntin'; import fitContainerModule from './fit-container'; import resizableModule from './resizable'; import droppableModule from './droppable'; +import eventModule from './event'; const DEBUG = import.meta.env.VITE_DEBUG == 'true' ? true : false; @@ -62,6 +63,12 @@ export class Frame { strictAssign(this.prototype, module); }); } + + static assembleStatic(...modules) { + modules.forEach((module) => { + strictAssign(this, module); + }); + } } Frame.assemble( @@ -70,5 +77,6 @@ Frame.assemble( paneModule, fitContainerModule, droppableModule, - resizableModule + resizableModule, + eventModule ); diff --git a/src/frame/pane.js b/src/frame/pane.js index b396aa5..80c3598 100644 --- a/src/frame/pane.js +++ b/src/frame/pane.js @@ -80,12 +80,13 @@ export default { const targetPaneSash = this.rootSash.getById(targetPaneSashId); if (!targetPaneSash) throw new Error('[bwin] Parent sash not found when adding pane'); - const mustAdd = this.onBeforePaneAdd(targetPaneSash); + const mustAdd = this.emit('before-pane-add', targetPaneSash); if (mustAdd === false) return null; const newPaneSash = addPaneSash(targetPaneSash, { position, size, id, minWidth, minHeight }); this.update(); - this.onPaneAdd(newPaneSash); + + this.emit('pane-add', newPaneSash); return newPaneSash; }, @@ -101,7 +102,7 @@ export default { const sash = this.rootSash.getById(sashId); if (!sash) throw new Error('[bwin] Sash not found when removing pane'); - const mustRemove = this.onBeforePaneRemove(sash); + const mustRemove = this.emit('before-pane-remove', sash); if (mustRemove === false) return; const siblingSash = parentSash.getChildSiblingById(sashId); @@ -137,7 +138,7 @@ export default { this.update(); // `sash.domNode` still exists at this point, // but was removed from the DOM during `this.update()` - this.onPaneRemove(sash); + this.emit('pane-remove', sash); }, swapPanes(sourcePaneEl, targetPaneEl) { @@ -156,12 +157,6 @@ export default { sourcePaneEl.setAttribute('can-drop', targetPaneCanDrop); targetPaneEl.setAttribute('can-drop', sourcePaneCanDrop); }, - - // To be overridden by user code - onBeforePaneAdd(targetPaneSash) {}, - onPaneAdd(newPaneSash) {}, - onBeforePaneRemove(paneSash) {}, - onPaneRemove(paneSash) {}, }; function __debug(parentEl) {