Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions dev/window/bwin-action-hooks.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module" src="./bwin-action-hooks.js"></script>
</head>
<body>
<main>
<h2>Debug</h2>
<section id="container"></section>
</main>
</body>
</html>
46 changes: 46 additions & 0 deletions dev/window/bwin-action-hooks.js
Original file line number Diff line number Diff line change
@@ -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);
});
32 changes: 16 additions & 16 deletions dev/window/bwin-add-remove-panes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions dev/window/bwin-detached-glass.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});

Expand Down
1 change: 1 addition & 0 deletions dev/window/bwin-detached-windowless-glass.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ <h2>BinaryWindow - windowless glass</h2>
<div>
<button id="add-windowless">Add windowless glass</button>
<button id="add-modal">Add modal windowless glass</button>
<button id="add-modal-close-on-backdrop">Add modal (close on backdrop click)</button>
<button id="add-positioned">Add positioned windowless glass</button>
<button id="add-fullscreen">Fullscreen popup</button>
<button id="add-non-resizable">Add non-resizable glass</button>
Expand Down
11 changes: 11 additions & 0 deletions dev/window/bwin-detached-windowless-glass.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
});
});

Expand Down
75 changes: 44 additions & 31 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/context/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 22 additions & 12 deletions src/animate.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 = '';
});
}
65 changes: 3 additions & 62 deletions src/binary-window/binary-window.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 `<bw-glass-backdrop for="<glassId>">`
* 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
Expand Down
8 changes: 4 additions & 4 deletions src/binary-window/detached-glass/action.attach.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'));
},
};
13 changes: 9 additions & 4 deletions src/binary-window/detached-glass/action.close.js
Original file line number Diff line number Diff line change
@@ -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);
}
},
};
3 changes: 2 additions & 1 deletion src/binary-window/detached-glass/action.minimize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
},
};
Loading