@noya-app/noya-designsystem 0.1.89 → 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.89",
3
+ "version": "0.1.91",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -29,9 +29,9 @@
29
29
  "@dnd-kit/core": "6.3.1",
30
30
  "@dnd-kit/sortable": "10.0.0",
31
31
  "@floating-ui/react": "^0.27.0",
32
- "@noya-app/noya-colorpicker": "0.1.35",
33
- "@noya-app/noya-utils": "0.1.9",
34
- "@noya-app/noya-geometry": "0.1.18",
32
+ "@noya-app/noya-colorpicker": "0.1.36",
33
+ "@noya-app/noya-utils": "0.1.10",
34
+ "@noya-app/noya-geometry": "0.1.19",
35
35
  "@noya-app/noya-icons": "0.1.20",
36
36
  "@noya-app/noya-keymap": "0.1.4",
37
37
  "@noya-app/noya-tailwind-config": "0.1.10",
@@ -9,6 +9,7 @@ export interface BaseToolbarProps {
9
9
  left?: React.ReactNode;
10
10
  right?: React.ReactNode;
11
11
  logo?: React.ReactNode;
12
+ showLogoDivider?: boolean;
12
13
  showDivider?: boolean;
13
14
  className?: string;
14
15
  innerClassName?: string;
@@ -63,6 +64,7 @@ export function BaseToolbar({
63
64
  left,
64
65
  right,
65
66
  logo,
67
+ showLogoDivider = true,
66
68
  showDivider = true,
67
69
  className,
68
70
  innerClassName,
@@ -97,7 +99,7 @@ export function BaseToolbar({
97
99
  {logo && (
98
100
  <>
99
101
  {logo}
100
- <DividerVertical />
102
+ {showLogoDivider && <DividerVertical />}
101
103
  </>
102
104
  )}
103
105
  <Spacer.Horizontal size={10} />
@@ -19,7 +19,10 @@ const StyledBackdrop = forwardRef<
19
19
  >(({ className, ...props }, ref) => (
20
20
  <DialogPrimitive.Backdrop
21
21
  ref={ref}
22
- className={cx(`n-fixed n-inset-0 n-bg-[rgba(0,0,0,0.5)]`, className)}
22
+ className={cx(
23
+ `n-fixed n-inset-0 n-z-[999] n-bg-[rgba(0,0,0,0.5)]`,
24
+ className
25
+ )}
23
26
  {...props}
24
27
  />
25
28
  ));
@@ -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,22 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { cleanup, render } from "@testing-library/react";
3
+ import React from "react";
4
+ import { BaseToolbar } from "../BaseToolbar";
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe("BaseToolbar", () => {
9
+ test("shows a vertical divider after the logo by default", () => {
10
+ const { container } = render(<BaseToolbar logo={<div>Logo</div>} />);
11
+
12
+ expect(container.querySelector(".n-w-px")).toBeTruthy();
13
+ });
14
+
15
+ test("can hide the vertical divider after the logo", () => {
16
+ const { container } = render(
17
+ <BaseToolbar logo={<div>Back</div>} showLogoDivider={false} />
18
+ );
19
+
20
+ expect(container.querySelector(".n-w-px")).toBeNull();
21
+ });
22
+ });
@@ -64,6 +64,19 @@ describe("DialogNavigator", () => {
64
64
  expect(view.getByRole("button", { name: "Close" })).toBeTruthy();
65
65
  });
66
66
 
67
+ test("stacks the backdrop above elevated page controls", () => {
68
+ render(<TestDialogNavigator onOpenChange={() => {}} />);
69
+
70
+ const backdrop = Array.from(document.querySelectorAll("div")).find(
71
+ (element) =>
72
+ element.classList.contains("n-bg-[rgba(0,0,0,0.5)]") &&
73
+ element.classList.contains("n-inset-0")
74
+ );
75
+
76
+ expect(backdrop).toBeTruthy();
77
+ expect(backdrop?.classList.contains("n-z-[999]")).toBe(true);
78
+ });
79
+
67
80
  test("combines navigation and dialog controls in the header", async () => {
68
81
  const onOpenChange = mock(() => {});
69
82
  const view = render(<TestDialogNavigator onOpenChange={onOpenChange} />);
@@ -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
+ });
@@ -182,7 +182,7 @@ export const DrawerWorkspaceLayout = memoGeneric(function DrawerWorkspaceLayout<
182
182
  )}
183
183
 
184
184
  {compactTabItem && (
185
- <div className="n-absolute n-top-0 n-left-0 n-h-[calc(var(--n-toolbar-height)+1px)] n-w-[calc(var(--n-toolbar-height)+1px)] n-bg-sidebar-background n-border-r n-border-b n-border-divider-strong n-z-10 n-flex n-items-center n-justify-center">
185
+ <div className="n-absolute n-top-0 n-left-0 n-h-[calc(var(--n-toolbar-height)+1px)] n-w-[calc(var(--n-toolbar-height)+1px)] n-bg-sidebar-background n-border-b n-border-divider-strong n-z-10 n-flex n-items-center n-justify-center">
186
186
  <Button
187
187
  aria-label={
188
188
  typeof compactTabItem.title === "string"
@@ -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
  </>
@@ -27,7 +27,12 @@ describe("DrawerWorkspaceLayout", () => {
27
27
 
28
28
  expect(container.textContent).toBe("Center content");
29
29
 
30
- fireEvent.click(getByRole("button", { name: "Navigation" }));
30
+ const navigationButton = getByRole("button", { name: "Navigation" });
31
+ const compactTrigger = navigationButton.parentElement;
32
+ expect(compactTrigger?.classList.contains("n-border-b")).toBe(true);
33
+ expect(compactTrigger?.classList.contains("n-border-r")).toBe(false);
34
+
35
+ fireEvent.click(navigationButton);
31
36
 
32
37
  expect(getByText("Sidebar content")).toBeTruthy();
33
38
  });
@@ -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
+ });