@noya-app/noya-designsystem 0.1.90 → 0.1.91

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.90",
3
+ "version": "0.1.91",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -1,17 +1,23 @@
1
1
  "use client";
2
2
 
3
- import React, { useMemo } from "react";
3
+ import React, { useCallback, useLayoutEffect, useMemo, useRef } from "react";
4
4
  import { cx } from "../utils/classNames";
5
5
  import { toolbarTransparentStyle } from "./BaseToolbar";
6
6
 
7
7
  export type OverlayLayout = "bottomCenterTopRight" | "topLeftTopRight";
8
8
 
9
+ export type OverlayToolbarRect = {
10
+ id: string;
11
+ rect: { x: number; y: number; width: number; height: number };
12
+ };
13
+
9
14
  export interface OverlayToolbarProps {
10
15
  left?: React.ReactNode;
11
16
  right?: React.ReactNode;
12
17
  overlayLayout?: OverlayLayout;
13
18
  className?: string;
14
19
  style?: React.CSSProperties;
20
+ onRectsChange?: (rects: OverlayToolbarRect[]) => void;
15
21
  }
16
22
 
17
23
  const panelClassName =
@@ -23,11 +29,16 @@ export function OverlayToolbar({
23
29
  overlayLayout = "bottomCenterTopRight",
24
30
  className,
25
31
  style,
32
+ onRectsChange,
26
33
  }: OverlayToolbarProps) {
27
34
  const resolvedLayout =
28
35
  overlayLayout === "topLeftTopRight"
29
36
  ? "topLeftTopRight"
30
37
  : "bottomCenterTopRight";
38
+ const leftPanelRef = useRef<HTMLDivElement>(null);
39
+ const rightPanelRef = useRef<HTMLDivElement>(null);
40
+ const hasLeft = Boolean(left);
41
+ const hasRight = Boolean(right);
31
42
 
32
43
  // Merge transparent backgrounds with custom style
33
44
  const panelStyle = useMemo(
@@ -38,11 +49,85 @@ export function OverlayToolbar({
38
49
  [style]
39
50
  );
40
51
 
52
+ const emitRects = useCallback(() => {
53
+ if (!onRectsChange) return;
54
+
55
+ const panels = [
56
+ {
57
+ id:
58
+ resolvedLayout === "topLeftTopRight"
59
+ ? "noya-toolbar-top-left"
60
+ : "noya-toolbar-bottom-center",
61
+ element: leftPanelRef.current,
62
+ },
63
+ {
64
+ id: "noya-toolbar-top-right",
65
+ element: rightPanelRef.current,
66
+ },
67
+ ];
68
+
69
+ onRectsChange(
70
+ panels.flatMap(({ id, element }) => {
71
+ if (!element) return [];
72
+
73
+ const rect = element.getBoundingClientRect();
74
+ const containerRect = element
75
+ .closest<HTMLElement>("[data-workspace-content]")
76
+ ?.getBoundingClientRect();
77
+ return [
78
+ {
79
+ id,
80
+ rect: {
81
+ x: rect.x - (containerRect?.x ?? 0),
82
+ y: rect.y - (containerRect?.y ?? 0),
83
+ width: rect.width,
84
+ height: rect.height,
85
+ },
86
+ },
87
+ ];
88
+ })
89
+ );
90
+ }, [onRectsChange, resolvedLayout]);
91
+
92
+ useLayoutEffect(() => {
93
+ if (!onRectsChange) return;
94
+
95
+ let animationFrame: number | undefined;
96
+ const scheduleEmit = () => {
97
+ if (animationFrame !== undefined) {
98
+ cancelAnimationFrame(animationFrame);
99
+ }
100
+ animationFrame = requestAnimationFrame(emitRects);
101
+ };
102
+
103
+ emitRects();
104
+
105
+ const resizeObserver =
106
+ typeof ResizeObserver === "undefined"
107
+ ? undefined
108
+ : new ResizeObserver(scheduleEmit);
109
+ const panels = [leftPanelRef.current, rightPanelRef.current];
110
+ panels.forEach((panel) => panel && resizeObserver?.observe(panel));
111
+ window.addEventListener("resize", scheduleEmit);
112
+ window.addEventListener("scroll", scheduleEmit, true);
113
+
114
+ return () => {
115
+ if (animationFrame !== undefined) {
116
+ cancelAnimationFrame(animationFrame);
117
+ }
118
+ resizeObserver?.disconnect();
119
+ window.removeEventListener("resize", scheduleEmit);
120
+ window.removeEventListener("scroll", scheduleEmit, true);
121
+ onRectsChange([]);
122
+ };
123
+ }, [emitRects, hasLeft, hasRight, onRectsChange]);
124
+
41
125
  const renderLeftPanel = (overlayId: string) => {
42
126
  if (!left) return null;
43
127
 
44
128
  return (
45
129
  <div
130
+ ref={leftPanelRef}
46
131
  className={panelClassName}
47
132
  data-embedded-overlay-id={overlayId}
48
133
  style={panelStyle}
@@ -84,6 +169,7 @@ export function OverlayToolbar({
84
169
  )}
85
170
  >
86
171
  <div
172
+ ref={rightPanelRef}
87
173
  className={panelClassName}
88
174
  data-embedded-overlay-id="noya-toolbar-top-right"
89
175
  style={panelStyle}
@@ -0,0 +1,63 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { cleanup, render } from "@testing-library/react";
3
+ import React from "react";
4
+ import { OverlayToolbar, OverlayToolbarRect } from "../OverlayToolbar";
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe("OverlayToolbar", () => {
9
+ test("reports the bounds of its local panels", () => {
10
+ const originalGetBoundingClientRect =
11
+ HTMLElement.prototype.getBoundingClientRect;
12
+ const updates: OverlayToolbarRect[][] = [];
13
+
14
+ HTMLElement.prototype.getBoundingClientRect = function () {
15
+ const id = this.getAttribute("data-embedded-overlay-id");
16
+ const rect = this.hasAttribute("data-workspace-content")
17
+ ? { x: 100, y: 200, width: 800, height: 600 }
18
+ : id === "noya-toolbar-top-left"
19
+ ? { x: 116, y: 216, width: 120, height: 40 }
20
+ : id === "noya-toolbar-top-right"
21
+ ? { x: 700, y: 224, width: 160, height: 48 }
22
+ : undefined;
23
+
24
+ if (!rect) return originalGetBoundingClientRect.call(this);
25
+
26
+ return {
27
+ ...rect,
28
+ top: rect.y,
29
+ right: rect.x + rect.width,
30
+ bottom: rect.y + rect.height,
31
+ left: rect.x,
32
+ toJSON: () => rect,
33
+ } as DOMRect;
34
+ };
35
+
36
+ try {
37
+ render(
38
+ <div data-workspace-content>
39
+ <OverlayToolbar
40
+ left={<div>Left</div>}
41
+ right={<div>Right</div>}
42
+ overlayLayout="topLeftTopRight"
43
+ onRectsChange={(rects) => updates.push(rects)}
44
+ />
45
+ </div>
46
+ );
47
+
48
+ expect(updates.at(-1)).toEqual([
49
+ {
50
+ id: "noya-toolbar-top-left",
51
+ rect: { x: 16, y: 16, width: 120, height: 40 },
52
+ },
53
+ {
54
+ id: "noya-toolbar-top-right",
55
+ rect: { x: 600, y: 24, width: 160, height: 48 },
56
+ },
57
+ ]);
58
+ } finally {
59
+ HTMLElement.prototype.getBoundingClientRect =
60
+ originalGetBoundingClientRect;
61
+ }
62
+ });
63
+ });
@@ -16,7 +16,11 @@ import React, {
16
16
  } from "react";
17
17
  import { cx } from "../../utils/classNames";
18
18
  import { MenuItem } from "../internal/Menu";
19
- import { OverlayLayout, OverlayToolbar } from "../OverlayToolbar";
19
+ import {
20
+ OverlayLayout,
21
+ OverlayToolbar,
22
+ OverlayToolbarRect,
23
+ } from "../OverlayToolbar";
20
24
  import { DrawerWorkspaceLayout } from "./DrawerWorkspaceLayout";
21
25
  import { PanelWorkspaceLayout } from "./PanelWorkspaceLayout";
22
26
  import { renderPanelChildren } from "./renderPanelChildren";
@@ -76,6 +80,10 @@ export interface WorkspaceLayoutProps<
76
80
  * @default "bottomCenterTopRight"
77
81
  */
78
82
  overlayLayout?: OverlayLayout;
83
+ /** Empty space to reserve above workspace content. @default 0 */
84
+ contentTopInset?: number;
85
+ /** Called when the local overlay toolbar panels move or resize. */
86
+ onOverlayToolbarRectsChange?: (rects: OverlayToolbarRect[]) => void;
79
87
  sideType?: SideType;
80
88
  sideTypeBreakpoint?: number;
81
89
  detectSize?: DetectSizeType;
@@ -165,6 +173,8 @@ export const WorkspaceLayout = forwardRefGeneric(function WorkspaceLayout<
165
173
  overlayToolbarLeft,
166
174
  overlayToolbarRight,
167
175
  overlayLayout,
176
+ contentTopInset = 0,
177
+ onOverlayToolbarRectsChange,
168
178
  children,
169
179
  sideType = "auto",
170
180
  sideTypeBreakpoint = 800,
@@ -450,7 +460,13 @@ export const WorkspaceLayout = forwardRefGeneric(function WorkspaceLayout<
450
460
  data-theme={theme}
451
461
  >
452
462
  {!isOverlay && toolbar}
453
- <div className="n-flex n-flex-row n-flex-1 n-relative">
463
+ <div
464
+ className="n-flex n-flex-row n-flex-1 n-min-h-0 n-relative"
465
+ data-workspace-content
466
+ style={
467
+ contentTopInset > 0 ? { paddingTop: contentTopInset } : undefined
468
+ }
469
+ >
454
470
  {readyToRender && (
455
471
  <>
456
472
  <LayoutComponent
@@ -478,6 +494,7 @@ export const WorkspaceLayout = forwardRefGeneric(function WorkspaceLayout<
478
494
  left={overlayToolbarLeft}
479
495
  right={overlayToolbarRight}
480
496
  overlayLayout={overlayLayout}
497
+ onRectsChange={onOverlayToolbarRectsChange}
481
498
  />
482
499
  )}
483
500
  </>
@@ -0,0 +1,41 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { cleanup, render } from "@testing-library/react";
3
+ import React from "react";
4
+ import { WorkspaceLayout } from "../WorkspaceLayout";
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe("WorkspaceLayout", () => {
9
+ test("applies the top inset to content without moving the toolbar", () => {
10
+ const { container, getByText } = render(
11
+ <WorkspaceLayout
12
+ contentTopInset={72}
13
+ detectSize="window"
14
+ toolbar={<div>Toolbar</div>}
15
+ >
16
+ <div>Content</div>
17
+ </WorkspaceLayout>
18
+ );
19
+
20
+ const content = container.querySelector<HTMLElement>(
21
+ "[data-workspace-content]"
22
+ );
23
+
24
+ expect(content?.style.paddingTop).toBe("72px");
25
+ expect(content?.contains(getByText("Toolbar"))).toBe(false);
26
+ expect(content?.contains(getByText("Content"))).toBe(true);
27
+ });
28
+
29
+ test("does not pad content when the inset is zero", () => {
30
+ const { container } = render(
31
+ <WorkspaceLayout contentTopInset={0} detectSize="window">
32
+ <div>Content</div>
33
+ </WorkspaceLayout>
34
+ );
35
+
36
+ const content = container.querySelector<HTMLElement>(
37
+ "[data-workspace-content]"
38
+ );
39
+ expect(content?.style.paddingTop).toBe("");
40
+ });
41
+ });