@hyperframes/studio 0.8.51 → 0.8.53-desktop.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.
Files changed (160) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
  2. package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +808 -12
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8109 -7583
  11. package/dist/index.js.map +1 -1
  12. package/package.json +9 -7
  13. package/src/App.tsx +45 -50
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  17. package/src/components/EditorShell.tsx +56 -56
  18. package/src/components/RightPanelTabs.test.tsx +151 -0
  19. package/src/components/RightPanelTabs.tsx +59 -0
  20. package/src/components/StudioHeader.dom.test.tsx +181 -0
  21. package/src/components/StudioHeader.test.ts +0 -12
  22. package/src/components/StudioHeader.tsx +117 -160
  23. package/src/components/StudioLeftPanels.tsx +202 -0
  24. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  25. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  26. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  27. package/src/components/TimelineHistoryButtons.tsx +54 -0
  28. package/src/components/TimelineToolbar.test.tsx +8 -0
  29. package/src/components/TimelineToolbar.tsx +3 -7
  30. package/src/components/dock/Dock.test.tsx +100 -18
  31. package/src/components/dock/Dock.tsx +32 -5
  32. package/src/components/dock/dock.css +5 -0
  33. package/src/components/dock/dockAccessibility.test.ts +166 -0
  34. package/src/components/dock/dockAccessibility.ts +98 -0
  35. package/src/components/dock/dockLayout.test.ts +134 -0
  36. package/src/components/dock/dockLayout.ts +53 -6
  37. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  38. package/src/components/dock/dockLayoutStore.ts +21 -4
  39. package/src/components/dock/dockTestHarness.ts +11 -0
  40. package/src/components/editor/LayersPanel.test.ts +97 -1
  41. package/src/components/editor/LayersPanel.tsx +4 -2
  42. package/src/components/editor/PreviewGuides.tsx +1 -1
  43. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  44. package/src/components/editor/TopologyLens.test.tsx +28 -0
  45. package/src/components/editor/TopologyLens.tsx +3 -1
  46. package/src/components/editor/useColorGradingController.test.ts +38 -0
  47. package/src/components/editor/useColorGradingController.ts +9 -1
  48. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  49. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  50. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  51. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  52. package/src/components/nle/NLEContext.tsx +0 -43
  53. package/src/components/nle/NLEPreview.tsx +1 -1
  54. package/src/components/nle/PreviewPane.tsx +1 -1
  55. package/src/components/nle/TimelinePane.tsx +53 -67
  56. package/src/components/panels/SlideshowPanel.tsx +1 -1
  57. package/src/components/panels/VariablesPanel.tsx +1 -1
  58. package/src/components/pointerTargetSize.test.tsx +8 -0
  59. package/src/components/renders/RenderQueue.tsx +1 -1
  60. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  61. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  62. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  63. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  64. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  65. package/src/components/timelineToolbarStyles.ts +5 -0
  66. package/src/components/ui/Button.test.tsx +222 -30
  67. package/src/components/ui/Button.tsx +88 -125
  68. package/src/components/ui/IconButton.tsx +38 -0
  69. package/src/components/ui/Input.tsx +115 -0
  70. package/src/components/ui/Menu.test.tsx +374 -0
  71. package/src/components/ui/Menu.tsx +213 -0
  72. package/src/components/ui/NumberField.tsx +122 -0
  73. package/src/components/ui/Popover.tsx +74 -0
  74. package/src/components/ui/Select.tsx +100 -0
  75. package/src/components/ui/Slider.tsx +153 -0
  76. package/src/components/ui/Tabs.test.tsx +98 -0
  77. package/src/components/ui/Tabs.tsx +71 -0
  78. package/src/components/ui/Toggle.tsx +63 -0
  79. package/src/components/ui/Tooltip.test.tsx +110 -0
  80. package/src/components/ui/Tooltip.tsx +38 -112
  81. package/src/components/ui/cn.test.ts +30 -0
  82. package/src/components/ui/cn.ts +25 -0
  83. package/src/components/ui/index.ts +29 -2
  84. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  85. package/src/components/ui/valueControls.test.tsx +524 -0
  86. package/src/contexts/PanelLayoutContext.tsx +3 -57
  87. package/src/hooks/appHotkeysDispatch.ts +3 -4
  88. package/src/hooks/timelineEditPermission.ts +31 -0
  89. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  90. package/src/hooks/useAppHotkeys.test.ts +0 -1
  91. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  92. package/src/hooks/useAppHotkeys.ts +20 -83
  93. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  94. package/src/hooks/useCaptionDetection.ts +3 -1
  95. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  96. package/src/hooks/useDomEditSession.ts +0 -7
  97. package/src/hooks/useDomEditWiring.ts +4 -9
  98. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  99. package/src/hooks/useEditHistoryActions.ts +106 -0
  100. package/src/hooks/useFileManager.ts +21 -123
  101. package/src/hooks/usePanelLayout.test.ts +108 -296
  102. package/src/hooks/usePanelLayout.ts +50 -255
  103. package/src/hooks/usePersistentEditHistory.ts +1 -1
  104. package/src/hooks/useProjectFileWriter.ts +150 -0
  105. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  106. package/src/hooks/useRightPanelIntents.ts +63 -0
  107. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  108. package/src/hooks/useServerConnection.ts +43 -10
  109. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  110. package/src/hooks/useSlideshowTabState.ts +6 -19
  111. package/src/hooks/useStudioContextValue.test.ts +6 -18
  112. package/src/hooks/useStudioContextValue.ts +5 -26
  113. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  114. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  115. package/src/hooks/useTimelineEditing.ts +103 -16
  116. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  117. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  118. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  119. package/src/hostMount.test.tsx +222 -0
  120. package/src/index.ts +87 -0
  121. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  122. package/src/player/components/Timeline.tsx +1 -1
  123. package/src/player/components/TimelineCanvas.tsx +4 -4
  124. package/src/player/components/TimelineClip.tsx +0 -1
  125. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  126. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  127. package/src/player/components/timelineTheme.test.ts +4 -4
  128. package/src/player/components/timelineTheme.ts +30 -30
  129. package/src/player/lib/playbackShortcuts.ts +2 -0
  130. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  131. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  132. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  133. package/src/player/store/previewIframeStore.test.tsx +29 -0
  134. package/src/player/store/previewIframeStore.ts +25 -0
  135. package/src/renderQueueExports.test.tsx +58 -0
  136. package/src/styles/classCandidates.test.ts +105 -0
  137. package/src/styles/classCandidates.ts +281 -0
  138. package/src/styles/hex-baseline.json +84 -0
  139. package/src/styles/hexRatchet.test.ts +201 -0
  140. package/src/styles/styleSources.test.ts +20 -0
  141. package/src/styles/styleSources.ts +47 -0
  142. package/src/styles/theme.css +57 -0
  143. package/src/styles/theme.test.ts +2 -15
  144. package/src/styles/tokenGate.test.ts +109 -0
  145. package/src/utils/sourcePatcher.ts +1 -1
  146. package/src/utils/studioHelpers.test.ts +18 -0
  147. package/src/utils/studioHelpers.ts +11 -8
  148. package/src/utils/studioUiPreferences.test.ts +10 -16
  149. package/src/utils/studioUiPreferences.ts +0 -16
  150. package/src/utils/typingTarget.ts +4 -0
  151. package/dist/assets/index-C05SNrvY.js +0 -360
  152. package/dist/assets/index-DUR9qzQM.css +0 -1
  153. package/src/components/PanelTabButton.tsx +0 -31
  154. package/src/components/StudioLeftSidebar.tsx +0 -207
  155. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  156. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  157. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  158. package/src/hooks/useInspectorSplitResize.ts +0 -51
  159. package/src/utils/fitPanels.test.ts +0 -164
  160. package/src/utils/fitPanels.ts +0 -151
@@ -1,8 +1,10 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import React, { act } from "react";
3
+ import React, { act, type ComponentProps } from "react";
4
+ import type { DockviewApi } from "dockview-react";
4
5
  import { createRoot, type Root } from "react-dom/client";
5
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import * as dockLayout from "./dockLayout";
6
8
  import { Dock } from "./Dock";
7
9
  import { parseDockLayout } from "./dockLayoutSchema";
8
10
  import { useDockLayoutStore } from "./dockLayoutStore";
@@ -11,16 +13,51 @@ import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
11
13
 
12
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
15
 
16
+ let dockApi: DockviewApi | null = null;
17
+ vi.mock("dockview-react", async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import("dockview-react")>();
19
+ return {
20
+ ...actual,
21
+ DockviewReact: (props: ComponentProps<typeof actual.DockviewReact>) =>
22
+ React.createElement(actual.DockviewReact, {
23
+ ...props,
24
+ onReady: (event) => {
25
+ dockApi = event.api;
26
+ props.onReady(event);
27
+ },
28
+ }),
29
+ };
30
+ });
31
+
32
+ const liveObservers = new Set<{ callback: () => void; target?: Element }>();
14
33
  class ResizeObserverStub {
15
- observe() {}
34
+ private readonly entry: { callback: () => void; target?: Element };
35
+ constructor(callback: () => void) {
36
+ this.entry = { callback };
37
+ }
38
+ observe(target: Element) {
39
+ this.entry.target = target;
40
+ liveObservers.add(this.entry);
41
+ }
16
42
  unobserve() {}
17
- disconnect() {}
43
+ disconnect() {
44
+ liveObservers.delete(this.entry);
45
+ }
18
46
  }
19
47
  (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
20
48
 
49
+ vi.mock("./dockLayout", async (importOriginal) => {
50
+ const actual = await importOriginal<typeof import("./dockLayout")>();
51
+ return { ...actual, applySideMinimums: vi.fn(actual.applySideMinimums) };
52
+ });
53
+ const applySideMinimums = vi.mocked(dockLayout.applySideMinimums);
54
+
21
55
  let root: Root | null = null;
22
56
 
23
- function mount(projectId: string | null) {
57
+ function mount(
58
+ projectId: string | null,
59
+ titles: Partial<Record<(typeof PANEL_IDS)[number], string>> = {},
60
+ ) {
24
61
  const host = document.createElement("div");
25
62
  document.body.append(host);
26
63
  root = createRoot(host);
@@ -28,7 +65,7 @@ function mount(projectId: string | null) {
28
65
  root?.render(
29
66
  <Dock.Root projectId={projectId}>
30
67
  {PANEL_IDS.map((id) => (
31
- <Dock.Panel key={id} id={id}>
68
+ <Dock.Panel key={id} id={id} title={titles[id]}>
32
69
  <div data-testid={`content-${id}`}>{id}</div>
33
70
  </Dock.Panel>
34
71
  ))}
@@ -38,6 +75,16 @@ function mount(projectId: string | null) {
38
75
  return host;
39
76
  }
40
77
 
78
+ /** The persisted views of the group holding `id`, after the debounced write lands. */
79
+ function persistedGroupOf(id: string): string | undefined {
80
+ act(() => {
81
+ vi.advanceTimersByTime(1000);
82
+ });
83
+ const grid = JSON.stringify(readStudioUiPreferences(undefined, "p1").dockLayout?.grid);
84
+ const groups = [...grid.matchAll(/"views":\[([^\]]*)\]/g)].map((m) => m[1]);
85
+ return groups.find((views) => views.includes(`"${id}"`));
86
+ }
87
+
41
88
  beforeEach(() => {
42
89
  localStorage.clear();
43
90
  vi.useFakeTimers();
@@ -98,17 +145,22 @@ describe("Dock on React 19", () => {
98
145
  expect(host.querySelector('[data-testid="content-renders"]')).not.toBeNull();
99
146
  });
100
147
 
148
+ it("keeps a panel's custom title when it is closed and reopened", async () => {
149
+ const host = mount("p1", { renders: "Renders (2)" });
150
+ expect(host.textContent).toContain("Renders (2)");
151
+ act(() => useDockLayoutStore.getState().closePanel("renders"));
152
+ await act(async () => {
153
+ useDockLayoutStore.getState().togglePanel("renders");
154
+ await Promise.resolve();
155
+ });
156
+ expect(host.textContent).toContain("Renders (2)");
157
+ });
158
+
101
159
  it("reopens a closed panel as a tab of its zone's group, not a new group", () => {
102
160
  mount("p1");
103
161
  act(() => useDockLayoutStore.getState().closePanel("compositions"));
104
162
  act(() => useDockLayoutStore.getState().togglePanel("compositions"));
105
- act(() => {
106
- vi.advanceTimersByTime(1000);
107
- });
108
- const grid = JSON.stringify(readStudioUiPreferences(undefined, "p1").dockLayout?.grid);
109
- const groups = [...grid.matchAll(/"views":\[([^\]]*)\]/g)].map((m) => m[1]);
110
- const home = groups.find((views) => views.includes('"compositions"'));
111
- expect(home).toContain('"assets"');
163
+ expect(persistedGroupOf("compositions")).toContain('"assets"');
112
164
  });
113
165
 
114
166
  it("reopens a side panel next to the preview when its whole column was closed", () => {
@@ -125,12 +177,7 @@ describe("Dock on React 19", () => {
125
177
  mount("p1");
126
178
  act(() => useDockLayoutStore.getState().closePanel("timeline"));
127
179
  act(() => useDockLayoutStore.getState().togglePanel("timeline"));
128
- act(() => {
129
- vi.advanceTimersByTime(1000);
130
- });
131
- const grid = JSON.stringify(readStudioUiPreferences(undefined, "p1").dockLayout?.grid);
132
- const groups = [...grid.matchAll(/"views":\[([^\]]*)\]/g)].map((m) => m[1]);
133
- expect(groups.find((views) => views.includes('"timeline"'))).not.toContain('"preview"');
180
+ expect(persistedGroupOf("timeline")).not.toContain('"preview"');
134
181
  });
135
182
 
136
183
  it("restores the stored layout on the next mount instead of rebuilding the default", () => {
@@ -157,6 +204,41 @@ describe("Dock on React 19", () => {
157
204
  });
158
205
  });
159
206
 
207
+ describe("Dock wiring", () => {
208
+ it("re-applies the side minimums when a panel is dragged to another group", () => {
209
+ mount(null);
210
+ applySideMinimums.mockClear();
211
+ const preview = dockApi?.getPanel("preview");
212
+ const design = dockApi?.getPanel("design");
213
+ if (!preview || !design) throw new Error("default layout is missing panels");
214
+ act(() => design.api.moveTo({ group: preview.group }));
215
+ expect(applySideMinimums).toHaveBeenCalled();
216
+ });
217
+
218
+ it("makes the sashes keyboard-focusable separators", () => {
219
+ const host = mount(null);
220
+ const sashes = host.querySelectorAll<HTMLElement>('.dv-sash[role="separator"]');
221
+ expect(sashes.length).toBeGreaterThan(0);
222
+ for (const sash of sashes) expect(sash.tabIndex).toBe(0);
223
+ });
224
+
225
+ it("re-fits the side columns to the window width when it resizes", () => {
226
+ mount(null);
227
+ const innerWidth = window.innerWidth;
228
+ Object.defineProperty(window, "innerWidth", { value: 560, configurable: true });
229
+ try {
230
+ applySideMinimums.mockClear();
231
+ const dockObservers = [...liveObservers].filter(({ target }) =>
232
+ target?.classList.contains("hf-dock"),
233
+ );
234
+ act(() => dockObservers.forEach(({ callback }) => callback()));
235
+ expect(applySideMinimums).toHaveBeenCalledWith(expect.anything(), 560);
236
+ } finally {
237
+ Object.defineProperty(window, "innerWidth", { value: innerWidth, configurable: true });
238
+ }
239
+ });
240
+ });
241
+
160
242
  describe("parseDockLayout", () => {
161
243
  it("rejects shapes that are not a dock layout", () => {
162
244
  expect(parseDockLayout(null)).toBeNull();
@@ -15,7 +15,8 @@ import {
15
15
  type IDockviewPanelProps,
16
16
  } from "dockview-react";
17
17
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
18
- import { addRegisteredPanel, buildEditLayout } from "./dockLayout";
18
+ import { installDockAccessibility } from "./dockAccessibility";
19
+ import { addRegisteredPanel, applySideMinimums, buildEditLayout } from "./dockLayout";
19
20
  import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
20
21
  import { useDockLayoutStore, type DockController, type DockSnapshot } from "./dockLayoutStore";
21
22
  import {
@@ -68,8 +69,17 @@ function snapshot(api: DockviewApi): DockSnapshot {
68
69
  openPanels.add(panel.id);
69
70
  if (panel.api.isVisible && panel.group.api.isVisible) visiblePanels.add(panel.id);
70
71
  }
72
+ const groupActivePanels = api.groups.flatMap((group) => {
73
+ const id = group.activePanel?.id;
74
+ return isPanelId(id) ? [id] : [];
75
+ });
71
76
  const active = api.activePanel?.id;
72
- return { openPanels, visiblePanels, activePanel: isPanelId(active) ? active : null };
77
+ return {
78
+ openPanels,
79
+ visiblePanels,
80
+ activePanel: isPanelId(active) ? active : null,
81
+ groupActivePanels,
82
+ };
73
83
  }
74
84
 
75
85
  function createController(api: DockviewApi): DockController {
@@ -135,7 +145,14 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
135
145
 
136
146
  const onReady = useCallback(
137
147
  ({ api }: DockviewReadyEvent) => {
148
+ disposeRef.current();
138
149
  restoreOrBuild(api, projectId);
150
+ applySideMinimums(api);
151
+ const root = api.groups[0]?.element.closest<HTMLElement>(".hf-dock");
152
+ const disposeAccessibility = root ? installDockAccessibility(api, root) : () => {};
153
+ // The dock spans the window (buildEditLayout sizes against it too); its own box lags a resize.
154
+ const resizeObserver = new ResizeObserver(() => applySideMinimums(api, window.innerWidth));
155
+ if (root) resizeObserver.observe(root);
139
156
  const store = useDockLayoutStore.getState();
140
157
  store.attach(createController(api));
141
158
  store.sync(snapshot(api));
@@ -156,7 +173,14 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
156
173
  persist();
157
174
  };
158
175
  const subscriptions = [
159
- api.onDidAddPanel(onDockChange),
176
+ api.onDidAddPanel(() => {
177
+ applySideMinimums(api);
178
+ onDockChange();
179
+ }),
180
+ api.onDidMovePanel(() => {
181
+ applySideMinimums(api);
182
+ onDockChange();
183
+ }),
160
184
  api.onDidRemovePanel(onDockChange),
161
185
  api.onDidActivePanelChange(onDockChange),
162
186
  api.onDidLayoutChange(onDockChange),
@@ -164,6 +188,8 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
164
188
  disposeRef.current = () => {
165
189
  clearTimeout(timer);
166
190
  for (const subscription of subscriptions) subscription.dispose();
191
+ disposeAccessibility();
192
+ resizeObserver.disconnect();
167
193
  useDockLayoutStore.getState().detach();
168
194
  };
169
195
  },
@@ -189,10 +215,11 @@ function Panel({ id, title, children }: { id: PanelId; title?: string; children:
189
215
  const element = useSlots().slots[id];
190
216
  const visible = useDockLayoutStore((state) => state.visiblePanels.has(id));
191
217
  const controller = useDockLayoutStore((state) => state.controller);
218
+ const open = useDockLayoutStore((state) => state.openPanels.has(id));
192
219
  const label = title ?? PANEL_DEFINITIONS[id].title;
193
220
  useEffect(() => {
194
- controller?.setTitle(id, label);
195
- }, [controller, id, label]);
221
+ if (open) controller?.setTitle(id, label);
222
+ }, [controller, id, label, open]);
196
223
  const definition: PanelDefinition = PANEL_DEFINITIONS[id];
197
224
  const shown = visible || definition.keepMounted;
198
225
  return element && shown ? createPortal(children, element) : null;
@@ -21,3 +21,8 @@
21
21
  --dv-tab-font-size: 11px;
22
22
  --dv-tabs-and-actions-container-font-size: 11px;
23
23
  }
24
+
25
+ .hf-dock .dv-sash:focus-visible {
26
+ background-color: var(--dv-active-sash-color);
27
+ outline: none;
28
+ }
@@ -0,0 +1,166 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import type { DockviewApi } from "dockview-react";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { mountBareDockview } from "./dockTestHarness";
6
+ import { installDockAccessibility } from "./dockAccessibility";
7
+ import { applySideMinimums, buildEditLayout } from "./dockLayout";
8
+
9
+ class ResizeObserverStub {
10
+ observe() {}
11
+ unobserve() {}
12
+ disconnect() {}
13
+ }
14
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
15
+
16
+ let host: HTMLElement;
17
+ let api: DockviewApi;
18
+ let dispose: () => void;
19
+
20
+ beforeEach(() => {
21
+ ({ host, api } = mountBareDockview());
22
+ api.layout(1200, 700);
23
+ buildEditLayout(api, 1200);
24
+ applySideMinimums(api, 1200);
25
+ dispose = installDockAccessibility(api, host);
26
+ });
27
+
28
+ afterEach(() => {
29
+ dispose();
30
+ api.dispose();
31
+ document.body.innerHTML = "";
32
+ });
33
+
34
+ const sashes = () => [...host.querySelectorAll<HTMLElement>(".dv-sash")];
35
+ const columnSash = (index: number) =>
36
+ sashes().filter((sash) => sash.getAttribute("aria-orientation") === "vertical")[
37
+ index
38
+ ] as HTMLElement;
39
+ const widthOf = (panel: string) => api.getPanel(panel)?.group.width;
40
+ const press = (target: Element, key: string, init: KeyboardEventInit = {}) =>
41
+ target.dispatchEvent(
42
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }),
43
+ );
44
+
45
+ describe("dock sashes", () => {
46
+ it("are focusable separators with an orientation and a label", () => {
47
+ expect(sashes()).toHaveLength(3);
48
+ for (const sash of sashes()) {
49
+ expect(sash.getAttribute("role")).toBe("separator");
50
+ expect(sash.tabIndex).toBe(0);
51
+ const label = sash.getAttribute("aria-label");
52
+ const orientation = sash.getAttribute("aria-orientation");
53
+ expect(label).toBe(orientation === "vertical" ? "Resize columns" : "Resize rows");
54
+ }
55
+ expect(
56
+ sashes()
57
+ .map((sash) => sash.getAttribute("aria-orientation"))
58
+ .sort(),
59
+ ).toEqual(["horizontal", "vertical", "vertical"]);
60
+ });
61
+
62
+ it("are decorated again after a layout restore rebuilds them", () => {
63
+ api.fromJSON(api.toJSON());
64
+ expect(sashes()).toHaveLength(3);
65
+ for (const sash of sashes()) expect(sash.getAttribute("role")).toBe("separator");
66
+ });
67
+
68
+ it("resize the neighbouring groups by 16px per arrow press, 64px with Shift", () => {
69
+ const left = widthOf("compositions") as number;
70
+ const preview = widthOf("preview") as number;
71
+ const sash = columnSash(0);
72
+ press(sash, "ArrowRight");
73
+ expect(widthOf("compositions")).toBe(left + 16);
74
+ expect(widthOf("preview")).toBe(preview - 16);
75
+ press(sash, "ArrowRight", { shiftKey: true });
76
+ expect(widthOf("compositions")).toBe(left + 80);
77
+ press(sash, "ArrowLeft");
78
+ expect(widthOf("compositions")).toBe(left + 64);
79
+ });
80
+
81
+ it("stop at the panels' minimum sizes", () => {
82
+ const sash = columnSash(0);
83
+ for (let i = 0; i < 20; i++) press(sash, "ArrowLeft", { shiftKey: true });
84
+ expect(widthOf("compositions")).toBe(200);
85
+ for (let i = 0; i < 40; i++) press(sash, "ArrowRight", { shiftKey: true });
86
+ expect(widthOf("preview")).toBe(360);
87
+ });
88
+
89
+ it("ignore the arrow keys of the other axis and keys pressed anywhere else", () => {
90
+ const left = widthOf("compositions");
91
+ press(columnSash(0), "ArrowDown");
92
+ press(host, "ArrowRight");
93
+ press(document.body, "ArrowRight");
94
+ expect(widthOf("compositions")).toBe(left);
95
+ });
96
+
97
+ it("resize rows with the vertical arrows", () => {
98
+ const timeline = api.getPanel("timeline")?.group.height as number;
99
+ const rows = sashes().find((sash) => sash.getAttribute("aria-orientation") === "horizontal");
100
+ press(rows as HTMLElement, "ArrowUp");
101
+ expect(api.getPanel("timeline")?.group.height).toBe(timeline + 16);
102
+ });
103
+ });
104
+
105
+ describe("dock tab strips", () => {
106
+ const tabsOf = (panel: string) => [
107
+ ...(api.getPanel(panel)?.group.element.querySelectorAll<HTMLElement>('[role="tab"]') ?? []),
108
+ ];
109
+
110
+ it("keep dockview's tablist, tab and selected roles with one tab stop", () => {
111
+ const tabs = tabsOf("compositions");
112
+ expect(tabs).toHaveLength(4);
113
+ expect(tabs[0]?.closest('[role="tablist"]')).not.toBeNull();
114
+ expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual([
115
+ "true",
116
+ "false",
117
+ "false",
118
+ "false",
119
+ ]);
120
+ expect(tabs.map((tab) => tab.tabIndex)).toEqual([0, -1, -1, -1]);
121
+ });
122
+
123
+ it("leave browser shortcuts alone when Alt, Ctrl or Meta is held", () => {
124
+ const sash = columnSash(0);
125
+ const left = widthOf("compositions");
126
+ const tab = tabsOf("compositions")[0] as HTMLElement;
127
+ const active = () => api.getPanel("compositions")?.group.activePanel?.id;
128
+ for (const init of [{ altKey: true }, { ctrlKey: true }, { metaKey: true }]) {
129
+ const onSash = new KeyboardEvent("keydown", {
130
+ key: "ArrowRight",
131
+ cancelable: true,
132
+ bubbles: true,
133
+ ...init,
134
+ });
135
+ sash.dispatchEvent(onSash);
136
+ press(tab, "ArrowRight", init);
137
+ expect(onSash.defaultPrevented).toBe(false);
138
+ expect(active()).toBe("compositions");
139
+ }
140
+ expect(widthOf("compositions")).toBe(left);
141
+ });
142
+
143
+ it("move focus and activate the neighbouring tab, wrapping at both ends", () => {
144
+ const tabs = tabsOf("compositions");
145
+ const active = () => api.getPanel("compositions")?.group.activePanel?.id;
146
+ (tabs[0] as HTMLElement).focus();
147
+ press(tabs[0] as HTMLElement, "ArrowRight");
148
+ expect(active()).toBe("assets");
149
+ expect(document.activeElement).toBe(tabs[1]);
150
+ press(tabs[1] as HTMLElement, "ArrowLeft");
151
+ press(tabs[0] as HTMLElement, "ArrowLeft");
152
+ expect(active()).toBe("catalog");
153
+ expect(document.activeElement).toBe(tabs[3]);
154
+ press(tabs[3] as HTMLElement, "ArrowRight");
155
+ expect(active()).toBe("compositions");
156
+ });
157
+
158
+ it("jump to the first and last tab with Home and End", () => {
159
+ const tabs = tabsOf("design");
160
+ press(tabs[0] as HTMLElement, "End");
161
+ expect(api.getPanel("design")?.group.activePanel?.id).toBe("variables");
162
+ expect(document.activeElement).toBe(tabs[3]);
163
+ press(tabs[3] as HTMLElement, "Home");
164
+ expect(api.getPanel("design")?.group.activePanel?.id).toBe("design");
165
+ });
166
+ });
@@ -0,0 +1,98 @@
1
+ import type { DockviewApi } from "dockview-react";
2
+
3
+ const SASH_STEP = 16;
4
+ const SASH_STEP_SHIFT = 64;
5
+
6
+ type Axis = "horizontal" | "vertical";
7
+
8
+ /** A horizontal split lays views left to right, so its sashes are vertical bars. */
9
+ function splitAxis(sash: HTMLElement): Axis {
10
+ return sash.closest(".dv-split-view-container")?.classList.contains("dv-horizontal")
11
+ ? "horizontal"
12
+ : "vertical";
13
+ }
14
+
15
+ /** Dockview draws sashes as bare divs; make each one a focusable separator. */
16
+ function decorateSashes(root: HTMLElement) {
17
+ for (const sash of root.querySelectorAll<HTMLElement>(".dv-sash")) {
18
+ const axis = splitAxis(sash);
19
+ sash.tabIndex = 0;
20
+ sash.setAttribute("role", "separator");
21
+ sash.setAttribute("aria-orientation", axis === "horizontal" ? "vertical" : "horizontal");
22
+ sash.setAttribute("aria-label", axis === "horizontal" ? "Resize columns" : "Resize rows");
23
+ }
24
+ }
25
+
26
+ /** Replays a pointer drag on the sash so dockview's own clamping to minimum sizes applies. */
27
+ function nudgeSash(sash: HTMLElement, axis: Axis, delta: number) {
28
+ const at = (value: number) =>
29
+ axis === "horizontal" ? { clientX: value, clientY: 0 } : { clientX: 0, clientY: value };
30
+ const doc = sash.ownerDocument;
31
+ const fire = (target: EventTarget, type: string, value: number) =>
32
+ target.dispatchEvent(new MouseEvent(type, { ...at(value), bubbles: true, cancelable: true }));
33
+ fire(sash, "pointerdown", 0);
34
+ fire(doc, "pointermove", delta);
35
+ fire(doc, "pointerup", delta);
36
+ }
37
+
38
+ /** Browser and OS shortcuts (Alt+Left is Back) keep their meaning. */
39
+ function hasShortcutModifier(event: KeyboardEvent) {
40
+ return event.altKey || event.ctrlKey || event.metaKey;
41
+ }
42
+
43
+ function onSashKeyDown(event: KeyboardEvent, sash: HTMLElement) {
44
+ if (hasShortcutModifier(event)) return;
45
+ const axis = splitAxis(sash);
46
+ const [less, more] =
47
+ axis === "horizontal" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
48
+ if (event.key !== less && event.key !== more) return;
49
+ event.preventDefault();
50
+ const step = event.shiftKey ? SASH_STEP_SHIFT : SASH_STEP;
51
+ nudgeSash(sash, axis, event.key === more ? step : -step);
52
+ }
53
+
54
+ /** Wraps around and activates the tab it lands on, unlike dockview's focus-only arrows. */
55
+ function onTabKeyDown(event: KeyboardEvent, tab: HTMLElement, api: DockviewApi) {
56
+ if (hasShortcutModifier(event)) return;
57
+ const tabs = [
58
+ ...(tab.closest('[role="tablist"]')?.querySelectorAll<HTMLElement>('[role="tab"]') ?? []),
59
+ ];
60
+ const index = tabs.indexOf(tab);
61
+ const last = tabs.length - 1;
62
+ const targets: Record<string, number> = {
63
+ ArrowRight: index === last ? 0 : index + 1,
64
+ ArrowLeft: index === 0 ? last : index - 1,
65
+ Home: 0,
66
+ End: last,
67
+ };
68
+ const target = tabs[targets[event.key] ?? -1];
69
+ if (!target) return;
70
+ event.preventDefault();
71
+ event.stopPropagation();
72
+ const panelId = target.dataset.tabPanelId;
73
+ if (panelId) api.getPanel(panelId)?.api.setActive();
74
+ target.focus();
75
+ }
76
+
77
+ /** Keyboard and ARIA support dockview 8.3 lacks for sashes and tab arrows. Returns a disposer. */
78
+ export function installDockAccessibility(api: DockviewApi, root: HTMLElement): () => void {
79
+ const decorate = () => decorateSashes(root);
80
+ decorate();
81
+ const subscriptions = [
82
+ api.onDidLayoutChange(decorate),
83
+ api.onDidLayoutFromJSON(decorate),
84
+ api.onDidAddPanel(decorate),
85
+ api.onDidRemovePanel(decorate),
86
+ ];
87
+ // Capture phase so the tab handler runs before dockview's own focus-only arrows.
88
+ const onKeyDown = (event: KeyboardEvent) => {
89
+ if (!(event.target instanceof HTMLElement)) return;
90
+ if (event.target.classList.contains("dv-sash")) onSashKeyDown(event, event.target);
91
+ else if (event.target.getAttribute("role") === "tab") onTabKeyDown(event, event.target, api);
92
+ };
93
+ root.addEventListener("keydown", onKeyDown, true);
94
+ return () => {
95
+ root.removeEventListener("keydown", onKeyDown, true);
96
+ for (const subscription of subscriptions) subscription.dispose();
97
+ };
98
+ }
@@ -0,0 +1,134 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import type { DockviewApi } from "dockview-react";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ applySideMinimums,
7
+ buildEditLayout,
8
+ defaultSideWidths,
9
+ sideMinimumWidth,
10
+ } from "./dockLayout";
11
+ import { mountBareDockview } from "./dockTestHarness";
12
+
13
+ class ResizeObserverStub {
14
+ observe() {}
15
+ unobserve() {}
16
+ disconnect() {}
17
+ }
18
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
19
+
20
+ // Expected values are hand-derived from the old fitPanels rule: the preview keeps 360 first,
21
+ // then each side column takes half of what remains, never above 200 or below 120.
22
+ describe("sideMinimumWidth", () => {
23
+ it.each([
24
+ [1680, 200],
25
+ [1200, 200],
26
+ [860, 200],
27
+ [760, 200],
28
+ [700, 170],
29
+ [600, 120],
30
+ [560, 120],
31
+ [400, 120],
32
+ ])("is %ipx wide -> sides shrink to %ipx at most", (viewport, expected) => {
33
+ expect(sideMinimumWidth(viewport)).toBe(expected);
34
+ });
35
+ });
36
+
37
+ describe("defaultSideWidths", () => {
38
+ it.each([
39
+ [1680, 384, 424],
40
+ [1280, 329, 364],
41
+ [860, 221, 244],
42
+ [560, 120, 120],
43
+ ])("at %ipx gives %i / %i", (viewport, left, right) => {
44
+ expect(defaultSideWidths(viewport)).toEqual({ left, right });
45
+ });
46
+
47
+ it.each([1680, 1280, 1000, 860, 760, 700, 600, 560, 480])(
48
+ "leaves the preview its 360px floor at %ipx whenever the sides can shrink enough",
49
+ (viewport) => {
50
+ const { left, right } = defaultSideWidths(viewport);
51
+ const floor = sideMinimumWidth(viewport);
52
+ const overflow = left + right + 360 - viewport;
53
+ expect(overflow <= 0 || (left === floor && right === floor)).toBe(true);
54
+ },
55
+ );
56
+ });
57
+
58
+ describe("applySideMinimums", () => {
59
+ let api: DockviewApi;
60
+ beforeEach(() => {
61
+ ({ api } = mountBareDockview());
62
+ });
63
+ afterEach(() => {
64
+ api.dispose();
65
+ document.body.innerHTML = "";
66
+ });
67
+
68
+ it("lowers side groups' minimum on a narrow window and shrinks them, never touching the preview", () => {
69
+ api.layout(1200, 700);
70
+ buildEditLayout(api, 1200);
71
+ applySideMinimums(api, 560);
72
+ expect(api.getPanel("compositions")?.group.minimumWidth).toBe(120);
73
+ expect(api.getPanel("design")?.group.minimumWidth).toBe(120);
74
+ expect(api.getPanel("compositions")?.group.width).toBe(120);
75
+ expect(api.getPanel("design")?.group.width).toBe(120);
76
+ expect(api.getPanel("preview")?.group.minimumWidth).toBe(360);
77
+ });
78
+
79
+ it("leaves a wide window's user-chosen widths alone", () => {
80
+ api.layout(1200, 700);
81
+ buildEditLayout(api, 1200);
82
+ api.getPanel("compositions")?.group.api.setSize({ width: 300 });
83
+ applySideMinimums(api, 1200);
84
+ expect(api.getPanel("compositions")?.group.width).toBe(300);
85
+ });
86
+
87
+ it("keeps the preview's floor when a side panel is tabbed into the preview group", () => {
88
+ api.layout(1200, 700);
89
+ buildEditLayout(api, 1200);
90
+ const preview = api.getPanel("preview");
91
+ const design = api.getPanel("design");
92
+ if (!preview || !design) throw new Error("default layout is missing panels");
93
+ design.api.moveTo({ group: preview.group, position: "center", index: 0 });
94
+ preview.api.setActive();
95
+ applySideMinimums(api, 560);
96
+ expect(preview.group.minimumWidth).toBe(360);
97
+ });
98
+
99
+ it("pins the preview floor when a side panel that was showing gets the preview dragged in", () => {
100
+ api.layout(1200, 700);
101
+ buildEditLayout(api, 1200);
102
+ applySideMinimums(api, 560);
103
+ const preview = api.getPanel("preview");
104
+ const design = api.getPanel("design");
105
+ if (!preview || !design) throw new Error("default layout is missing panels");
106
+ preview.api.moveTo({ group: design.group });
107
+ applySideMinimums(api, 560);
108
+ expect(preview.group.minimumWidth).toBe(360);
109
+ });
110
+
111
+ it("rewrites the minimum of a group that is neither the preview nor side-only", () => {
112
+ api.layout(1200, 700);
113
+ buildEditLayout(api, 1200);
114
+ const timeline = api.getPanel("timeline");
115
+ const compositions = api.getPanel("compositions");
116
+ if (!timeline || !compositions) throw new Error("default layout is missing panels");
117
+ timeline.api.moveTo({ group: compositions.group });
118
+ applySideMinimums(api, 560);
119
+ expect(compositions.group.minimumWidth).toBe(120);
120
+ });
121
+
122
+ it("drops the preview floor from a group the preview has left", () => {
123
+ api.layout(1200, 700);
124
+ buildEditLayout(api, 1200);
125
+ const preview = api.getPanel("preview");
126
+ const timeline = api.getPanel("timeline");
127
+ if (!preview || !timeline) throw new Error("default layout is missing panels");
128
+ preview.api.moveTo({ group: timeline.group });
129
+ applySideMinimums(api, 560);
130
+ preview.api.moveTo({ position: "right", group: api.getPanel("design")?.group });
131
+ applySideMinimums(api, 560);
132
+ expect(timeline.group.minimumWidth).toBe(120);
133
+ });
134
+ });