@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.
@@ -0,0 +1,315 @@
1
+ //#region dom/src/controller.ts
2
+ /** Attach the renderer root; pane elements are resolved only when focus is requested. */
3
+ var FocusGridDomController = class {
4
+ root = null;
5
+ setRoot(root) {
6
+ this.root = root;
7
+ }
8
+ focus(paneId) {
9
+ const root = this.root;
10
+ if (!root) return false;
11
+ const pane = [...root.querySelectorAll("[data-pane-id]")].find((element) => element.dataset.paneId === paneId && element.closest(".FocusgridFocusGrid") === root);
12
+ if (!pane) return false;
13
+ const ownsFocus = () => pane.contains(root.ownerDocument.activeElement);
14
+ if (ownsFocus()) return true;
15
+ pane.focus({ preventScroll: true });
16
+ return ownsFocus();
17
+ }
18
+ };
19
+ //#endregion
20
+ //#region dom/src/frame.ts
21
+ function requestFrame(callback) {
22
+ if (typeof globalThis.requestAnimationFrame === "function") return {
23
+ kind: "animation",
24
+ id: globalThis.requestAnimationFrame(() => callback())
25
+ };
26
+ return {
27
+ kind: "timeout",
28
+ id: globalThis.setTimeout(callback, 0)
29
+ };
30
+ }
31
+ function cancelFrame(frame) {
32
+ if (frame.kind === "animation") {
33
+ globalThis.cancelAnimationFrame(frame.id);
34
+ return;
35
+ }
36
+ globalThis.clearTimeout(frame.id);
37
+ }
38
+ //#endregion
39
+ //#region dom/src/pointer-resize.ts
40
+ var PointerResizeController = class {
41
+ controller;
42
+ state = { phase: "idle" };
43
+ onDocumentPointerMove = (event) => {
44
+ this.updateResize(event);
45
+ };
46
+ onDocumentPointerUp = (event) => {
47
+ this.endResize(event);
48
+ };
49
+ onDocumentPointerCancel = (event) => {
50
+ this.endResize(event);
51
+ };
52
+ constructor(controller) {
53
+ this.controller = controller;
54
+ }
55
+ startResize(event, handle, captureTarget) {
56
+ event.preventDefault();
57
+ this.destroy();
58
+ const ownerDocument = this.resolveOwnerDocument(event, captureTarget);
59
+ const pointerCaptureTarget = this.resolveCaptureTarget(captureTarget);
60
+ const startSizes = this.controller.getSplitSizes(handle.splitId);
61
+ if (!startSizes) return;
62
+ this.state = {
63
+ phase: "dragging",
64
+ pointerId: event.pointerId,
65
+ splitId: handle.splitId,
66
+ index: handle.index,
67
+ startX: event.clientX,
68
+ startY: event.clientY,
69
+ direction: handle.direction,
70
+ startSizes,
71
+ ownerDocument,
72
+ captureTarget: pointerCaptureTarget,
73
+ pendingDeltaPx: 0,
74
+ pendingFrame: null
75
+ };
76
+ this.setPointerCapture(pointerCaptureTarget, event.pointerId);
77
+ this.addDocumentListeners(ownerDocument);
78
+ }
79
+ updateResize(event) {
80
+ if (this.state.phase !== "dragging" || this.state.pointerId !== event.pointerId) return;
81
+ const deltaPx = this.state.direction === "horizontal" ? event.clientX - this.state.startX : event.clientY - this.state.startY;
82
+ if (this.state.pendingFrame) {
83
+ this.state = {
84
+ ...this.state,
85
+ pendingDeltaPx: deltaPx
86
+ };
87
+ return;
88
+ }
89
+ const pendingFrame = requestFrame(() => {
90
+ if (this.state.phase !== "dragging") return;
91
+ this.state = {
92
+ ...this.state,
93
+ pendingFrame: null
94
+ };
95
+ this.dispatchPendingResize();
96
+ });
97
+ this.state = {
98
+ ...this.state,
99
+ pendingDeltaPx: deltaPx,
100
+ pendingFrame
101
+ };
102
+ }
103
+ endResize(event) {
104
+ if (this.state.phase !== "dragging" || this.state.pointerId !== event.pointerId) return;
105
+ this.flushPendingFrame();
106
+ this.finishDrag();
107
+ }
108
+ destroy() {
109
+ this.cancelPendingFrame();
110
+ this.finishDrag();
111
+ }
112
+ finishDrag() {
113
+ if (this.state.phase !== "dragging") return;
114
+ const { captureTarget, ownerDocument, pointerId } = this.state;
115
+ this.removeDocumentListeners(ownerDocument);
116
+ this.releasePointerCapture(captureTarget, pointerId);
117
+ this.state = { phase: "idle" };
118
+ }
119
+ flushPendingFrame() {
120
+ if (this.state.phase !== "dragging" || !this.state.pendingFrame) return;
121
+ cancelFrame(this.state.pendingFrame);
122
+ this.state = {
123
+ ...this.state,
124
+ pendingFrame: null
125
+ };
126
+ this.dispatchPendingResize();
127
+ }
128
+ dispatchPendingResize() {
129
+ if (this.state.phase !== "dragging") return;
130
+ this.controller.resizeHandle(this.state.splitId, {
131
+ index: this.state.index,
132
+ deltaPx: this.state.pendingDeltaPx,
133
+ snapshotSizes: this.state.startSizes
134
+ });
135
+ }
136
+ cancelPendingFrame() {
137
+ if (this.state.phase !== "dragging" || !this.state.pendingFrame) return;
138
+ cancelFrame(this.state.pendingFrame);
139
+ this.state = {
140
+ ...this.state,
141
+ pendingFrame: null
142
+ };
143
+ }
144
+ addDocumentListeners(ownerDocument) {
145
+ ownerDocument?.addEventListener("pointermove", this.onDocumentPointerMove);
146
+ ownerDocument?.addEventListener("pointerup", this.onDocumentPointerUp);
147
+ ownerDocument?.addEventListener("pointercancel", this.onDocumentPointerCancel);
148
+ }
149
+ removeDocumentListeners(ownerDocument) {
150
+ ownerDocument?.removeEventListener("pointermove", this.onDocumentPointerMove);
151
+ ownerDocument?.removeEventListener("pointerup", this.onDocumentPointerUp);
152
+ ownerDocument?.removeEventListener("pointercancel", this.onDocumentPointerCancel);
153
+ }
154
+ resolveOwnerDocument(event, captureTarget) {
155
+ if (captureTarget?.ownerDocument) return captureTarget.ownerDocument;
156
+ if (typeof Element !== "undefined" && event.target instanceof Element && event.target.ownerDocument) return event.target.ownerDocument;
157
+ return typeof document === "undefined" ? null : document;
158
+ }
159
+ resolveCaptureTarget(captureTarget) {
160
+ if (!captureTarget) return null;
161
+ if ("setPointerCapture" in captureTarget || "releasePointerCapture" in captureTarget) return captureTarget;
162
+ return null;
163
+ }
164
+ setPointerCapture(target, pointerId) {
165
+ try {
166
+ target?.setPointerCapture?.(pointerId);
167
+ } catch {}
168
+ }
169
+ releasePointerCapture(target, pointerId) {
170
+ if (!target?.releasePointerCapture) return;
171
+ try {
172
+ if (!target.hasPointerCapture || target.hasPointerCapture(pointerId)) target.releasePointerCapture(pointerId);
173
+ } catch {}
174
+ }
175
+ };
176
+ //#endregion
177
+ //#region dom/src/resize-observer.ts
178
+ function observeRootSize(root, controller) {
179
+ const updateSize = ({ width, height }) => {
180
+ controller.setContainerSize(Math.floor(width), Math.floor(height));
181
+ };
182
+ if (typeof ResizeObserver === "undefined") {
183
+ updateSize(root.getBoundingClientRect());
184
+ return () => {};
185
+ }
186
+ const observer = new ResizeObserver(([entry]) => {
187
+ if (entry) updateSize(entry.contentRect);
188
+ });
189
+ observer.observe(root);
190
+ return () => observer.disconnect();
191
+ }
192
+ //#endregion
193
+ //#region dom/src/interactivity.ts
194
+ const INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
195
+ "alertdialog",
196
+ "button",
197
+ "checkbox",
198
+ "combobox",
199
+ "dialog",
200
+ "grid",
201
+ "gridcell",
202
+ "link",
203
+ "listbox",
204
+ "menu",
205
+ "menubar",
206
+ "menuitem",
207
+ "menuitemcheckbox",
208
+ "menuitemradio",
209
+ "option",
210
+ "radio",
211
+ "radiogroup",
212
+ "scrollbar",
213
+ "searchbox",
214
+ "slider",
215
+ "spinbutton",
216
+ "switch",
217
+ "tab",
218
+ "tablist",
219
+ "textbox",
220
+ "toolbar",
221
+ "tree",
222
+ "treegrid",
223
+ "treeitem"
224
+ ]);
225
+ function hasInteractiveOwner(target, boundary) {
226
+ let element = target;
227
+ while (element && element !== boundary) {
228
+ if (element instanceof HTMLElement && isInteractiveElement(element)) return true;
229
+ element = element.parentElement;
230
+ }
231
+ return boundary instanceof HTMLElement && isInteractiveElement(boundary);
232
+ }
233
+ function shouldFocusPaneShellForPointer(target, paneShell) {
234
+ return target === paneShell || !hasInteractiveOwner(target, paneShell);
235
+ }
236
+ function isInteractiveElement(element) {
237
+ if (isUnavailableElement(element)) return false;
238
+ const tagName = element.tagName.toLowerCase();
239
+ const role = element.getAttribute("role")?.toLowerCase();
240
+ 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);
241
+ }
242
+ function isUnavailableElement(element) {
243
+ let current = element;
244
+ while (current) {
245
+ 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;
246
+ const view = current.ownerDocument.defaultView;
247
+ if (view) {
248
+ const style = view.getComputedStyle(current);
249
+ if (style.display === "none" || style.visibility === "hidden") return true;
250
+ }
251
+ current = current.parentElement;
252
+ }
253
+ return false;
254
+ }
255
+ function isEditableTarget(target) {
256
+ if (!(target instanceof HTMLElement)) return false;
257
+ if (target.isContentEditable || target.getAttribute("role")?.toLowerCase() === "textbox") return true;
258
+ if (target.matches("textarea, select")) return !target.matches(":disabled");
259
+ return target instanceof HTMLInputElement && !target.disabled && !target.readOnly && [
260
+ "date",
261
+ "datetime-local",
262
+ "email",
263
+ "file",
264
+ "month",
265
+ "number",
266
+ "password",
267
+ "search",
268
+ "tel",
269
+ "text",
270
+ "time",
271
+ "url",
272
+ "week"
273
+ ].includes(target.type);
274
+ }
275
+ //#endregion
276
+ //#region dom/src/mount.ts
277
+ /** Own native grid interactions; React only attaches and detaches the root. */
278
+ function mountFocusGrid(root, controller, dom) {
279
+ dom.setRoot(root);
280
+ const resize = new PointerResizeController(controller);
281
+ const ownedTarget = (event) => {
282
+ const target = event.target;
283
+ return target instanceof Element && target.closest(".FocusgridFocusGrid") === root ? target : null;
284
+ };
285
+ const onFocus = (event) => {
286
+ const paneId = ownedTarget(event)?.closest("[data-pane-id]")?.dataset.paneId;
287
+ if (paneId) controller.focus(paneId);
288
+ };
289
+ const onPointerDown = (event) => {
290
+ const target = ownedTarget(event);
291
+ if (!target) return;
292
+ const separator = target.closest("[data-resize-handle]");
293
+ if (separator) {
294
+ const handle = controller.getLayout().handles.find((handle) => handle.id === separator.dataset.resizeHandle);
295
+ if (handle) resize.startResize(event, handle, separator);
296
+ return;
297
+ }
298
+ const pane = target.closest("[data-pane-id]");
299
+ if (!pane?.dataset.paneId) return;
300
+ controller.focus(pane.dataset.paneId);
301
+ if (shouldFocusPaneShellForPointer(target, pane)) pane.focus({ preventScroll: true });
302
+ };
303
+ root.addEventListener("focusin", onFocus, true);
304
+ root.addEventListener("pointerdown", onPointerDown);
305
+ const stopObserving = observeRootSize(root, controller);
306
+ return () => {
307
+ root.removeEventListener("focusin", onFocus, true);
308
+ root.removeEventListener("pointerdown", onPointerDown);
309
+ resize.destroy();
310
+ stopObserving();
311
+ dom.setRoot(null);
312
+ };
313
+ }
314
+ //#endregion
315
+ export { FocusGridDomController, isEditableTarget, mountFocusGrid };