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
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module" src="./dark-theme.js"></script>
<script type="module" src="./bwin-dark-theme.js"></script>
</head>
<body>
<main>
<h2>Dark Theme</h2>
<button id="toggle-theme">Toggle Theme</button>
<button id="add-windowless">Add windowless glass</button>
<form id="set-theme-form">
<input id="theme-input" type="text" placeholder="Theme name" value="dark" />
<button id="set-theme">Set Theme</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,7 @@ document.querySelector('#set-theme-form').addEventListener('submit', (event) =>
const theme = document.querySelector('#theme-input').value.trim();
bwin.setTheme(theme);
});

document.querySelector('#add-windowless').addEventListener('click', () => {
BinaryWindow.addWindowlessGlass({ title: 'Windowless glass', content: inputs });
});
3 changes: 3 additions & 0 deletions dev/features/bwin-detached-glass.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ <h2>BinaryWindow - detached glass</h2>
<button data-position="bottom-left">Add bottom-left</button>
<button data-position="bottom-right">Add bottom-right</button>
<button data-position="center">Add center</button>
<button id="add-windowless">Add windowless glass</button>
<button id="add-modal">Add modal windowless glass</button>
<button id="add-fullscreen">Fullscreen popup</button>
</div>
<section id="container"></section>
</main>
Expand Down
31 changes: 31 additions & 0 deletions dev/features/bwin-detached-glass.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,35 @@ document.querySelector('#add-default').addEventListener('click', () => {
bwin.addDetachedGlass({ content: createGlassContent('default') });
});

// Static method: floats on document.body, not inside any bw-window.
document.querySelector('#add-windowless').addEventListener('click', () => {
BinaryWindow.addWindowlessGlass({
title: 'Windowless glass',
content: createGlassContent('windowless'),
});
});

// Modal: a backdrop is appended behind the glass to block everything underneath.
document.querySelector('#add-modal').addEventListener('click', () => {
BinaryWindow.addWindowlessGlass({
modal: true,
title: 'Modal windowless glass',
content: createGlassContent('modal'),
});
});

// Windowless glass filling the viewport with a 50px inset on every edge.
document.querySelector('#add-fullscreen').addEventListener('click', () => {
const EDGE = 20;
BinaryWindow.addWindowlessGlass({
title: 'Fullscreen popup',
draggable: false,
position: 'top-left',
offset: EDGE,
width: document.documentElement.clientWidth - EDGE * 2,
height: document.documentElement.clientHeight - EDGE * 2,
content: createGlassContent('fullscreen'),
});
});

// document.querySelector('#add-default').click();
9 changes: 6 additions & 3 deletions dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,14 @@ navEl.querySelector('#_toggle-theme').addEventListener('click', () => {

const goDark = windowEls[0].getAttribute('theme') !== 'dark';

windowEls.forEach((windowEl) => {
// Windowless glasses float on the page body, outside bw-window, so theme them too.
const themedEls = [...windowEls, ...frameDoc.querySelectorAll('bw-glass[windowless]')];

themedEls.forEach((el) => {
if (goDark) {
windowEl.setAttribute('theme', 'dark');
el.setAttribute('theme', 'dark');
} else {
windowEl.removeAttribute('theme');
el.removeAttribute('theme');
}
});

Expand Down
136 changes: 95 additions & 41 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/context/conventions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Coding conventions

The full rationale behind the rules summarized in [`CLAUDE.md`](../../CLAUDE.md). Read this when writing or reviewing bwin source; `CLAUDE.md` is the quick checklist, this is the *why*.
The full rationale behind the rules summarized in [`CLAUDE.md`](../../CLAUDE.md). Read this when writing or reviewing bwin source; `CLAUDE.md` is the quick checklist, this is the _why_.

See also [`ARCHITECTURE.md`](../ARCHITECTURE.md) for the system design and [`react-bwin-integration.md`](./react-bwin-integration.md) for the downstream contract.

Expand All @@ -10,13 +10,13 @@ See also [`ARCHITECTURE.md`](../ARCHITECTURE.md) for the system design and [`rea

Use the window-construction metaphor precisely — the full glossary is [`ARCHITECTURE.md` §1](../ARCHITECTURE.md#1-the-window-construction-metaphor). Don't pick a name whose well-known meaning differs from what the code does (e.g. jQuery's `unwrap` removes the wrapper in place, so `extractChildNodes` is clearer for moving children into a fragment).

Use plain "glass" by default; say "attached glass" only when contrasting with "detached glass".
Use plain "glass" by default; say "attached glass" only when contrasting with "detached glass". A **windowless glass** is a detached glass with no owning window (floats on `document.body`); use that exact term — not "free glass" (the old name) or "floating glass".

---

## Naming

- **DOM-element variables get an `El` suffix with a *specific* noun** — `activeGlassEl`, not `activeEl`, and not a vague `glassEl` when more specificity is available.
- **DOM-element variables get an `El` suffix with a _specific_ noun** — `activeGlassEl`, not `activeEl`, and not a vague `glassEl` when more specificity is available.
- **Element accessors are named `get<Noun>`** — e.g. `getActiveGlass` (returns the element that `activeGlassEl` would hold).
- **Constants name the context they apply to, not just the quantity** — `MIN_RESIZE_WIDTH`, not `MIN_WIDTH`, so a resize-time minimum isn't confused with an unrelated creation-time size default.
- **Prefer established domain/library terms** and match their conventional meaning.
Expand Down
90 changes: 67 additions & 23 deletions src/binary-window/binary-window.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { Frame } from '../frame/frame';
import glassModule, { Glass, DEFAULT_GLASS_ACTIONS } from './glass';
import glassModule, { Glass } from './glass';
import { createDomNode } from '../utils';
import trimModule from './trim';
import detachedGlassModule, { DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass';
import detachedGlassModule, {
DetachedGlass,
DEFAULT_WINDOWLESS_GLASS_ACTIONS,
} from './detached-glass';
import { detachedGlassManager } from './detached-glass/manager';
import { removeGlassBackdrop } from './detached-glass/utils';
import { normActions } from './utils';

// debug: ci round 2
export class BinaryWindow extends Frame {
Expand All @@ -12,7 +18,7 @@ export class BinaryWindow extends Frame {
super(settings);

this.theme = settings.theme || '';
this.actions = BinaryWindow.normActions(settings.actions);
this.actions = normActions(settings.actions);
}

frame() {
Expand Down Expand Up @@ -95,30 +101,68 @@ export class BinaryWindow extends Frame {
}
}

// Returns [glassActions, detachedGlassActions]
static normActions(actions) {
if (actions === undefined) return [DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS];
if (!actions || !Array.isArray(actions) || actions.length === 0) return [[], []];

// [glassActions]
if (actions.length === 1 && Array.isArray(actions[0])) return [actions[0], DEFAULT_DETACHED_GLASS_ACTIONS];

// [action1, action2, ...]
if (!actions.some(Array.isArray)) return [actions, DEFAULT_DETACHED_GLASS_ACTIONS];
/**
* 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.
* @returns {DetachedGlass}
*/
static addWindowlessGlass(options = {}) {
const { modal, ...glassOptions } = options;

const glass = new DetachedGlass({
actions: DEFAULT_WINDOWLESS_GLASS_ACTIONS,
position: 'center',
...glassOptions,
});

glass.domNode.setAttribute('windowless', '');

document.body.append(glass.domNode);
detachedGlassManager.addGlassByElement(glass.domNode);
// bringToFront reserves the z-index slot just below the glass for this backdrop.
const glassZIndex = detachedGlassManager.bringToFront(glass.domNode);

if (modal) {
const backdropEl = document.createElement('bw-glass-backdrop');
backdropEl.setAttribute('for', glass.domNode.id);
backdropEl.style.zIndex = glassZIndex - 1;
document.body.append(backdropEl);
}

// [undefined, detachedGlassActions]
if (actions.length >= 2 && !Array.isArray(actions[0]) && Array.isArray(actions[1]))
return [[], actions[1]];
return glass;
}

// [glassActions, undefined]
if (actions.length >= 2 && Array.isArray(actions[0]) && !Array.isArray(actions[1]))
return [actions[0], []];
/**
* 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
* @returns {Element|null} - The removed element, or null if no glass had that id
*/
static removeWindowlessGlass(windowlessGlassId) {
const removedGlassEl = detachedGlassManager.removeGlassById(windowlessGlassId);
removedGlassEl?.remove();

// [glassActions, detachedGlassActions]
if (actions.length >= 2 && Array.isArray(actions[0]) && Array.isArray(actions[1]))
return actions;
removeGlassBackdrop(windowlessGlassId);

throw new Error(`[bwin] Invalid actions format`);
return removedGlassEl;
}
}

Expand Down
30 changes: 15 additions & 15 deletions src/binary-window/binary-window.test.js
Original file line number Diff line number Diff line change
@@ -1,56 +1,56 @@
import { describe, it, expect } from 'vitest';
import { BinaryWindow } from './binary-window';
import { normActions } from './utils';
import { DEFAULT_GLASS_ACTIONS } from './glass';
import { DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass';

describe('BinaryWindow.normActions', () => {
describe('normActions', () => {
it('returns the builtin actions when actions is undefined', () => {
expect(BinaryWindow.normActions(undefined)).toEqual([DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS]);
expect(normActions(undefined)).toEqual([DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS]);
});

it('returns [[], []] when actions is null, empty, or not an array', () => {
expect(BinaryWindow.normActions(null)).toEqual([[], []]);
expect(BinaryWindow.normActions('a')).toEqual([[], []]);
expect(BinaryWindow.normActions({})).toEqual([[], []]);
expect(BinaryWindow.normActions([])).toEqual([[], []]);
expect(normActions(null)).toEqual([[], []]);
expect(normActions('a')).toEqual([[], []]);
expect(normActions({})).toEqual([[], []]);
expect(normActions([])).toEqual([[], []]);
});

it('returns [glassActions, DEFAULT_DETACHED_GLASS_ACTIONS] for a single grouped array', () => {
const a = { label: 'A' };

expect(BinaryWindow.normActions([[a]])).toEqual([[a], DEFAULT_DETACHED_GLASS_ACTIONS]);
expect(normActions([[a]])).toEqual([[a], DEFAULT_DETACHED_GLASS_ACTIONS]);
});

it('returns [actions, DEFAULT_DETACHED_GLASS_ACTIONS] when actions is a flat array', () => {
const a = { label: 'A' };
const b = { label: 'B' };

expect(BinaryWindow.normActions([a, b])).toEqual([[a, b], DEFAULT_DETACHED_GLASS_ACTIONS]);
expect(normActions([a, b])).toEqual([[a, b], DEFAULT_DETACHED_GLASS_ACTIONS]);
});

it('returns [[], detachedGlassActions] when first group is absent', () => {
const b = { label: 'B' };

expect(BinaryWindow.normActions([undefined, [b]])).toEqual([[], [b]]);
expect(BinaryWindow.normActions([null, [b]])).toEqual([[], [b]]);
expect(normActions([undefined, [b]])).toEqual([[], [b]]);
expect(normActions([null, [b]])).toEqual([[], [b]]);
});

it('returns [glassActions, []] when second group is absent', () => {
const a = { label: 'A' };

expect(BinaryWindow.normActions([[a], undefined])).toEqual([[a], []]);
expect(BinaryWindow.normActions([[a], null])).toEqual([[a], []]);
expect(normActions([[a], undefined])).toEqual([[a], []]);
expect(normActions([[a], null])).toEqual([[a], []]);
});

it('returns actions as-is when both groups are arrays', () => {
const a = { label: 'A' };
const b = { label: 'B' };
const grouped = [[a], [b]];

expect(BinaryWindow.normActions(grouped)).toBe(grouped);
expect(normActions(grouped)).toBe(grouped);
});

it('throws when an array is present but neither of the first two slots is one', () => {
expect(() => BinaryWindow.normActions([null, null, []])).toThrow('[bwin] Invalid actions format');
expect(() => normActions([null, null, []])).toThrow('[bwin] Invalid actions format');
});
});
11 changes: 4 additions & 7 deletions src/binary-window/detached-glass/action.attach.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { extractChildNodes } from '@/utils';
import { transferGlass } from '../glass/utils';

export default {
label: '',
Expand All @@ -24,16 +24,13 @@ export default {
size = 0.5;
}

// Pass the inner nodes, not the bw-glass-content wrapper — Glass adds its own.
const contentEl = detachedGlassEl.querySelector('bw-glass-content');

binaryWindow.addPane(targetSashId, {
const paneSash = binaryWindow.addPane(targetSashId, {
position,
size,
content: extractChildNodes(contentEl),
title: detachedGlassEl.querySelector('bw-glass-title')?.textContent || '',
});

transferGlass(detachedGlassEl, paneSash.domNode);

binaryWindow.removeDetachedGlass(detachedGlassEl.id);
},
};
5 changes: 4 additions & 1 deletion src/binary-window/detached-glass/action.close.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { detachedGlassManager } from './manager';
import { removeGlassBackdrop } from './utils';

export default {
label: '',
Expand All @@ -9,6 +10,8 @@ export default {

detachedGlassManager.removeGlassById(glassEl.id);
glassEl.remove();

// Remove the modal backdrop tied to this glass, if any (windowless modal glass).
removeGlassBackdrop(glassEl.id);
},
};

2 changes: 1 addition & 1 deletion src/binary-window/detached-glass/activate.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default {
enableDetachedGlassActivate() {
// Clicking anywhere in a detached glass brings it to front. Move/resize
// grabs bubble here too, so focus handling lives in one place.
this.windowElement.addEventListener('pointerdown', (event) => {
document.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;

const glassEl = event.target.closest?.('bw-glass[detached]');
Expand Down
5 changes: 5 additions & 0 deletions src/binary-window/detached-glass/detached-glass.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import attachAction from './action.attach';
import minimizeAction from './action.minimize';

export const DEFAULT_DETACHED_GLASS_ACTIONS = [minimizeAction, attachAction, closeAction];

// A windowless glass floats on `document.body` with no owning window, so minimize/attach
// (which need a `binaryWindow`) don't apply — close is the only built-in that works.
export const DEFAULT_WINDOWLESS_GLASS_ACTIONS = [closeAction];

export class DetachedGlass extends Glass {
constructor(options) {
const {
Expand Down
6 changes: 5 additions & 1 deletion src/binary-window/detached-glass/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import dragModule from './drag';
import resizeModule from './resize';
import restoreModule from './restore';

export { DetachedGlass, DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass';
export {
DetachedGlass,
DEFAULT_DETACHED_GLASS_ACTIONS,
DEFAULT_WINDOWLESS_GLASS_ACTIONS,
} from './detached-glass';

export default {
enableDetachedGlassFeatures() {
Expand Down
11 changes: 7 additions & 4 deletions src/binary-window/detached-glass/manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ class DetachedGlassManager {
// Already front-most (it owns the [active] marker) → nothing to raise.
if (glassEl.hasAttribute('active')) return;

this.topZIndex += 1;
// Reserve 1 for modal on windowless glass
this.topZIndex += 2;
glassEl.style.zIndex = this.topZIndex;

// Only the front-most glass keeps [active]; it drives the stronger shadow in CSS.
glassEl.parentElement
?.querySelectorAll(':scope > bw-glass[detached][active]')
.forEach((el) => el !== glassEl && el.removeAttribute('active'));
// Cleared across all managed glasses, so a detached and a windowless glass
// (different parents) can't both look active at once.
this.glasses.forEach((el) => el !== glassEl && el.removeAttribute('active'));
glassEl.setAttribute('active', '');

return this.topZIndex;
}

removeGlassById(id) {
Expand Down
Loading