Skip to content

Commit f7b4996

Browse files
committed
feat: add slot geometry plugin
1 parent e01230b commit f7b4996

5 files changed

Lines changed: 324 additions & 0 deletions

File tree

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
"require": "./dist/cjs/mp3-encoder.js",
4141
"default": "./dist/cjs/mp3-encoder.js"
4242
},
43+
"./slot-geometry": {
44+
"types": "./dist/types/plugins/SlotGeometry/index.d.ts",
45+
"import": "./dist/es/slot-geometry.mjs",
46+
"require": "./dist/cjs/slot-geometry.js",
47+
"default": "./dist/cjs/slot-geometry.js"
48+
},
4349
"./dist/css/*": {
4450
"default": "./dist/css/*"
4551
},
@@ -57,6 +63,9 @@
5763
],
5864
"mp3-encoder": [
5965
"./dist/types/plugins/encoders/mp3.d.ts"
66+
],
67+
"slot-geometry": [
68+
"./dist/types/plugins/SlotGeometry/index.d.ts"
6069
]
6170
}
6271
},
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useRef,
7+
useState,
8+
} from 'react';
9+
import type { PropsWithChildren } from 'react';
10+
11+
/**
12+
* Slot geometry — an opt-in module that reports which registered "slots" are visually **obscured**.
13+
*
14+
* The ChatView `LayoutController` is purely logical: slots, bindings, a `hidden` flag, history — it
15+
* has no idea where slots land on screen. Whether one panel visually *covers* another is a physical
16+
* concern owned by the app's CSS (e.g. at narrow widths a secondary panel might be laid out at full
17+
* width, collapsing the primary one). This module is the missing physical half:
18+
*
19+
* - App code registers the DOM element backing each slot (`useRegisterSlotGeometry`).
20+
* - It observes those elements (`ResizeObserver` + window resize) and measures real rects.
21+
* - It reports which slots are currently obscured — collapsed to ~0 area, or covered by another
22+
* registered slot past a threshold — with no knowledge of breakpoints or class names.
23+
*
24+
* It is opt-in (wrap a subtree in `SlotGeometryProvider`) and **detection only**: it never mutates
25+
* the layout. Consumers read `isObscured(slot)` and decide what to do (e.g. release a covering
26+
* slot). A declarative rules/auto-close layer could sit on top of this.
27+
*
28+
* Dependency direction is one-way: this observes the DOM the layout produces; it does not depend on
29+
* the `LayoutController`, and slot ids are plain strings, so it works with any slotted layout.
30+
*/
31+
32+
/** A slot identifier. Plain string so the module is layout-agnostic (e.g. any `SlotName`). */
33+
export type SlotId = string;
34+
35+
/** Fraction of a slot's area another slot must overlap before we treat it as covered. */
36+
const COVER_THRESHOLD = 0.6;
37+
/** Rendered area (px²) below which a slot counts as collapsed/hidden — effectively obscured. */
38+
const MIN_VISIBLE_AREA = 1;
39+
40+
export type SlotCoverage = {
41+
/** slot id -> whether it is currently obscured (collapsed, or covered by another slot). */
42+
obscured: Record<SlotId, boolean>;
43+
};
44+
45+
export type SlotGeometryContextValue = {
46+
coverage: SlotCoverage;
47+
/**
48+
* Live, synchronous obscured check — measures the DOM at call time, so it is safe to call from
49+
* event handlers where the reactive `coverage` snapshot might lag a layout change.
50+
*/
51+
isObscured: (slot: SlotId) => boolean;
52+
register: (slot: SlotId, element: HTMLElement | null) => void;
53+
};
54+
55+
const noopContextValue: SlotGeometryContextValue = {
56+
coverage: { obscured: {} },
57+
isObscured: () => false,
58+
register: () => {
59+
// No-op: outside a SlotGeometryProvider there is no geometry to track, so registration is
60+
// silently ignored (consumers still render; `isObscured` just always returns false).
61+
},
62+
};
63+
64+
const SlotGeometryContext = createContext<SlotGeometryContextValue>(noopContextValue);
65+
66+
const intersectionArea = (a: DOMRect, b: DOMRect) => {
67+
const width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
68+
const height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
69+
return width * height;
70+
};
71+
72+
/**
73+
* @internal Exported for unit tests. A target is obscured when its own rendered box is ~0 area
74+
* (collapsed/hidden) or another element covers at least `COVER_THRESHOLD` of it.
75+
*/
76+
export const measureObscured = (target: HTMLElement, others: HTMLElement[]) => {
77+
const rect = target.getBoundingClientRect();
78+
const area = rect.width * rect.height;
79+
if (area < MIN_VISIBLE_AREA) return true;
80+
let maxCoveredFraction = 0;
81+
for (const other of others) {
82+
const fraction = intersectionArea(rect, other.getBoundingClientRect()) / area;
83+
if (fraction > maxCoveredFraction) maxCoveredFraction = fraction;
84+
}
85+
return maxCoveredFraction >= COVER_THRESHOLD;
86+
};
87+
88+
const coverageChanged = (previous: SlotCoverage, next: Record<SlotId, boolean>) => {
89+
const keys = new Set([...Object.keys(previous.obscured), ...Object.keys(next)]);
90+
for (const key of keys) {
91+
if (previous.obscured[key] !== next[key]) return true;
92+
}
93+
return false;
94+
};
95+
96+
export const SlotGeometryProvider = ({ children }: PropsWithChildren) => {
97+
const elementsRef = useRef<Map<SlotId, HTMLElement>>(new Map());
98+
const observerRef = useRef<ResizeObserver | null>(null);
99+
const rafRef = useRef<number | null>(null);
100+
const [coverage, setCoverage] = useState<SlotCoverage>({ obscured: {} });
101+
102+
const othersOf = (slot: SlotId) =>
103+
[...elementsRef.current.entries()]
104+
.filter(([id]) => id !== slot)
105+
.map(([, element]) => element);
106+
107+
const isObscured = useCallback((slot: SlotId) => {
108+
const element = elementsRef.current.get(slot);
109+
if (!element) return false;
110+
return measureObscured(element, othersOf(slot));
111+
}, []);
112+
113+
const recompute = useCallback(() => {
114+
const next: Record<SlotId, boolean> = {};
115+
for (const [slot, element] of elementsRef.current.entries()) {
116+
next[slot] = measureObscured(element, othersOf(slot));
117+
}
118+
setCoverage((previous) =>
119+
coverageChanged(previous, next) ? { obscured: next } : previous,
120+
);
121+
}, []);
122+
123+
// Coalesce bursts of ResizeObserver/resize callbacks into one measurement per frame. This also
124+
// keeps us clear of the "ResizeObserver loop" warning and avoids feedback churn: recompute only
125+
// pushes new state when an obscured flag actually flips, which is a fixed point.
126+
const scheduleRecompute = useCallback(() => {
127+
if (typeof requestAnimationFrame === 'undefined') {
128+
recompute();
129+
return;
130+
}
131+
if (rafRef.current !== null) return;
132+
rafRef.current = requestAnimationFrame(() => {
133+
rafRef.current = null;
134+
recompute();
135+
});
136+
}, [recompute]);
137+
138+
const register = useCallback(
139+
(slot: SlotId, element: HTMLElement | null) => {
140+
const elements = elementsRef.current;
141+
const previous = elements.get(slot);
142+
if (previous && previous !== element) observerRef.current?.unobserve(previous);
143+
144+
if (element) {
145+
elements.set(slot, element);
146+
observerRef.current?.observe(element);
147+
} else {
148+
elements.delete(slot);
149+
}
150+
scheduleRecompute();
151+
},
152+
[scheduleRecompute],
153+
);
154+
155+
useEffect(() => {
156+
if (typeof ResizeObserver === 'undefined') return;
157+
const observer = new ResizeObserver(() => scheduleRecompute());
158+
observerRef.current = observer;
159+
// Ref callbacks fire during commit (before this passive effect), so pick up anything that
160+
// registered before the observer existed.
161+
for (const element of elementsRef.current.values()) observer.observe(element);
162+
163+
const onResize = () => scheduleRecompute();
164+
window.addEventListener('resize', onResize);
165+
scheduleRecompute();
166+
167+
return () => {
168+
observer.disconnect();
169+
observerRef.current = null;
170+
window.removeEventListener('resize', onResize);
171+
if (rafRef.current !== null && typeof cancelAnimationFrame !== 'undefined') {
172+
cancelAnimationFrame(rafRef.current);
173+
rafRef.current = null;
174+
}
175+
};
176+
}, [scheduleRecompute]);
177+
178+
const value: SlotGeometryContextValue = { coverage, isObscured, register };
179+
180+
return (
181+
<SlotGeometryContext.Provider value={value}>{children}</SlotGeometryContext.Provider>
182+
);
183+
};
184+
185+
export const useSlotGeometry = () => useContext(SlotGeometryContext);
186+
187+
/** Returns a stable callback ref that registers/unregisters the element backing `slot`. */
188+
export const useRegisterSlotGeometry = (slot: SlotId) => {
189+
const { register } = useSlotGeometry();
190+
return useCallback(
191+
(element: HTMLElement | null) => register(slot, element),
192+
[register, slot],
193+
);
194+
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import React from 'react';
2+
import { act, render } from '@testing-library/react';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { measureObscured, SlotGeometryProvider, useSlotGeometry } from '../SlotGeometry';
6+
import type { SlotGeometryContextValue } from '../SlotGeometry';
7+
8+
const rect = (left: number, top: number, width: number, height: number): DOMRect =>
9+
({
10+
bottom: top + height,
11+
height,
12+
left,
13+
right: left + width,
14+
toJSON: () => ({}),
15+
top,
16+
width,
17+
x: left,
18+
y: top,
19+
}) as DOMRect;
20+
21+
const elementAt = (r: DOMRect) =>
22+
({ getBoundingClientRect: () => r }) as unknown as HTMLElement;
23+
24+
describe('measureObscured', () => {
25+
it('treats a collapsed (~0 area) element as obscured regardless of others', () => {
26+
expect(measureObscured(elementAt(rect(0, 0, 0, 100)), [])).toBe(true);
27+
expect(measureObscured(elementAt(rect(0, 0, 0, 0)), [])).toBe(true);
28+
});
29+
30+
it('is not obscured when visible with no other slots', () => {
31+
expect(measureObscured(elementAt(rect(0, 0, 100, 100)), [])).toBe(false);
32+
});
33+
34+
it('is not obscured by a side-by-side neighbour (no overlap)', () => {
35+
const target = elementAt(rect(0, 0, 100, 100));
36+
const neighbour = elementAt(rect(100, 0, 100, 100));
37+
expect(measureObscured(target, [neighbour])).toBe(false);
38+
});
39+
40+
it('is obscured when another slot fully covers it', () => {
41+
const target = elementAt(rect(0, 0, 100, 100));
42+
const cover = elementAt(rect(0, 0, 100, 100));
43+
expect(measureObscured(target, [cover])).toBe(true);
44+
});
45+
46+
it('respects the coverage threshold (0.6)', () => {
47+
const target = elementAt(rect(0, 0, 100, 100));
48+
// Covers 50% → below threshold → not obscured.
49+
expect(measureObscured(target, [elementAt(rect(50, 0, 100, 100))])).toBe(false);
50+
// Covers 70% → above threshold → obscured.
51+
expect(measureObscured(target, [elementAt(rect(30, 0, 100, 100))])).toBe(true);
52+
});
53+
});
54+
55+
describe('SlotGeometryProvider', () => {
56+
const OriginalResizeObserver = globalThis.ResizeObserver;
57+
58+
beforeEach(() => {
59+
globalThis.ResizeObserver = class implements ResizeObserver {
60+
disconnect() {}
61+
observe() {}
62+
unobserve() {}
63+
};
64+
});
65+
66+
afterEach(() => {
67+
globalThis.ResizeObserver = OriginalResizeObserver;
68+
vi.restoreAllMocks();
69+
});
70+
71+
const renderProvider = () => {
72+
let geometry: SlotGeometryContextValue | undefined;
73+
const Capture = () => {
74+
geometry = useSlotGeometry();
75+
return null;
76+
};
77+
render(
78+
<SlotGeometryProvider>
79+
<Capture />
80+
</SlotGeometryProvider>,
81+
);
82+
if (!geometry) throw new Error('geometry context not captured');
83+
return () => geometry as SlotGeometryContextValue;
84+
};
85+
86+
it('reports a registered, collapsed slot as obscured (live check)', () => {
87+
const geometry = renderProvider();
88+
act(() => geometry().register('main', elementAt(rect(0, 0, 0, 600))));
89+
expect(geometry().isObscured('main')).toBe(true);
90+
});
91+
92+
it('reports a visible registered slot as not obscured', () => {
93+
const geometry = renderProvider();
94+
act(() => geometry().register('main', elementAt(rect(0, 0, 400, 600))));
95+
expect(geometry().isObscured('main')).toBe(false);
96+
});
97+
98+
it('reports a slot covered by another registered slot as obscured', () => {
99+
const geometry = renderProvider();
100+
act(() => {
101+
geometry().register('main', elementAt(rect(0, 0, 400, 600)));
102+
geometry().register('side', elementAt(rect(0, 0, 400, 600)));
103+
});
104+
expect(geometry().isObscured('main')).toBe(true);
105+
});
106+
107+
it('returns false for unregistered / unknown slots', () => {
108+
const geometry = renderProvider();
109+
expect(geometry().isObscured('missing')).toBe(false);
110+
act(() => geometry().register('main', elementAt(rect(0, 0, 0, 0))));
111+
act(() => geometry().register('main', null));
112+
expect(geometry().isObscured('main')).toBe(false);
113+
});
114+
});

src/plugins/SlotGeometry/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export {
2+
SlotGeometryProvider,
3+
useSlotGeometry,
4+
useRegisterSlotGeometry,
5+
} from './SlotGeometry';
6+
export type { SlotCoverage, SlotGeometryContextValue, SlotId } from './SlotGeometry';

vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default defineConfig({
2222
'channel-detail': resolve(__dirname, './src/plugins/ChannelDetail/index.ts'),
2323
emojis: resolve(__dirname, './src/plugins/Emojis/index.ts'),
2424
'mp3-encoder': resolve(__dirname, './src/plugins/encoders/mp3.ts'),
25+
'slot-geometry': resolve(__dirname, './src/plugins/SlotGeometry/index.ts'),
2526
},
2627
},
2728
emptyOutDir: false,

0 commit comments

Comments
 (0)