@andrewcyuan/focusgrid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/core/index.cjs +812 -0
- package/dist/core/index.d.cts +184 -0
- package/dist/core/index.d.ts +184 -0
- package/dist/core/index.js +807 -0
- package/dist/dom/index.cjs +318 -0
- package/dist/dom/index.d.cts +177 -0
- package/dist/dom/index.d.ts +177 -0
- package/dist/dom/index.js +315 -0
- package/dist/react/index.cjs +1336 -0
- package/dist/react/index.d.cts +294 -0
- package/dist/react/index.d.ts +294 -0
- package/dist/react/index.js +1328 -0
- package/dist/react/styles.css +42 -0
- package/package.json +66 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region dom/src/controller.ts
|
|
3
|
+
/** Attach the renderer root; pane elements are resolved only when focus is requested. */
|
|
4
|
+
var FocusGridDomController = class {
|
|
5
|
+
root = null;
|
|
6
|
+
setRoot(root) {
|
|
7
|
+
this.root = root;
|
|
8
|
+
}
|
|
9
|
+
focus(paneId) {
|
|
10
|
+
const root = this.root;
|
|
11
|
+
if (!root) return false;
|
|
12
|
+
const pane = [...root.querySelectorAll("[data-pane-id]")].find((element) => element.dataset.paneId === paneId && element.closest(".FocusgridFocusGrid") === root);
|
|
13
|
+
if (!pane) return false;
|
|
14
|
+
const ownsFocus = () => pane.contains(root.ownerDocument.activeElement);
|
|
15
|
+
if (ownsFocus()) return true;
|
|
16
|
+
pane.focus({ preventScroll: true });
|
|
17
|
+
return ownsFocus();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region dom/src/frame.ts
|
|
22
|
+
function requestFrame(callback) {
|
|
23
|
+
if (typeof globalThis.requestAnimationFrame === "function") return {
|
|
24
|
+
kind: "animation",
|
|
25
|
+
id: globalThis.requestAnimationFrame(() => callback())
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
kind: "timeout",
|
|
29
|
+
id: globalThis.setTimeout(callback, 0)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function cancelFrame(frame) {
|
|
33
|
+
if (frame.kind === "animation") {
|
|
34
|
+
globalThis.cancelAnimationFrame(frame.id);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
globalThis.clearTimeout(frame.id);
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region dom/src/pointer-resize.ts
|
|
41
|
+
var PointerResizeController = class {
|
|
42
|
+
controller;
|
|
43
|
+
state = { phase: "idle" };
|
|
44
|
+
onDocumentPointerMove = (event) => {
|
|
45
|
+
this.updateResize(event);
|
|
46
|
+
};
|
|
47
|
+
onDocumentPointerUp = (event) => {
|
|
48
|
+
this.endResize(event);
|
|
49
|
+
};
|
|
50
|
+
onDocumentPointerCancel = (event) => {
|
|
51
|
+
this.endResize(event);
|
|
52
|
+
};
|
|
53
|
+
constructor(controller) {
|
|
54
|
+
this.controller = controller;
|
|
55
|
+
}
|
|
56
|
+
startResize(event, handle, captureTarget) {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
this.destroy();
|
|
59
|
+
const ownerDocument = this.resolveOwnerDocument(event, captureTarget);
|
|
60
|
+
const pointerCaptureTarget = this.resolveCaptureTarget(captureTarget);
|
|
61
|
+
const startSizes = this.controller.getSplitSizes(handle.splitId);
|
|
62
|
+
if (!startSizes) return;
|
|
63
|
+
this.state = {
|
|
64
|
+
phase: "dragging",
|
|
65
|
+
pointerId: event.pointerId,
|
|
66
|
+
splitId: handle.splitId,
|
|
67
|
+
index: handle.index,
|
|
68
|
+
startX: event.clientX,
|
|
69
|
+
startY: event.clientY,
|
|
70
|
+
direction: handle.direction,
|
|
71
|
+
startSizes,
|
|
72
|
+
ownerDocument,
|
|
73
|
+
captureTarget: pointerCaptureTarget,
|
|
74
|
+
pendingDeltaPx: 0,
|
|
75
|
+
pendingFrame: null
|
|
76
|
+
};
|
|
77
|
+
this.setPointerCapture(pointerCaptureTarget, event.pointerId);
|
|
78
|
+
this.addDocumentListeners(ownerDocument);
|
|
79
|
+
}
|
|
80
|
+
updateResize(event) {
|
|
81
|
+
if (this.state.phase !== "dragging" || this.state.pointerId !== event.pointerId) return;
|
|
82
|
+
const deltaPx = this.state.direction === "horizontal" ? event.clientX - this.state.startX : event.clientY - this.state.startY;
|
|
83
|
+
if (this.state.pendingFrame) {
|
|
84
|
+
this.state = {
|
|
85
|
+
...this.state,
|
|
86
|
+
pendingDeltaPx: deltaPx
|
|
87
|
+
};
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const pendingFrame = requestFrame(() => {
|
|
91
|
+
if (this.state.phase !== "dragging") return;
|
|
92
|
+
this.state = {
|
|
93
|
+
...this.state,
|
|
94
|
+
pendingFrame: null
|
|
95
|
+
};
|
|
96
|
+
this.dispatchPendingResize();
|
|
97
|
+
});
|
|
98
|
+
this.state = {
|
|
99
|
+
...this.state,
|
|
100
|
+
pendingDeltaPx: deltaPx,
|
|
101
|
+
pendingFrame
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
endResize(event) {
|
|
105
|
+
if (this.state.phase !== "dragging" || this.state.pointerId !== event.pointerId) return;
|
|
106
|
+
this.flushPendingFrame();
|
|
107
|
+
this.finishDrag();
|
|
108
|
+
}
|
|
109
|
+
destroy() {
|
|
110
|
+
this.cancelPendingFrame();
|
|
111
|
+
this.finishDrag();
|
|
112
|
+
}
|
|
113
|
+
finishDrag() {
|
|
114
|
+
if (this.state.phase !== "dragging") return;
|
|
115
|
+
const { captureTarget, ownerDocument, pointerId } = this.state;
|
|
116
|
+
this.removeDocumentListeners(ownerDocument);
|
|
117
|
+
this.releasePointerCapture(captureTarget, pointerId);
|
|
118
|
+
this.state = { phase: "idle" };
|
|
119
|
+
}
|
|
120
|
+
flushPendingFrame() {
|
|
121
|
+
if (this.state.phase !== "dragging" || !this.state.pendingFrame) return;
|
|
122
|
+
cancelFrame(this.state.pendingFrame);
|
|
123
|
+
this.state = {
|
|
124
|
+
...this.state,
|
|
125
|
+
pendingFrame: null
|
|
126
|
+
};
|
|
127
|
+
this.dispatchPendingResize();
|
|
128
|
+
}
|
|
129
|
+
dispatchPendingResize() {
|
|
130
|
+
if (this.state.phase !== "dragging") return;
|
|
131
|
+
this.controller.resizeHandle(this.state.splitId, {
|
|
132
|
+
index: this.state.index,
|
|
133
|
+
deltaPx: this.state.pendingDeltaPx,
|
|
134
|
+
snapshotSizes: this.state.startSizes
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
cancelPendingFrame() {
|
|
138
|
+
if (this.state.phase !== "dragging" || !this.state.pendingFrame) return;
|
|
139
|
+
cancelFrame(this.state.pendingFrame);
|
|
140
|
+
this.state = {
|
|
141
|
+
...this.state,
|
|
142
|
+
pendingFrame: null
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
addDocumentListeners(ownerDocument) {
|
|
146
|
+
ownerDocument?.addEventListener("pointermove", this.onDocumentPointerMove);
|
|
147
|
+
ownerDocument?.addEventListener("pointerup", this.onDocumentPointerUp);
|
|
148
|
+
ownerDocument?.addEventListener("pointercancel", this.onDocumentPointerCancel);
|
|
149
|
+
}
|
|
150
|
+
removeDocumentListeners(ownerDocument) {
|
|
151
|
+
ownerDocument?.removeEventListener("pointermove", this.onDocumentPointerMove);
|
|
152
|
+
ownerDocument?.removeEventListener("pointerup", this.onDocumentPointerUp);
|
|
153
|
+
ownerDocument?.removeEventListener("pointercancel", this.onDocumentPointerCancel);
|
|
154
|
+
}
|
|
155
|
+
resolveOwnerDocument(event, captureTarget) {
|
|
156
|
+
if (captureTarget?.ownerDocument) return captureTarget.ownerDocument;
|
|
157
|
+
if (typeof Element !== "undefined" && event.target instanceof Element && event.target.ownerDocument) return event.target.ownerDocument;
|
|
158
|
+
return typeof document === "undefined" ? null : document;
|
|
159
|
+
}
|
|
160
|
+
resolveCaptureTarget(captureTarget) {
|
|
161
|
+
if (!captureTarget) return null;
|
|
162
|
+
if ("setPointerCapture" in captureTarget || "releasePointerCapture" in captureTarget) return captureTarget;
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
setPointerCapture(target, pointerId) {
|
|
166
|
+
try {
|
|
167
|
+
target?.setPointerCapture?.(pointerId);
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
releasePointerCapture(target, pointerId) {
|
|
171
|
+
if (!target?.releasePointerCapture) return;
|
|
172
|
+
try {
|
|
173
|
+
if (!target.hasPointerCapture || target.hasPointerCapture(pointerId)) target.releasePointerCapture(pointerId);
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region dom/src/resize-observer.ts
|
|
179
|
+
function observeRootSize(root, controller) {
|
|
180
|
+
const updateSize = ({ width, height }) => {
|
|
181
|
+
controller.setContainerSize(Math.floor(width), Math.floor(height));
|
|
182
|
+
};
|
|
183
|
+
if (typeof ResizeObserver === "undefined") {
|
|
184
|
+
updateSize(root.getBoundingClientRect());
|
|
185
|
+
return () => {};
|
|
186
|
+
}
|
|
187
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
188
|
+
if (entry) updateSize(entry.contentRect);
|
|
189
|
+
});
|
|
190
|
+
observer.observe(root);
|
|
191
|
+
return () => observer.disconnect();
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region dom/src/interactivity.ts
|
|
195
|
+
const INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
196
|
+
"alertdialog",
|
|
197
|
+
"button",
|
|
198
|
+
"checkbox",
|
|
199
|
+
"combobox",
|
|
200
|
+
"dialog",
|
|
201
|
+
"grid",
|
|
202
|
+
"gridcell",
|
|
203
|
+
"link",
|
|
204
|
+
"listbox",
|
|
205
|
+
"menu",
|
|
206
|
+
"menubar",
|
|
207
|
+
"menuitem",
|
|
208
|
+
"menuitemcheckbox",
|
|
209
|
+
"menuitemradio",
|
|
210
|
+
"option",
|
|
211
|
+
"radio",
|
|
212
|
+
"radiogroup",
|
|
213
|
+
"scrollbar",
|
|
214
|
+
"searchbox",
|
|
215
|
+
"slider",
|
|
216
|
+
"spinbutton",
|
|
217
|
+
"switch",
|
|
218
|
+
"tab",
|
|
219
|
+
"tablist",
|
|
220
|
+
"textbox",
|
|
221
|
+
"toolbar",
|
|
222
|
+
"tree",
|
|
223
|
+
"treegrid",
|
|
224
|
+
"treeitem"
|
|
225
|
+
]);
|
|
226
|
+
function hasInteractiveOwner(target, boundary) {
|
|
227
|
+
let element = target;
|
|
228
|
+
while (element && element !== boundary) {
|
|
229
|
+
if (element instanceof HTMLElement && isInteractiveElement(element)) return true;
|
|
230
|
+
element = element.parentElement;
|
|
231
|
+
}
|
|
232
|
+
return boundary instanceof HTMLElement && isInteractiveElement(boundary);
|
|
233
|
+
}
|
|
234
|
+
function shouldFocusPaneShellForPointer(target, paneShell) {
|
|
235
|
+
return target === paneShell || !hasInteractiveOwner(target, paneShell);
|
|
236
|
+
}
|
|
237
|
+
function isInteractiveElement(element) {
|
|
238
|
+
if (isUnavailableElement(element)) return false;
|
|
239
|
+
const tagName = element.tagName.toLowerCase();
|
|
240
|
+
const role = element.getAttribute("role")?.toLowerCase();
|
|
241
|
+
return element.isContentEditable || element.tabIndex >= 0 || tagName === "a" && element.hasAttribute("href") || tagName === "button" || tagName === "input" || tagName === "select" || tagName === "textarea" || tagName === "dialog" || tagName === "summary" || role !== void 0 && INTERACTIVE_ROLES.has(role);
|
|
242
|
+
}
|
|
243
|
+
function isUnavailableElement(element) {
|
|
244
|
+
let current = element;
|
|
245
|
+
while (current) {
|
|
246
|
+
if (current.hidden || current.hasAttribute("hidden") || current.hasAttribute("inert") || current.getAttribute("aria-hidden") === "true" || current.getAttribute("aria-disabled") === "true" || "disabled" in current && Boolean(current.disabled)) return true;
|
|
247
|
+
const view = current.ownerDocument.defaultView;
|
|
248
|
+
if (view) {
|
|
249
|
+
const style = view.getComputedStyle(current);
|
|
250
|
+
if (style.display === "none" || style.visibility === "hidden") return true;
|
|
251
|
+
}
|
|
252
|
+
current = current.parentElement;
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
function isEditableTarget(target) {
|
|
257
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
258
|
+
if (target.isContentEditable || target.getAttribute("role")?.toLowerCase() === "textbox") return true;
|
|
259
|
+
if (target.matches("textarea, select")) return !target.matches(":disabled");
|
|
260
|
+
return target instanceof HTMLInputElement && !target.disabled && !target.readOnly && [
|
|
261
|
+
"date",
|
|
262
|
+
"datetime-local",
|
|
263
|
+
"email",
|
|
264
|
+
"file",
|
|
265
|
+
"month",
|
|
266
|
+
"number",
|
|
267
|
+
"password",
|
|
268
|
+
"search",
|
|
269
|
+
"tel",
|
|
270
|
+
"text",
|
|
271
|
+
"time",
|
|
272
|
+
"url",
|
|
273
|
+
"week"
|
|
274
|
+
].includes(target.type);
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region dom/src/mount.ts
|
|
278
|
+
/** Own native grid interactions; React only attaches and detaches the root. */
|
|
279
|
+
function mountFocusGrid(root, controller, dom) {
|
|
280
|
+
dom.setRoot(root);
|
|
281
|
+
const resize = new PointerResizeController(controller);
|
|
282
|
+
const ownedTarget = (event) => {
|
|
283
|
+
const target = event.target;
|
|
284
|
+
return target instanceof Element && target.closest(".FocusgridFocusGrid") === root ? target : null;
|
|
285
|
+
};
|
|
286
|
+
const onFocus = (event) => {
|
|
287
|
+
const paneId = ownedTarget(event)?.closest("[data-pane-id]")?.dataset.paneId;
|
|
288
|
+
if (paneId) controller.focus(paneId);
|
|
289
|
+
};
|
|
290
|
+
const onPointerDown = (event) => {
|
|
291
|
+
const target = ownedTarget(event);
|
|
292
|
+
if (!target) return;
|
|
293
|
+
const separator = target.closest("[data-resize-handle]");
|
|
294
|
+
if (separator) {
|
|
295
|
+
const handle = controller.getLayout().handles.find((handle) => handle.id === separator.dataset.resizeHandle);
|
|
296
|
+
if (handle) resize.startResize(event, handle, separator);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const pane = target.closest("[data-pane-id]");
|
|
300
|
+
if (!pane?.dataset.paneId) return;
|
|
301
|
+
controller.focus(pane.dataset.paneId);
|
|
302
|
+
if (shouldFocusPaneShellForPointer(target, pane)) pane.focus({ preventScroll: true });
|
|
303
|
+
};
|
|
304
|
+
root.addEventListener("focusin", onFocus, true);
|
|
305
|
+
root.addEventListener("pointerdown", onPointerDown);
|
|
306
|
+
const stopObserving = observeRootSize(root, controller);
|
|
307
|
+
return () => {
|
|
308
|
+
root.removeEventListener("focusin", onFocus, true);
|
|
309
|
+
root.removeEventListener("pointerdown", onPointerDown);
|
|
310
|
+
resize.destroy();
|
|
311
|
+
stopObserving();
|
|
312
|
+
dom.setRoot(null);
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
//#endregion
|
|
316
|
+
exports.FocusGridDomController = FocusGridDomController;
|
|
317
|
+
exports.isEditableTarget = isEditableTarget;
|
|
318
|
+
exports.mountFocusGrid = mountFocusGrid;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
//#region core/src/layout/types.d.ts
|
|
2
|
+
type PaneId = string;
|
|
3
|
+
type NodeId = string;
|
|
4
|
+
type Orientation = "horizontal" | "vertical";
|
|
5
|
+
declare const cardinalDirections: readonly ["left", "right", "up", "down"];
|
|
6
|
+
type CardinalDirection = (typeof cardinalDirections)[number];
|
|
7
|
+
declare enum PaneCommandCapability {
|
|
8
|
+
ResizeX = "canResizeX",
|
|
9
|
+
ResizeY = "canResizeY",
|
|
10
|
+
Remove = "canRemove",
|
|
11
|
+
SplitHorizontal = "canSplitHorizontal",
|
|
12
|
+
SplitVertical = "canSplitVertical",
|
|
13
|
+
SwapX = "canSwapX",
|
|
14
|
+
SwapY = "canSwapY",
|
|
15
|
+
Focus = "canFocus"
|
|
16
|
+
}
|
|
17
|
+
type PaneCommandCapabilityInput = Partial<Record<PaneCommandCapability, boolean>>;
|
|
18
|
+
type PaneNode = {
|
|
19
|
+
kind: "pane";
|
|
20
|
+
id: NodeId;
|
|
21
|
+
paneId: PaneId;
|
|
22
|
+
} & PaneCommandCapabilityInput;
|
|
23
|
+
type SplitNode = {
|
|
24
|
+
kind: "split";
|
|
25
|
+
id: NodeId;
|
|
26
|
+
orientation: Orientation;
|
|
27
|
+
children: LayoutNode[];
|
|
28
|
+
sizes: number[];
|
|
29
|
+
lastFocusedChildId?: NodeId;
|
|
30
|
+
};
|
|
31
|
+
type LayoutNode = PaneNode | SplitNode;
|
|
32
|
+
type Rect = {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
};
|
|
38
|
+
type ComputedPane = {
|
|
39
|
+
paneId: PaneId;
|
|
40
|
+
nodeId: NodeId;
|
|
41
|
+
rect: Rect;
|
|
42
|
+
active: boolean;
|
|
43
|
+
};
|
|
44
|
+
type ComputedHandle = {
|
|
45
|
+
id: string;
|
|
46
|
+
splitId: NodeId;
|
|
47
|
+
index: number;
|
|
48
|
+
rect: Rect;
|
|
49
|
+
direction: Orientation;
|
|
50
|
+
};
|
|
51
|
+
type ComputedNode = {
|
|
52
|
+
kind: "pane";
|
|
53
|
+
pane: ComputedPane;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "split";
|
|
56
|
+
orientation: Orientation;
|
|
57
|
+
children: ComputedNode[];
|
|
58
|
+
handles: ComputedHandle[];
|
|
59
|
+
};
|
|
60
|
+
type ComputedLayout = {
|
|
61
|
+
root: ComputedNode;
|
|
62
|
+
rectByNodeId: Map<NodeId, Rect>;
|
|
63
|
+
panes: ComputedPane[];
|
|
64
|
+
handles: ComputedHandle[];
|
|
65
|
+
};
|
|
66
|
+
type FocusGridControllerState = {
|
|
67
|
+
root: LayoutNode;
|
|
68
|
+
activePaneId: PaneId | null;
|
|
69
|
+
container: {
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region core/src/controller.d.ts
|
|
76
|
+
interface DomController {
|
|
77
|
+
focus(paneId: PaneId): boolean;
|
|
78
|
+
}
|
|
79
|
+
type Listener = (next: FocusGridControllerState, previous: FocusGridControllerState) => void;
|
|
80
|
+
type PaneLayoutChangeEvent = {
|
|
81
|
+
pane: ComputedPane;
|
|
82
|
+
previousPane: ComputedPane;
|
|
83
|
+
controller: FocusGridController;
|
|
84
|
+
};
|
|
85
|
+
type PaneCloseEvent = {
|
|
86
|
+
paneId: PaneId;
|
|
87
|
+
previousPane: ComputedPane;
|
|
88
|
+
controller: FocusGridController;
|
|
89
|
+
};
|
|
90
|
+
type PaneEventHandlers = {
|
|
91
|
+
onPaneLayoutChange?: (event: PaneLayoutChangeEvent) => void;
|
|
92
|
+
onPaneClose?: (event: PaneCloseEvent) => void;
|
|
93
|
+
};
|
|
94
|
+
type PaneDefaults = PaneCommandCapabilityInput;
|
|
95
|
+
type FocusGridControllerProps = FocusGridControllerState & {
|
|
96
|
+
paneDefaults?: PaneDefaults;
|
|
97
|
+
minWidth?: number;
|
|
98
|
+
minHeight?: number;
|
|
99
|
+
directionalFocusOverflow?: boolean;
|
|
100
|
+
};
|
|
101
|
+
type SplitPaneOptions = Partial<Omit<PaneNode, "kind" | "id" | "paneId">> & {
|
|
102
|
+
side: CardinalDirection;
|
|
103
|
+
newPaneId?: PaneId;
|
|
104
|
+
newPaneNodeId?: NodeId;
|
|
105
|
+
splitId?: NodeId;
|
|
106
|
+
preserveActivePane?: boolean;
|
|
107
|
+
};
|
|
108
|
+
type ResizePaneOptions = {
|
|
109
|
+
direction: CardinalDirection;
|
|
110
|
+
deltaPx: number;
|
|
111
|
+
};
|
|
112
|
+
type ResizeHandleOptions = {
|
|
113
|
+
index: number;
|
|
114
|
+
deltaPx: number;
|
|
115
|
+
snapshotSizes?: number[];
|
|
116
|
+
};
|
|
117
|
+
declare class FocusGridController {
|
|
118
|
+
private readonly domController;
|
|
119
|
+
readonly minWidth: number;
|
|
120
|
+
readonly minHeight: number;
|
|
121
|
+
readonly directionalFocusOverflow: boolean;
|
|
122
|
+
private currentState;
|
|
123
|
+
private cachedLayout;
|
|
124
|
+
private readonly paneDefaults;
|
|
125
|
+
private readonly listeners;
|
|
126
|
+
constructor({
|
|
127
|
+
paneDefaults,
|
|
128
|
+
minWidth,
|
|
129
|
+
minHeight,
|
|
130
|
+
directionalFocusOverflow,
|
|
131
|
+
...state
|
|
132
|
+
}: FocusGridControllerProps, domController: DomController);
|
|
133
|
+
/** Computed render data; stable until the next committed change. */
|
|
134
|
+
getLayout(): ComputedLayout;
|
|
135
|
+
getPane(paneId: PaneId): PaneNode | null;
|
|
136
|
+
getContainerSize(): {
|
|
137
|
+
width: number;
|
|
138
|
+
height: number;
|
|
139
|
+
};
|
|
140
|
+
getSplitSizes(splitId: NodeId): number[] | null;
|
|
141
|
+
splitActive(side: CardinalDirection): PaneId | null;
|
|
142
|
+
removeActive(): boolean;
|
|
143
|
+
focusAdjacent(direction: CardinalDirection): boolean;
|
|
144
|
+
swapAdjacent(direction: CardinalDirection): boolean;
|
|
145
|
+
resizeActive(direction: CardinalDirection, deltaPx: number): boolean;
|
|
146
|
+
split(paneNodeId: NodeId, props: SplitPaneOptions): PaneId | null;
|
|
147
|
+
remove(paneId: PaneId): boolean;
|
|
148
|
+
swap(firstPaneId: PaneId, secondPaneId: PaneId): boolean;
|
|
149
|
+
resize(paneId: PaneId, props: ResizePaneOptions): boolean;
|
|
150
|
+
resizeHandle(splitId: NodeId, props: ResizeHandleOptions): boolean;
|
|
151
|
+
focus(paneId: PaneId): boolean;
|
|
152
|
+
updatePane(paneId: PaneId, patch: Partial<Omit<PaneNode, "kind" | "id" | "paneId">>): boolean;
|
|
153
|
+
setContainerSize(width: number, height: number): boolean;
|
|
154
|
+
private commit;
|
|
155
|
+
subscribePaneEvents({
|
|
156
|
+
onPaneLayoutChange,
|
|
157
|
+
onPaneClose
|
|
158
|
+
}: PaneEventHandlers): () => void;
|
|
159
|
+
subscribe(listener: Listener): () => void;
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region dom/src/controller.d.ts
|
|
163
|
+
/** Attach the renderer root; pane elements are resolved only when focus is requested. */
|
|
164
|
+
declare class FocusGridDomController implements DomController {
|
|
165
|
+
private root;
|
|
166
|
+
setRoot(root: HTMLElement | null): void;
|
|
167
|
+
focus(paneId: PaneId): boolean;
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region dom/src/mount.d.ts
|
|
171
|
+
/** Own native grid interactions; React only attaches and detaches the root. */
|
|
172
|
+
declare function mountFocusGrid(root: HTMLElement, controller: FocusGridController, dom: FocusGridDomController): () => void;
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region dom/src/interactivity.d.ts
|
|
175
|
+
declare function isEditableTarget(target: EventTarget | null): boolean;
|
|
176
|
+
//#endregion
|
|
177
|
+
export { FocusGridDomController, isEditableTarget, mountFocusGrid };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
//#region core/src/layout/types.d.ts
|
|
2
|
+
type PaneId = string;
|
|
3
|
+
type NodeId = string;
|
|
4
|
+
type Orientation = "horizontal" | "vertical";
|
|
5
|
+
declare const cardinalDirections: readonly ["left", "right", "up", "down"];
|
|
6
|
+
type CardinalDirection = (typeof cardinalDirections)[number];
|
|
7
|
+
declare enum PaneCommandCapability {
|
|
8
|
+
ResizeX = "canResizeX",
|
|
9
|
+
ResizeY = "canResizeY",
|
|
10
|
+
Remove = "canRemove",
|
|
11
|
+
SplitHorizontal = "canSplitHorizontal",
|
|
12
|
+
SplitVertical = "canSplitVertical",
|
|
13
|
+
SwapX = "canSwapX",
|
|
14
|
+
SwapY = "canSwapY",
|
|
15
|
+
Focus = "canFocus"
|
|
16
|
+
}
|
|
17
|
+
type PaneCommandCapabilityInput = Partial<Record<PaneCommandCapability, boolean>>;
|
|
18
|
+
type PaneNode = {
|
|
19
|
+
kind: "pane";
|
|
20
|
+
id: NodeId;
|
|
21
|
+
paneId: PaneId;
|
|
22
|
+
} & PaneCommandCapabilityInput;
|
|
23
|
+
type SplitNode = {
|
|
24
|
+
kind: "split";
|
|
25
|
+
id: NodeId;
|
|
26
|
+
orientation: Orientation;
|
|
27
|
+
children: LayoutNode[];
|
|
28
|
+
sizes: number[];
|
|
29
|
+
lastFocusedChildId?: NodeId;
|
|
30
|
+
};
|
|
31
|
+
type LayoutNode = PaneNode | SplitNode;
|
|
32
|
+
type Rect = {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
};
|
|
38
|
+
type ComputedPane = {
|
|
39
|
+
paneId: PaneId;
|
|
40
|
+
nodeId: NodeId;
|
|
41
|
+
rect: Rect;
|
|
42
|
+
active: boolean;
|
|
43
|
+
};
|
|
44
|
+
type ComputedHandle = {
|
|
45
|
+
id: string;
|
|
46
|
+
splitId: NodeId;
|
|
47
|
+
index: number;
|
|
48
|
+
rect: Rect;
|
|
49
|
+
direction: Orientation;
|
|
50
|
+
};
|
|
51
|
+
type ComputedNode = {
|
|
52
|
+
kind: "pane";
|
|
53
|
+
pane: ComputedPane;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "split";
|
|
56
|
+
orientation: Orientation;
|
|
57
|
+
children: ComputedNode[];
|
|
58
|
+
handles: ComputedHandle[];
|
|
59
|
+
};
|
|
60
|
+
type ComputedLayout = {
|
|
61
|
+
root: ComputedNode;
|
|
62
|
+
rectByNodeId: Map<NodeId, Rect>;
|
|
63
|
+
panes: ComputedPane[];
|
|
64
|
+
handles: ComputedHandle[];
|
|
65
|
+
};
|
|
66
|
+
type FocusGridControllerState = {
|
|
67
|
+
root: LayoutNode;
|
|
68
|
+
activePaneId: PaneId | null;
|
|
69
|
+
container: {
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region core/src/controller.d.ts
|
|
76
|
+
interface DomController {
|
|
77
|
+
focus(paneId: PaneId): boolean;
|
|
78
|
+
}
|
|
79
|
+
type Listener = (next: FocusGridControllerState, previous: FocusGridControllerState) => void;
|
|
80
|
+
type PaneLayoutChangeEvent = {
|
|
81
|
+
pane: ComputedPane;
|
|
82
|
+
previousPane: ComputedPane;
|
|
83
|
+
controller: FocusGridController;
|
|
84
|
+
};
|
|
85
|
+
type PaneCloseEvent = {
|
|
86
|
+
paneId: PaneId;
|
|
87
|
+
previousPane: ComputedPane;
|
|
88
|
+
controller: FocusGridController;
|
|
89
|
+
};
|
|
90
|
+
type PaneEventHandlers = {
|
|
91
|
+
onPaneLayoutChange?: (event: PaneLayoutChangeEvent) => void;
|
|
92
|
+
onPaneClose?: (event: PaneCloseEvent) => void;
|
|
93
|
+
};
|
|
94
|
+
type PaneDefaults = PaneCommandCapabilityInput;
|
|
95
|
+
type FocusGridControllerProps = FocusGridControllerState & {
|
|
96
|
+
paneDefaults?: PaneDefaults;
|
|
97
|
+
minWidth?: number;
|
|
98
|
+
minHeight?: number;
|
|
99
|
+
directionalFocusOverflow?: boolean;
|
|
100
|
+
};
|
|
101
|
+
type SplitPaneOptions = Partial<Omit<PaneNode, "kind" | "id" | "paneId">> & {
|
|
102
|
+
side: CardinalDirection;
|
|
103
|
+
newPaneId?: PaneId;
|
|
104
|
+
newPaneNodeId?: NodeId;
|
|
105
|
+
splitId?: NodeId;
|
|
106
|
+
preserveActivePane?: boolean;
|
|
107
|
+
};
|
|
108
|
+
type ResizePaneOptions = {
|
|
109
|
+
direction: CardinalDirection;
|
|
110
|
+
deltaPx: number;
|
|
111
|
+
};
|
|
112
|
+
type ResizeHandleOptions = {
|
|
113
|
+
index: number;
|
|
114
|
+
deltaPx: number;
|
|
115
|
+
snapshotSizes?: number[];
|
|
116
|
+
};
|
|
117
|
+
declare class FocusGridController {
|
|
118
|
+
private readonly domController;
|
|
119
|
+
readonly minWidth: number;
|
|
120
|
+
readonly minHeight: number;
|
|
121
|
+
readonly directionalFocusOverflow: boolean;
|
|
122
|
+
private currentState;
|
|
123
|
+
private cachedLayout;
|
|
124
|
+
private readonly paneDefaults;
|
|
125
|
+
private readonly listeners;
|
|
126
|
+
constructor({
|
|
127
|
+
paneDefaults,
|
|
128
|
+
minWidth,
|
|
129
|
+
minHeight,
|
|
130
|
+
directionalFocusOverflow,
|
|
131
|
+
...state
|
|
132
|
+
}: FocusGridControllerProps, domController: DomController);
|
|
133
|
+
/** Computed render data; stable until the next committed change. */
|
|
134
|
+
getLayout(): ComputedLayout;
|
|
135
|
+
getPane(paneId: PaneId): PaneNode | null;
|
|
136
|
+
getContainerSize(): {
|
|
137
|
+
width: number;
|
|
138
|
+
height: number;
|
|
139
|
+
};
|
|
140
|
+
getSplitSizes(splitId: NodeId): number[] | null;
|
|
141
|
+
splitActive(side: CardinalDirection): PaneId | null;
|
|
142
|
+
removeActive(): boolean;
|
|
143
|
+
focusAdjacent(direction: CardinalDirection): boolean;
|
|
144
|
+
swapAdjacent(direction: CardinalDirection): boolean;
|
|
145
|
+
resizeActive(direction: CardinalDirection, deltaPx: number): boolean;
|
|
146
|
+
split(paneNodeId: NodeId, props: SplitPaneOptions): PaneId | null;
|
|
147
|
+
remove(paneId: PaneId): boolean;
|
|
148
|
+
swap(firstPaneId: PaneId, secondPaneId: PaneId): boolean;
|
|
149
|
+
resize(paneId: PaneId, props: ResizePaneOptions): boolean;
|
|
150
|
+
resizeHandle(splitId: NodeId, props: ResizeHandleOptions): boolean;
|
|
151
|
+
focus(paneId: PaneId): boolean;
|
|
152
|
+
updatePane(paneId: PaneId, patch: Partial<Omit<PaneNode, "kind" | "id" | "paneId">>): boolean;
|
|
153
|
+
setContainerSize(width: number, height: number): boolean;
|
|
154
|
+
private commit;
|
|
155
|
+
subscribePaneEvents({
|
|
156
|
+
onPaneLayoutChange,
|
|
157
|
+
onPaneClose
|
|
158
|
+
}: PaneEventHandlers): () => void;
|
|
159
|
+
subscribe(listener: Listener): () => void;
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region dom/src/controller.d.ts
|
|
163
|
+
/** Attach the renderer root; pane elements are resolved only when focus is requested. */
|
|
164
|
+
declare class FocusGridDomController implements DomController {
|
|
165
|
+
private root;
|
|
166
|
+
setRoot(root: HTMLElement | null): void;
|
|
167
|
+
focus(paneId: PaneId): boolean;
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region dom/src/mount.d.ts
|
|
171
|
+
/** Own native grid interactions; React only attaches and detaches the root. */
|
|
172
|
+
declare function mountFocusGrid(root: HTMLElement, controller: FocusGridController, dom: FocusGridDomController): () => void;
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region dom/src/interactivity.d.ts
|
|
175
|
+
declare function isEditableTarget(target: EventTarget | null): boolean;
|
|
176
|
+
//#endregion
|
|
177
|
+
export { FocusGridDomController, isEditableTarget, mountFocusGrid };
|