@noya-app/noya-designsystem 0.1.76 → 0.1.78

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 (86) hide show
  1. package/.turbo/turbo-build.log +20 -12
  2. package/CHANGELOG.md +19 -0
  3. package/dist/chunk-D57E6H3M.mjs +36 -0
  4. package/dist/chunk-D57E6H3M.mjs.map +1 -0
  5. package/dist/emojis.d.mts +1 -0
  6. package/dist/emojis.d.ts +1 -0
  7. package/dist/emojis.js +31 -0
  8. package/dist/emojis.js.map +1 -0
  9. package/dist/emojis.mjs +8 -0
  10. package/dist/emojis.mjs.map +1 -0
  11. package/dist/index.css +1 -1
  12. package/dist/index.d.mts +192 -93
  13. package/dist/index.d.ts +192 -93
  14. package/dist/index.js +3355 -1763
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +3622 -2030
  17. package/dist/index.mjs.map +1 -1
  18. package/package.json +13 -9
  19. package/src/__tests__/__snapshots__/fuzzyScorer.test.ts.snap +1 -1
  20. package/src/components/Avatar.tsx +10 -10
  21. package/src/components/Banner.tsx +1 -1
  22. package/src/components/BaseToolbar.tsx +20 -6
  23. package/src/components/Button.tsx +2 -1
  24. package/src/components/Chip.tsx +1 -1
  25. package/src/components/ColorSwatchControl.tsx +22 -14
  26. package/src/components/CommandPalette.tsx +10 -6
  27. package/src/components/ContextMenu.tsx +249 -38
  28. package/src/components/Dialog.tsx +70 -67
  29. package/src/components/Drawer.tsx +56 -17
  30. package/src/components/DropdownMenu.tsx +307 -46
  31. package/src/components/EditableText.tsx +1 -1
  32. package/src/components/EmojiPicker.tsx +645 -0
  33. package/src/components/GridBackground.tsx +40 -0
  34. package/src/components/GridView.tsx +5 -1
  35. package/src/components/IVirtualizedList.tsx +21 -1
  36. package/src/components/InputField.tsx +2 -10
  37. package/src/components/InspectorContainer.tsx +4 -2
  38. package/src/components/Message.tsx +5 -16
  39. package/src/components/OverlayToolbar.tsx +97 -0
  40. package/src/components/Popover.tsx +73 -107
  41. package/src/components/Progress.tsx +18 -18
  42. package/src/components/ScrollArea.tsx +66 -31
  43. package/src/components/ScrollableSidebar.tsx +1 -1
  44. package/src/components/SegmentedControl.tsx +166 -38
  45. package/src/components/SelectMenu.tsx +193 -101
  46. package/src/components/Slider.tsx +40 -38
  47. package/src/components/StackNavigator.tsx +1 -0
  48. package/src/components/Switch.tsx +4 -4
  49. package/src/components/TextArea.tsx +1 -1
  50. package/src/components/Toast.tsx +99 -26
  51. package/src/components/Toolbar.tsx +133 -24
  52. package/src/components/Tooltip.tsx +18 -8
  53. package/src/components/UserPointer.tsx +5 -3
  54. package/src/components/Virtualized.tsx +193 -14
  55. package/src/components/VisuallyHidden.tsx +20 -0
  56. package/src/components/__tests__/Virtualized.math.test.ts +426 -1
  57. package/src/components/__tests__/Virtualized.test.tsx +129 -1
  58. package/src/components/ai-assistant/AIAssistantLayout.tsx +11 -6
  59. package/src/components/internal/Menu.tsx +4 -0
  60. package/src/components/listView/ListViewEditableRowTitle.tsx +1 -1
  61. package/src/components/listView/ListViewRoot.tsx +5 -1
  62. package/src/components/resizablePanels/Panel.tsx +94 -0
  63. package/src/components/resizablePanels/PanelGroup.tsx +498 -0
  64. package/src/components/resizablePanels/PanelGroupContext.tsx +14 -0
  65. package/src/components/resizablePanels/PanelResizeHandle.tsx +61 -0
  66. package/src/components/resizablePanels/index.ts +7 -0
  67. package/src/components/resizablePanels/types.ts +65 -0
  68. package/src/components/workspace/DrawerWorkspaceLayout.tsx +22 -7
  69. package/src/components/workspace/PanelWorkspaceLayout.tsx +30 -84
  70. package/src/components/workspace/WorkspaceLayout.tsx +82 -76
  71. package/src/components/workspace/types.ts +6 -4
  72. package/src/contexts/DialogContext.tsx +15 -24
  73. package/src/emojis.ts +1 -0
  74. package/src/hooks/useTriggerToggle.ts +95 -0
  75. package/src/index.css +2 -0
  76. package/src/index.tsx +3 -2
  77. package/src/theme/proseTheme.ts +22 -0
  78. package/src/utils/mergeProps.ts +57 -0
  79. package/src/utils/skinTone.ts +90 -0
  80. package/tailwind.config.ts +6 -1
  81. package/tsup.config.ts +1 -1
  82. package/src/__tests__/workspaceLayout.test.ts +0 -281
  83. package/src/components/ScrollArea2.tsx +0 -76
  84. package/src/components/internal/MenuViewport.tsx +0 -178
  85. package/src/components/internal/SelectItem.tsx +0 -138
  86. package/src/components/workspace/panelStorage.ts +0 -216
@@ -0,0 +1,95 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ interface UseTriggerToggleOptions {
4
+ /**
5
+ * Called when an interaction outside the popup occurs.
6
+ * Return false to prevent the component from closing.
7
+ */
8
+ shouldCloseOnInteractOutside?: (event: PointerEvent) => boolean;
9
+ }
10
+
11
+ /**
12
+ * Hook that provides toggle behavior for trigger-based components (Popover, Drawer, etc.)
13
+ *
14
+ * When a component is open and the user clicks the trigger, this hook ensures:
15
+ * 1. The component closes (normal behavior)
16
+ * 2. The immediate reopen attempt from the same click is blocked
17
+ * 3. Subsequent clicks work normally
18
+ *
19
+ * @param isOpen - Whether the component is currently open
20
+ * @param setIsOpen - Function to update the open state
21
+ * @param options - Optional configuration
22
+ * @returns Object with anchorElement state, popupRef, and handleOpenChange
23
+ */
24
+ export function useTriggerToggle(
25
+ isOpen: boolean,
26
+ setIsOpen: (open: boolean) => void,
27
+ options: UseTriggerToggleOptions = {}
28
+ ) {
29
+ const { shouldCloseOnInteractOutside } = options;
30
+
31
+ const [anchorElement, setAnchorElement] = useState<Element | null>(null);
32
+ const popupRef = useRef<HTMLDivElement>(null);
33
+ // Track whether a close was just initiated (to prevent immediate reopen from same click)
34
+ const closeInitiatedRef = useRef(false);
35
+ // Track whether the next close should be prevented based on shouldCloseOnInteractOutside
36
+ const shouldPreventCloseRef = useRef(false);
37
+
38
+ useEffect(() => {
39
+ if (!isOpen) return;
40
+
41
+ const handlePointerDown = (event: PointerEvent) => {
42
+ // Check if click is outside the popup content for shouldCloseOnInteractOutside
43
+ if (
44
+ popupRef.current &&
45
+ !popupRef.current.contains(event.target as Node)
46
+ ) {
47
+ if (shouldCloseOnInteractOutside) {
48
+ const shouldClose = shouldCloseOnInteractOutside(event);
49
+ shouldPreventCloseRef.current = !shouldClose;
50
+ }
51
+ }
52
+ };
53
+
54
+ // Use capture phase to intercept before Base UI's handler
55
+ document.addEventListener("pointerdown", handlePointerDown, true);
56
+
57
+ return () => {
58
+ document.removeEventListener("pointerdown", handlePointerDown, true);
59
+ };
60
+ }, [isOpen, anchorElement, shouldCloseOnInteractOutside]);
61
+
62
+ const handleOpenChange = useCallback(
63
+ (newOpen: boolean) => {
64
+ // If closing and we should prevent it, skip
65
+ if (!newOpen && shouldPreventCloseRef.current) {
66
+ shouldPreventCloseRef.current = false;
67
+ return;
68
+ }
69
+
70
+ // When closing, set flag and clear it on next macrotask
71
+ // This detects if open is called within the same event loop cycle (same click)
72
+ if (!newOpen) {
73
+ closeInitiatedRef.current = true;
74
+ setTimeout(() => {
75
+ closeInitiatedRef.current = false;
76
+ }, 0);
77
+ }
78
+
79
+ // Block immediate reopen from the same click that closed the component
80
+ if (newOpen && closeInitiatedRef.current) {
81
+ return;
82
+ }
83
+
84
+ setIsOpen(newOpen);
85
+ },
86
+ [setIsOpen]
87
+ );
88
+
89
+ return {
90
+ anchorElement,
91
+ setAnchorElement,
92
+ popupRef,
93
+ handleOpenChange,
94
+ };
95
+ }
package/src/index.css CHANGED
@@ -109,6 +109,8 @@
109
109
  --n-inspector-v-separator: 10px;
110
110
  --n-dialog-padding: 32px;
111
111
  --n-input-height: 27px;
112
+ --n-input-padding-x: 8px;
113
+ --n-input-padding-y: 4px;
112
114
  --n-icon: var(--n-indigo-300);
113
115
  --n-icon-selected: rgb(220, 220, 220);
114
116
  --n-warning: rgb(251, 211, 0);
package/src/index.tsx CHANGED
@@ -26,11 +26,13 @@ export * from "./components/DraggableMenuButton";
26
26
  export * from "./components/Drawer";
27
27
  export * from "./components/DropdownMenu";
28
28
  export * from "./components/EditableText";
29
+ export * from "./components/EmojiPicker";
29
30
  export * from "./components/Fade";
30
31
  export * from "./components/file-explorer/FileExplorerLayout";
31
32
  export * from "./components/FileUploadIndicator";
32
33
  export * from "./components/FloatingWindow";
33
34
  export * from "./components/Grid";
35
+ export * from "./components/GridBackground";
34
36
  export * from "./components/GridView";
35
37
  export * from "./components/IconButton";
36
38
  export * from "./components/Icons";
@@ -42,7 +44,6 @@ export type {
42
44
  MenuItem,
43
45
  SelectableMenuItem,
44
46
  } from "./components/internal/Menu";
45
- export * from "./components/internal/MenuViewport";
46
47
  export * from "./components/IVirtualizedList";
47
48
  export * from "./components/Label";
48
49
  export * from "./components/LabeledElementView";
@@ -62,7 +63,6 @@ export * from "./components/ResizableContainer";
62
63
  export * from "./components/RingProgress";
63
64
  export * from "./components/ScrollableSidebar";
64
65
  export * from "./components/ScrollArea";
65
- export * from "./components/ScrollArea2";
66
66
  export * from "./components/SearchCompletionMenu";
67
67
  export * from "./components/Section";
68
68
  export * from "./components/SegmentedControl";
@@ -128,5 +128,6 @@ export * from "./components/DimensionInput";
128
128
  export * as InspectorPrimitives from "./components/InspectorPrimitives";
129
129
 
130
130
  export * from "./components/BaseToolbar";
131
+ export * from "./components/OverlayToolbar";
131
132
  export * from "./components/Toolbar";
132
133
  export * from "./components/ToolbarDrawer";
@@ -1,2 +1,24 @@
1
1
  export const proseTheme =
2
2
  "n-prose n-prose-sm n-max-w-none [[data-theme='dark']_&]:n-prose-invert prose-a:n-text-blue-600";
3
+
4
+ export const whiteboardProseTheme = (isDark: boolean) =>
5
+ [
6
+ "n-prose",
7
+ "n-prose-sm",
8
+ "n-max-w-none",
9
+ isDark ? "n-prose-invert n-text-white" : "",
10
+ "prose-p:n-m-0",
11
+ "prose-ul:n-m-0",
12
+ "prose-ol:n-m-0",
13
+ "prose-li:n-m-0",
14
+ "prose-li:n-p-0",
15
+ "prose-a:n-text-inherit",
16
+ "prose-pre:n-my-1",
17
+ "prose-pre:n-text-inherit",
18
+ "prose-strong:n-text-inherit",
19
+ "prose-em:n-italic",
20
+ "prose-ol:n-list-inside",
21
+ "prose-ul:n-list-inside",
22
+ "prose-ol:n-pl-0",
23
+ "prose-ul:n-pl-0",
24
+ ].join(" ");
@@ -0,0 +1,57 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Merges trigger props with child element props, composing event handlers
5
+ * instead of overwriting them. This ensures that when a component like
6
+ * Tooltip wraps a Button with an onClick, both handlers get called.
7
+ *
8
+ * @param triggerProps - Props from the trigger component (e.g., Base UI's Trigger render prop)
9
+ * @param childProps - Props from the child element being wrapped
10
+ * @returns Merged props with composed event handlers
11
+ */
12
+ export function mergePropsWithEventComposition(
13
+ triggerProps: Record<string, unknown>,
14
+ childProps: Record<string, unknown>
15
+ ): Record<string, unknown> {
16
+ const mergedProps: Record<string, unknown> = { ...triggerProps };
17
+
18
+ // Compose event handlers to preserve child's handlers
19
+ for (const key of Object.keys(triggerProps)) {
20
+ if (
21
+ key.startsWith("on") &&
22
+ typeof triggerProps[key] === "function" &&
23
+ typeof childProps[key] === "function"
24
+ ) {
25
+ const triggerHandler = triggerProps[key] as (...args: unknown[]) => void;
26
+ const childHandler = childProps[key] as (...args: unknown[]) => void;
27
+ mergedProps[key] = (...args: unknown[]) => {
28
+ childHandler(...args);
29
+ triggerHandler(...args);
30
+ };
31
+ }
32
+ }
33
+
34
+ return mergedProps;
35
+ }
36
+
37
+ /**
38
+ * Clones a React element with trigger props, composing event handlers.
39
+ * If the element is not a valid React element, wraps it in a span.
40
+ *
41
+ * @param element - The element to clone (trigger child)
42
+ * @param triggerProps - Props from the trigger component
43
+ * @returns Cloned element with merged props
44
+ */
45
+ export function cloneElementWithMergedProps(
46
+ element: React.ReactNode,
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ triggerProps: any
49
+ ): React.ReactElement {
50
+ const props = triggerProps as Record<string, unknown>;
51
+ if (React.isValidElement(element)) {
52
+ const childProps = element.props as Record<string, unknown>;
53
+ const mergedProps = mergePropsWithEventComposition(props, childProps);
54
+ return React.cloneElement(element, mergedProps);
55
+ }
56
+ return React.createElement("span", props, element);
57
+ }
@@ -0,0 +1,90 @@
1
+ // Fitzpatrick skin tone modifiers
2
+ export const SKIN_TONES = {
3
+ default: "",
4
+ light: "\u{1F3FB}", // 🏻 Type 1-2
5
+ "medium-light": "\u{1F3FC}", // 🏼 Type 3
6
+ medium: "\u{1F3FD}", // 🏽 Type 4
7
+ "medium-dark": "\u{1F3FE}", // 🏾 Type 5
8
+ dark: "\u{1F3FF}", // 🏿 Type 6
9
+ } as const;
10
+
11
+ export type SkinTone = keyof typeof SKIN_TONES;
12
+
13
+ // Display emojis for the skin tone picker (using hand as example)
14
+ export const SKIN_TONE_DISPLAY: Record<SkinTone, string> = {
15
+ default: "✋",
16
+ light: "✋🏻",
17
+ "medium-light": "✋🏼",
18
+ medium: "✋🏽",
19
+ "medium-dark": "✋🏾",
20
+ dark: "✋🏿",
21
+ };
22
+
23
+ // Emoji presentation selector - takes the same place as skin tone modifier
24
+ // https://unicode.org/reports/tr51/#composing_zwj_seq
25
+ const EMOJI_PRESENTATION_SELECTOR = "\u{FE0F}";
26
+
27
+ // Emojis that don't render correctly with skin tones on some platforms
28
+ // - Family emojis with 2 people (to distinguish from couple, handshake, etc.)
29
+ // - Multi-person activity emojis (don't render correctly on macOS)
30
+ const SKIN_TONE_EXCLUDED_EMOJIS = new Set([
31
+ "👩‍👦",
32
+ "👩‍👧",
33
+ "👨‍👧",
34
+ "👨‍👦",
35
+ "👯", // dancers
36
+ "👯‍♂️", // dancing_men
37
+ "👯‍♀️", // dancing_women
38
+ "🤼", // wrestling
39
+ "🤼‍♂️", // men_wrestling
40
+ "🤼‍♀️", // women_wrestling
41
+ ]);
42
+
43
+ // Regex to match emoji modifier base characters (characters that can have skin tone applied)
44
+ const EMOJI_MODIFIER_BASE_REGEX = /\p{Emoji_Modifier_Base}/gu;
45
+
46
+ // Apply skin tone modifier to an emoji
47
+ // Based on https://github.com/sindresorhus/skin-tone (MIT)
48
+ export function applySkinTone(emoji: string, skinTone: SkinTone): string {
49
+ if (skinTone === "default") {
50
+ return emoji;
51
+ }
52
+
53
+ // Remove any existing skin tone modifiers
54
+ const cleanEmoji = emoji.replaceAll(/[\u{1F3FB}-\u{1F3FF}]/gu, "");
55
+
56
+ // Count how many modifiable components the emoji has
57
+ const modifiableCount =
58
+ cleanEmoji.match(EMOJI_MODIFIER_BASE_REGEX)?.length ?? 0;
59
+
60
+ // Skip if:
61
+ // - No modifiable components
62
+ // - More than 2 modifiable components (family emojis with 3+ people)
63
+ // - Excluded emoji (family emojis, dancers that don't render correctly)
64
+ if (
65
+ modifiableCount === 0 ||
66
+ modifiableCount > 2 ||
67
+ SKIN_TONE_EXCLUDED_EMOJIS.has(cleanEmoji)
68
+ ) {
69
+ return emoji;
70
+ }
71
+
72
+ const tone = SKIN_TONES[skinTone];
73
+ let result = "";
74
+
75
+ for (const codePoint of cleanEmoji) {
76
+ // Skip emoji presentation selector (it's replaced by skin tone modifier)
77
+ if (codePoint === EMOJI_PRESENTATION_SELECTOR) {
78
+ continue;
79
+ }
80
+
81
+ result += codePoint;
82
+
83
+ // Apply skin tone after each modifiable component
84
+ if (EMOJI_MODIFIER_BASE_REGEX.test(codePoint)) {
85
+ result += tone;
86
+ }
87
+ }
88
+
89
+ return result;
90
+ }
@@ -7,7 +7,12 @@ const config = {
7
7
  ...noyaConfig,
8
8
  prefix: "n-",
9
9
  plugins: [containerQueries, typography],
10
- safelist: [...noyaConfig.safelist, "dark:n-prose-invert"],
10
+ safelist: [
11
+ ...noyaConfig.safelist,
12
+ "n-prose-invert",
13
+ "dark:n-prose-invert",
14
+ "prose-p:n-m-0",
15
+ ],
11
16
  } satisfies Config;
12
17
 
13
18
  export default config;
package/tsup.config.ts CHANGED
@@ -6,7 +6,7 @@ const execAsync = promisify(exec);
6
6
 
7
7
  // "build": "tsup src/index.tsx --format cjs,esm --dts --sourcemap",
8
8
  export default defineConfig({
9
- entry: ["./src/index.tsx"],
9
+ entry: ["./src/index.tsx", "./src/emojis.ts"],
10
10
  format: ["cjs", "esm"],
11
11
  dts: true,
12
12
  sourcemap: true,
@@ -1,281 +0,0 @@
1
- import { expect, it } from "bun:test";
2
- import { MockClientStorage } from "../../../noya-react-utils/src/__tests__/MockClientStorage";
3
- import {
4
- CONTENT_AREA_ID,
5
- LEFT_SIDEBAR_ID,
6
- RIGHT_SIDEBAR_ID,
7
- } from "../components/workspace/constants";
8
- import {
9
- getInitialLayout,
10
- getLayoutCollapsedState,
11
- getLayoutIds,
12
- getPropertyKey,
13
- getStorageKey,
14
- mapWorkspaceLayoutState,
15
- setInitialLayout,
16
- toPercentageSize,
17
- toPixelSize,
18
- } from "../components/workspace/panelStorage";
19
- import { WorkspaceLayoutState } from "../components/workspace/types";
20
-
21
- const TEST_WINDOW_WIDTH = 1000;
22
-
23
- it("gets layout ids", () => {
24
- expect(
25
- getLayoutIds({
26
- hasLeftPanel: true,
27
- hasRightPanel: false,
28
- hasCenterPanel: true,
29
- })
30
- ).toEqual([LEFT_SIDEBAR_ID, CONTENT_AREA_ID]);
31
- });
32
-
33
- it("gets layout ids with all panels", () => {
34
- expect(
35
- getLayoutIds({
36
- hasLeftPanel: true,
37
- hasRightPanel: true,
38
- hasCenterPanel: true,
39
- })
40
- ).toEqual([LEFT_SIDEBAR_ID, CONTENT_AREA_ID, RIGHT_SIDEBAR_ID]);
41
- });
42
-
43
- it("gets layout ids with no panels", () => {
44
- expect(
45
- getLayoutIds({
46
- hasLeftPanel: false,
47
- hasRightPanel: false,
48
- hasCenterPanel: false,
49
- })
50
- ).toEqual([]);
51
- });
52
-
53
- it("converts to pixel size", () => {
54
- expect(toPixelSize(25, TEST_WINDOW_WIDTH)).toBe(250); // 25% of 1000px = 250px
55
- expect(toPixelSize(50, TEST_WINDOW_WIDTH)).toBe(500); // 50% of 1000px = 500px
56
- expect(toPixelSize(75, TEST_WINDOW_WIDTH)).toBe(750); // 75% of 1000px = 750px
57
- });
58
-
59
- it("converts to percentage size", () => {
60
- expect(toPercentageSize(250, TEST_WINDOW_WIDTH)).toBe(25); // 250px of 1000px = 25%
61
- expect(toPercentageSize(500, TEST_WINDOW_WIDTH)).toBe(50); // 500px of 1000px = 50%
62
- expect(toPercentageSize(750, TEST_WINDOW_WIDTH)).toBe(75); // 750px of 1000px = 75%
63
- });
64
-
65
- it("maps workspace layout state", () => {
66
- const layout: WorkspaceLayoutState = {
67
- "left,center": {
68
- layout: [20, 80],
69
- expandToSizes: { left: 30, center: 70 },
70
- },
71
- "left,center,right": {
72
- layout: [20, 60, 20],
73
- expandToSizes: { left: 25, center: 50, right: 25 },
74
- },
75
- };
76
-
77
- const mapped = mapWorkspaceLayoutState(layout, (size) => size * 2);
78
-
79
- expect(mapped).toEqual({
80
- "left,center": {
81
- layout: [40, 160],
82
- expandToSizes: { left: 60, center: 140 },
83
- },
84
- "left,center,right": {
85
- layout: [40, 120, 40],
86
- expandToSizes: { left: 50, center: 100, right: 50 },
87
- },
88
- });
89
- });
90
-
91
- it("gets property key", () => {
92
- expect(getPropertyKey([LEFT_SIDEBAR_ID, CONTENT_AREA_ID])).toBe(
93
- [CONTENT_AREA_ID, LEFT_SIDEBAR_ID].sort().join(",")
94
- );
95
- });
96
-
97
- it("gets storage key", () => {
98
- expect(getStorageKey("test-workspace")).toBe(
99
- "react-resizable-panels:test-workspace"
100
- );
101
- });
102
-
103
- it("gets initial layout with left panel only", () => {
104
- const result = getInitialLayout({
105
- layoutIds: [LEFT_SIDEBAR_ID, CONTENT_AREA_ID],
106
- leftSidebarOptions: {
107
- initialSize: 20,
108
- defaultCollapsed: false,
109
- },
110
- rightSidebarOptions: {
111
- initialSize: 20,
112
- defaultCollapsed: false,
113
- },
114
- centerPanelPercentage: 80,
115
- hasLeftPanel: true,
116
- hasRightPanel: false,
117
- hasCenterPanel: true,
118
- windowWidth: TEST_WINDOW_WIDTH,
119
- });
120
-
121
- expect(result).toEqual([200, 800]); // 20% and 80% of 1000px
122
- });
123
-
124
- it("gets initial layout with collapsed left panel", () => {
125
- const result = getInitialLayout({
126
- layoutIds: [LEFT_SIDEBAR_ID, CONTENT_AREA_ID],
127
- leftSidebarOptions: {
128
- initialSize: 20,
129
- defaultCollapsed: true,
130
- },
131
- rightSidebarOptions: {
132
- initialSize: 20,
133
- defaultCollapsed: false,
134
- },
135
- centerPanelPercentage: 80,
136
- hasLeftPanel: true,
137
- hasRightPanel: false,
138
- hasCenterPanel: true,
139
- windowWidth: TEST_WINDOW_WIDTH,
140
- });
141
-
142
- expect(result).toEqual([0, 1000]); // Collapsed left panel, center takes full width
143
- });
144
-
145
- it("gets initial layout with all panels", () => {
146
- const result = getInitialLayout({
147
- layoutIds: [LEFT_SIDEBAR_ID, CONTENT_AREA_ID, RIGHT_SIDEBAR_ID],
148
- leftSidebarOptions: {
149
- initialSize: 20,
150
- defaultCollapsed: false,
151
- },
152
- rightSidebarOptions: {
153
- initialSize: 20,
154
- defaultCollapsed: false,
155
- },
156
- centerPanelPercentage: 60,
157
- hasLeftPanel: true,
158
- hasRightPanel: true,
159
- hasCenterPanel: true,
160
- windowWidth: TEST_WINDOW_WIDTH,
161
- });
162
-
163
- expect(result).toEqual([200, 600, 200]); // 20%, 60%, 20% of 1000px
164
- });
165
-
166
- it("sets initial layout in storage", () => {
167
- const mockStorage = new MockClientStorage();
168
-
169
- setInitialLayout({
170
- storageKey: "test-key",
171
- propertyKey: "left,center",
172
- calculateLayout: () => [200, 800],
173
- storage: mockStorage,
174
- });
175
-
176
- expect(mockStorage.hasItem("test-key")).toBe(true);
177
- expect(mockStorage.getItemParsed<WorkspaceLayoutState>("test-key")).toEqual({
178
- "left,center": {
179
- layout: [200, 800],
180
- expandToSizes: {},
181
- },
182
- });
183
- });
184
-
185
- it("does not update existing layout property in storage", () => {
186
- const existingLayout: WorkspaceLayoutState = {
187
- center: {
188
- layout: [1000],
189
- expandToSizes: {},
190
- },
191
- // Already has the property we're trying to set
192
- "left,center": {
193
- layout: [100, 900],
194
- expandToSizes: {},
195
- },
196
- };
197
-
198
- const mockStorage = new MockClientStorage();
199
- mockStorage.setItem("test-key", JSON.stringify(existingLayout));
200
-
201
- setInitialLayout({
202
- storageKey: "test-key",
203
- propertyKey: "left,center",
204
- calculateLayout: () => [200, 800],
205
- storage: mockStorage,
206
- });
207
-
208
- // setInitialLayout doesn't update existing properties, so the layout should remain unchanged
209
- expect(mockStorage.getItemParsed<WorkspaceLayoutState>("test-key")).toEqual(
210
- existingLayout
211
- );
212
- });
213
-
214
- it("adds new layout property to existing storage", () => {
215
- const existingLayout: WorkspaceLayoutState = {
216
- center: {
217
- layout: [1000],
218
- expandToSizes: {},
219
- },
220
- };
221
-
222
- const mockStorage = new MockClientStorage();
223
- mockStorage.setItem("test-key", JSON.stringify(existingLayout));
224
-
225
- // The propertyId should be sorted
226
- const sortedPropertyId = [LEFT_SIDEBAR_ID, CONTENT_AREA_ID].sort().join(",");
227
-
228
- setInitialLayout({
229
- storageKey: "test-key",
230
- propertyKey: sortedPropertyId,
231
- calculateLayout: () => [200, 800],
232
- storage: mockStorage,
233
- });
234
-
235
- expect(mockStorage.getItemParsed<WorkspaceLayoutState>("test-key")).toEqual({
236
- center: {
237
- layout: [1000],
238
- expandToSizes: {},
239
- },
240
- [sortedPropertyId]: {
241
- layout: [200, 800],
242
- expandToSizes: {},
243
- },
244
- });
245
- });
246
-
247
- it("gets layout collapsed state", () => {
248
- const layoutGroup = {
249
- layout: [0, 800, 200],
250
- expandToSizes: {},
251
- };
252
-
253
- const result = getLayoutCollapsedState({
254
- layoutGroup,
255
- propertyKey: "left,center,right",
256
- layoutIds: [LEFT_SIDEBAR_ID, CONTENT_AREA_ID, RIGHT_SIDEBAR_ID],
257
- hasLeftPanel: true,
258
- hasRightPanel: true,
259
- });
260
-
261
- expect(result.leftSidebarCollapsed).toBe(true); // size is 0
262
- expect(result.rightSidebarCollapsed).toBe(false); // size is 200
263
- });
264
-
265
- it("handles panels that don't exist", () => {
266
- const layoutGroup = {
267
- layout: [1000],
268
- expandToSizes: {},
269
- };
270
-
271
- const result = getLayoutCollapsedState({
272
- layoutGroup,
273
- propertyKey: "center",
274
- layoutIds: [CONTENT_AREA_ID],
275
- hasLeftPanel: false,
276
- hasRightPanel: false,
277
- });
278
-
279
- expect(result.leftSidebarCollapsed).toBe(false); // Panel doesn't exist
280
- expect(result.rightSidebarCollapsed).toBe(false); // Panel doesn't exist
281
- });
@@ -1,76 +0,0 @@
1
- "use client";
2
-
3
- import { ScrollArea } from "@base-ui-components/react/scroll-area";
4
- import * as React from "react";
5
- import { cx } from "../utils/classNames";
6
-
7
- const SCROLLBAR_STATE_CLASSES =
8
- "scroll-component n-opacity-0 n-transition-opacity n-delay-300 n-pointer-events-none data-[hovering]:n-opacity-100 data-[hovering]:n-delay-0 data-[hovering]:n-duration-75 data-[hovering]:n-pointer-events-auto data-[scrolling]:n-opacity-100 data-[scrolling]:n-delay-0 data-[scrolling]:n-duration-75 data-[scrolling]:n-pointer-events-auto";
9
-
10
- const SCROLLBAR_TRACK_VERTICAL =
11
- "n-m-1 n-w-1 flex n-justify-center n-rounded-md n-bg-divider-subtle";
12
- const SCROLLBAR_TRACK_HORIZONTAL =
13
- "n-m-1 n-h-1 flex n-justify-center n-rounded-md n-bg-divider-subtle";
14
-
15
- const SCROLLBAR_THUMB_VERTICAL =
16
- "scroll-component n-w-full n-rounded n-bg-scrollbar";
17
- const SCROLLBAR_THUMB_HORIZONTAL =
18
- "scroll-component n-h-full n-rounded n-bg-scrollbar";
19
-
20
- type ScrollAreaViewportProps = React.HTMLAttributes<HTMLDivElement> & {
21
- ref?: React.Ref<HTMLDivElement>;
22
- };
23
-
24
- export function ScrollArea2({
25
- children,
26
- className,
27
- viewportClassName,
28
- orientation = "vertical",
29
- style,
30
- viewportProps,
31
- }: {
32
- children: React.ReactNode;
33
- className?: string;
34
- viewportClassName?: string;
35
- orientation?: "vertical" | "horizontal" | "both";
36
- style?: React.CSSProperties;
37
- viewportProps?: ScrollAreaViewportProps;
38
- }) {
39
- const {
40
- className: viewportPropsClassName,
41
- ref: viewportRef,
42
- ...viewportRest
43
- } = viewportProps ?? {};
44
-
45
- return (
46
- <ScrollArea.Root className={cx("n-relative", className)} style={style}>
47
- <ScrollArea.Viewport
48
- ref={viewportRef}
49
- className={cx(
50
- "n-h-full n-overscroll-contain",
51
- viewportClassName,
52
- viewportPropsClassName
53
- )}
54
- {...viewportRest}
55
- >
56
- {children}
57
- </ScrollArea.Viewport>
58
- {(orientation === "vertical" || orientation === "both") && (
59
- <ScrollArea.Scrollbar
60
- className={cx(SCROLLBAR_STATE_CLASSES, SCROLLBAR_TRACK_VERTICAL)}
61
- >
62
- <ScrollArea.Thumb className={SCROLLBAR_THUMB_VERTICAL} />
63
- </ScrollArea.Scrollbar>
64
- )}
65
- {(orientation === "horizontal" || orientation === "both") && (
66
- <ScrollArea.Scrollbar
67
- orientation="horizontal"
68
- className={cx(SCROLLBAR_STATE_CLASSES, SCROLLBAR_TRACK_HORIZONTAL)}
69
- >
70
- <ScrollArea.Thumb className={SCROLLBAR_THUMB_HORIZONTAL} />
71
- </ScrollArea.Scrollbar>
72
- )}
73
- {orientation === "both" && <ScrollArea.Corner />}
74
- </ScrollArea.Root>
75
- );
76
- }