@noya-app/noya-designsystem 0.1.46 → 0.1.48

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 (42) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +16 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +208 -98
  5. package/dist/index.d.ts +208 -98
  6. package/dist/index.js +2148 -1604
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +2153 -1618
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +6 -7
  11. package/src/__tests__/validateDropIndicator.test.ts +263 -0
  12. package/src/components/ActionMenu.tsx +40 -0
  13. package/src/components/ActivityIndicator.tsx +7 -1
  14. package/src/components/Collection.tsx +12 -2
  15. package/src/components/Combobox.tsx +14 -1
  16. package/src/components/ComboboxMenu.tsx +14 -5
  17. package/src/components/Dialog.tsx +28 -31
  18. package/src/components/Drawer.tsx +98 -0
  19. package/src/components/Grid.tsx +57 -27
  20. package/src/components/GridView.tsx +39 -25
  21. package/src/components/InputField.tsx +87 -92
  22. package/src/components/Label.tsx +7 -7
  23. package/src/components/List.tsx +42 -16
  24. package/src/components/ListView.tsx +21 -17
  25. package/src/components/MediaThumbnail.tsx +117 -0
  26. package/src/components/SearchCompletionMenu.tsx +31 -37
  27. package/src/components/SelectMenu.tsx +19 -17
  28. package/src/components/Sortable.tsx +70 -44
  29. package/src/components/Switch.tsx +1 -1
  30. package/src/components/internal/Menu.tsx +38 -23
  31. package/src/components/internal/MenuViewport.tsx +7 -1
  32. package/src/components/internal/SelectItem.tsx +51 -27
  33. package/src/components/internal/__tests__/Menu.test.tsx +12 -0
  34. package/src/components/workspace/DrawerWorkspaceLayout.tsx +86 -0
  35. package/src/components/workspace/PanelWorkspaceLayout.tsx +102 -0
  36. package/src/components/{WorkspaceLayout.tsx → workspace/WorkspaceLayout.tsx} +109 -106
  37. package/src/components/workspace/types.ts +31 -0
  38. package/src/contexts/DialogContext.tsx +91 -30
  39. package/src/hooks/usePreservePanelSize.tsx +1 -5
  40. package/src/index.css +3 -1
  41. package/src/index.tsx +4 -1
  42. package/tailwind.config.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -20,12 +20,11 @@
20
20
  "lint": "eslint . --max-warnings 0"
21
21
  },
22
22
  "dependencies": {
23
- "@dnd-kit/core": "3.1.1",
24
- "@dnd-kit/modifiers": "3.0.0",
25
- "@dnd-kit/sortable": "4.0.0",
26
- "@noya-app/noya-colorpicker": "0.1.17",
27
- "@noya-app/noya-utils": "0.1.3",
28
- "@noya-app/noya-geometry": "0.1.7",
23
+ "@dnd-kit/core": "6.3.1",
24
+ "@dnd-kit/sortable": "10.0.0",
25
+ "@noya-app/noya-colorpicker": "0.1.18",
26
+ "@noya-app/noya-utils": "0.1.4",
27
+ "@noya-app/noya-geometry": "0.1.8",
29
28
  "@noya-app/noya-icons": "0.1.6",
30
29
  "@noya-app/noya-keymap": "0.1.3",
31
30
  "@radix-ui/primitive": "^1.0.0",
@@ -0,0 +1,263 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ defaultAcceptsDrop,
4
+ validateDropIndicator,
5
+ } from "../components/Sortable";
6
+
7
+ describe("defaultAcceptsDrop", () => {
8
+ it("should return false if the active item is the same as the over item", () => {
9
+ const result = defaultAcceptsDrop(0, 0, "inside");
10
+ expect(result).toBe(false);
11
+ });
12
+
13
+ it("should return false for 'inside' position", () => {
14
+ const result = defaultAcceptsDrop(0, 1, "inside");
15
+ expect(result).toBe(false);
16
+ });
17
+
18
+ it("should return false when dropping above the same item", () => {
19
+ const result = defaultAcceptsDrop(0, 0, "above");
20
+ expect(result).toBe(false);
21
+ });
22
+
23
+ it("should return false when dropping below the same item", () => {
24
+ const result = defaultAcceptsDrop(0, 0, "below");
25
+ expect(result).toBe(false);
26
+ });
27
+
28
+ it("should return false when dropping above the item below the source", () => {
29
+ const result = defaultAcceptsDrop(0, 1, "above");
30
+ expect(result).toBe(false);
31
+ });
32
+
33
+ it("should return false when dropping below the item above the source", () => {
34
+ const result = defaultAcceptsDrop(1, 0, "below");
35
+ expect(result).toBe(false);
36
+ });
37
+
38
+ it("should return true when dropping above a valid target", () => {
39
+ const result = defaultAcceptsDrop(0, 2, "above");
40
+ expect(result).toBe(true);
41
+ });
42
+
43
+ it("should return true when dropping below a valid target", () => {
44
+ const result = defaultAcceptsDrop(2, 0, "below");
45
+ expect(result).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe("validateDropIndicator", () => {
50
+ const mockAcceptsDrop = (
51
+ sourceIndex: number,
52
+ destinationIndex: number,
53
+ position: "above" | "below" | "inside"
54
+ ) => {
55
+ // Mock that only allows dropping inside
56
+ if (position === "inside") return true;
57
+ return false;
58
+ };
59
+
60
+ const mockAcceptsDropAboveBelow = (
61
+ sourceIndex: number,
62
+ destinationIndex: number,
63
+ position: "above" | "below" | "inside"
64
+ ) => {
65
+ // Mock that only allows dropping above/below
66
+ if (position === "inside") return false;
67
+ return true;
68
+ };
69
+
70
+ const mockAcceptsAll = (
71
+ sourceIndex: number,
72
+ destinationIndex: number,
73
+ position: "above" | "below" | "inside"
74
+ ) => {
75
+ // Mock that allows all drop positions
76
+ return true;
77
+ };
78
+
79
+ const mockAcceptsAboveAndInside = (
80
+ sourceIndex: number,
81
+ destinationIndex: number,
82
+ position: "above" | "below" | "inside"
83
+ ) => {
84
+ // Mock that allows dropping above and inside, but not below
85
+ return position !== "below";
86
+ };
87
+
88
+ const keys = ["item1", "item2", "item3"];
89
+
90
+ describe("with above and inside positions allowed, but not below", () => {
91
+ it("should return 'inside' when cursor is in the middle third", () => {
92
+ const result = validateDropIndicator({
93
+ acceptsDrop: mockAcceptsAboveAndInside,
94
+ keys,
95
+ activeId: "item1",
96
+ overId: "item2",
97
+ offsetStart: 50, // offsetStart in middle third
98
+ elementStart: 0, // elementStart
99
+ elementSize: 100, // elementSize
100
+ });
101
+ expect(result).toBe("inside");
102
+ });
103
+
104
+ it("should return 'above' when cursor is in the top third", () => {
105
+ const result = validateDropIndicator({
106
+ acceptsDrop: mockAcceptsAboveAndInside,
107
+ keys,
108
+ activeId: "item1",
109
+ overId: "item2",
110
+ offsetStart: 20, // offsetStart in top third
111
+ elementStart: 0, // elementStart
112
+ elementSize: 100, // elementSize
113
+ });
114
+ expect(result).toBe("above");
115
+ });
116
+
117
+ it("should fallback to 'inside' when cursor is in bottom third since 'below' is not allowed", () => {
118
+ const result = validateDropIndicator({
119
+ acceptsDrop: mockAcceptsAboveAndInside,
120
+ keys,
121
+ activeId: "item1",
122
+ overId: "item2",
123
+ offsetStart: 80, // offsetStart in bottom third
124
+ elementStart: 0, // elementStart
125
+ elementSize: 100, // elementSize
126
+ });
127
+ expect(result).toBe("inside");
128
+ });
129
+ });
130
+
131
+ describe("with all drop positions allowed", () => {
132
+ it("should prefer 'inside' when cursor is in the middle third", () => {
133
+ const result = validateDropIndicator({
134
+ acceptsDrop: mockAcceptsAll,
135
+ keys,
136
+ activeId: "item1",
137
+ overId: "item2",
138
+ offsetStart: 50, // offsetStart in middle third
139
+ elementStart: 0, // elementStart
140
+ elementSize: 100, // elementSize
141
+ });
142
+ expect(result).toBe("inside");
143
+ });
144
+
145
+ it("should prefer 'above' when cursor is in the top third", () => {
146
+ const result = validateDropIndicator({
147
+ acceptsDrop: mockAcceptsAll,
148
+ keys,
149
+ activeId: "item1",
150
+ overId: "item2",
151
+ offsetStart: 20, // offsetStart in top third
152
+ elementStart: 0, // elementStart
153
+ elementSize: 100, // elementSize
154
+ });
155
+ expect(result).toBe("above");
156
+ });
157
+
158
+ it("should prefer 'below' when cursor is in the bottom third", () => {
159
+ const result = validateDropIndicator({
160
+ acceptsDrop: mockAcceptsAll,
161
+ keys,
162
+ activeId: "item1",
163
+ overId: "item2",
164
+ offsetStart: 80, // offsetStart in bottom third
165
+ elementStart: 0, // elementStart
166
+ elementSize: 100, // elementSize
167
+ });
168
+ expect(result).toBe("below");
169
+ });
170
+
171
+ it("should handle non-zero elementStart position", () => {
172
+ const result = validateDropIndicator({
173
+ acceptsDrop: mockAcceptsAll,
174
+ keys,
175
+ activeId: "item1",
176
+ overId: "item2",
177
+ offsetStart: 120, // offsetStart in middle third relative to elementStart
178
+ elementStart: 100, // elementStart
179
+ elementSize: 60, // elementSize
180
+ });
181
+ expect(result).toBe("inside");
182
+ });
183
+ });
184
+
185
+ it("should return 'inside' when cursor is in the middle third and inside drops are allowed", () => {
186
+ const result = validateDropIndicator({
187
+ acceptsDrop: mockAcceptsDrop,
188
+ keys,
189
+ activeId: "item1",
190
+ overId: "item2",
191
+ offsetStart: 50, // offsetStart in middle third
192
+ elementStart: 0, // elementStart
193
+ elementSize: 100, // elementSize
194
+ });
195
+ expect(result).toBe("inside");
196
+ });
197
+
198
+ it("should return 'above' when cursor is in top half and above drops are allowed", () => {
199
+ const result = validateDropIndicator({
200
+ acceptsDrop: mockAcceptsDropAboveBelow,
201
+ keys,
202
+ activeId: "item1",
203
+ overId: "item2",
204
+ offsetStart: 20, // offsetStart in top half
205
+ elementStart: 0, // elementStart
206
+ elementSize: 100, // elementSize
207
+ });
208
+ expect(result).toBe("above");
209
+ });
210
+
211
+ it("should return 'below' when cursor is in bottom half and below drops are allowed", () => {
212
+ const result = validateDropIndicator({
213
+ acceptsDrop: mockAcceptsDropAboveBelow,
214
+ keys,
215
+ activeId: "item1",
216
+ overId: "item2",
217
+ offsetStart: 70, // offsetStart in bottom half
218
+ elementStart: 0, // elementStart
219
+ elementSize: 100, // elementSize
220
+ });
221
+ expect(result).toBe("below");
222
+ });
223
+
224
+ it("should fallback to inside when above/below not allowed and inside is allowed", () => {
225
+ const result = validateDropIndicator({
226
+ acceptsDrop: mockAcceptsDrop,
227
+ keys,
228
+ activeId: "item1",
229
+ overId: "item2",
230
+ offsetStart: 20, // offsetStart in top half
231
+ elementStart: 0, // elementStart
232
+ elementSize: 100, // elementSize
233
+ });
234
+ expect(result).toBe("inside");
235
+ });
236
+
237
+ it("should return undefined when no drop positions are allowed", () => {
238
+ const neverAllowDrop = () => false;
239
+ const result = validateDropIndicator({
240
+ acceptsDrop: neverAllowDrop,
241
+ keys,
242
+ activeId: "item1",
243
+ overId: "item2",
244
+ offsetStart: 50,
245
+ elementStart: 0,
246
+ elementSize: 100,
247
+ });
248
+ expect(result).toBeUndefined();
249
+ });
250
+
251
+ it("should ignore items not in the keys array", () => {
252
+ const result = validateDropIndicator({
253
+ acceptsDrop: mockAcceptsDrop,
254
+ keys,
255
+ activeId: "nonexistent",
256
+ overId: "item2",
257
+ offsetStart: 50,
258
+ elementStart: 0,
259
+ elementSize: 100,
260
+ });
261
+ expect(result).toBeUndefined();
262
+ });
263
+ });
@@ -0,0 +1,40 @@
1
+ import { memoGeneric } from "@noya-app/react-utils";
2
+ import React, { useMemo } from "react";
3
+ import { cssVars } from "../theme";
4
+ import { DropdownMenu } from "./DropdownMenu";
5
+ import { IconButton } from "./IconButton";
6
+ import { MenuItem } from "./internal/Menu";
7
+
8
+ type ActionMenuProps<TMenu extends string> = {
9
+ menuItems: MenuItem<TMenu>[];
10
+ onSelect: (action: TMenu) => void;
11
+ selected: boolean;
12
+ onOpenChange: (open: boolean) => void;
13
+ };
14
+
15
+ export const ActionMenu = memoGeneric(function ActionMenu<
16
+ TMenu extends string,
17
+ >({ menuItems, onSelect, selected, onOpenChange }: ActionMenuProps<TMenu>) {
18
+ const style = useMemo(() => {
19
+ return {
20
+ backgroundColor: selected
21
+ ? cssVars.colors.primaryPastel
22
+ : cssVars.colors.inputBackground,
23
+ padding: "4px",
24
+ };
25
+ }, [selected]);
26
+
27
+ return (
28
+ <DropdownMenu
29
+ items={menuItems}
30
+ onSelect={onSelect}
31
+ onOpenChange={onOpenChange}
32
+ >
33
+ <IconButton
34
+ style={style}
35
+ iconName="DotsVerticalIcon"
36
+ color={selected ? cssVars.colors.primary : undefined}
37
+ />
38
+ </DropdownMenu>
39
+ );
40
+ });
@@ -1,10 +1,12 @@
1
1
  import * as React from "react";
2
+ import { cx } from "../utils/classNames";
2
3
 
3
4
  interface Props {
4
5
  size?: number;
5
6
  opacity?: number;
6
7
  color?: string;
7
8
  trackColor?: string;
9
+ className?: string;
8
10
  }
9
11
 
10
12
  export const ActivityIndicator = React.memo(function ActivityIndicator({
@@ -12,6 +14,7 @@ export const ActivityIndicator = React.memo(function ActivityIndicator({
12
14
  opacity = 1,
13
15
  color = "black",
14
16
  trackColor = "rgba(0, 0, 0, 0.1)",
17
+ className,
15
18
  }: Props) {
16
19
  const opacityStyle = React.useMemo(() => ({ opacity }), [opacity]);
17
20
  const dynamicStyles = React.useMemo(
@@ -24,7 +27,10 @@ export const ActivityIndicator = React.memo(function ActivityIndicator({
24
27
  [size, color, trackColor]
25
28
  );
26
29
  return (
27
- <div className="flex justify-center items-center" style={opacityStyle}>
30
+ <div
31
+ className={cx("flex justify-center items-center", className)}
32
+ style={opacityStyle}
33
+ >
28
34
  <div style={dynamicStyles} className="animate-spin rounded-[50%]" />
29
35
  </div>
30
36
  );
@@ -11,6 +11,11 @@ export type CollectionRef = {
11
11
  editName: (id: string) => void;
12
12
  };
13
13
 
14
+ export type CollectionThumbnailProps<T> = {
15
+ item: T;
16
+ selected: boolean;
17
+ };
18
+
14
19
  export interface CollectionProps<T, M extends string = string> {
15
20
  className?: string;
16
21
  items: T[];
@@ -30,8 +35,11 @@ export interface CollectionProps<T, M extends string = string> {
30
35
  menuItems?: MenuItem<M>[];
31
36
  onSelectMenuItem?: (action: M, selectedItems: T[]) => void;
32
37
  onRename?: (item: T, newName: string) => void;
33
- renderThumbnail?: (item: T, selected: boolean) => React.ReactNode;
34
- renderAction?: (item: T, selected: boolean) => React.ReactNode;
38
+ renderThumbnail?: ({
39
+ item,
40
+ selected,
41
+ }: CollectionThumbnailProps<T>) => React.ReactNode;
42
+ renderAction?: "menu" | ((item: T, selected: boolean) => React.ReactNode);
35
43
  renderDetail?: (item: T, selected: boolean) => React.ReactNode;
36
44
  /** Callback when selection changes. If not provided, selection will be disabled. */
37
45
  onSelectionChange?: (
@@ -57,7 +65,9 @@ export interface CollectionProps<T, M extends string = string> {
57
65
  /** Currently selected file IDs */
58
66
  selectedIds?: string[];
59
67
  viewType?: CollectionViewType;
68
+ onClickItem?: (itemId: string) => void;
60
69
  onDoubleClickItem?: (itemId: string) => void;
70
+ renderEmptyState?: () => React.ReactElement;
61
71
  }
62
72
 
63
73
  export const Collection = forwardRefGeneric(function Collection<
@@ -27,6 +27,10 @@ import {
27
27
  import { ListView } from "./ListView";
28
28
  import { Small } from "./Text";
29
29
 
30
+ const comboboxButtonStyle = {
31
+ right: "2px",
32
+ };
33
+
30
34
  export type BaseComboboxProps<T extends string> = {
31
35
  id?: string;
32
36
  loading?: boolean;
@@ -441,6 +445,10 @@ export const Combobox = memoGeneric(
441
445
  [filteredItems]
442
446
  );
443
447
 
448
+ const hasCheckedItems = items.some(
449
+ (item) => isSelectableMenuItem(item) && item.checked
450
+ );
451
+
444
452
  return (
445
453
  <InputField.Root
446
454
  id={id}
@@ -475,6 +483,7 @@ export const Combobox = memoGeneric(
475
483
  style={{
476
484
  flex: `0 0 ${height}px`,
477
485
  }}
486
+ indented={hasCheckedItems}
478
487
  />
479
488
  ) : (
480
489
  <div className="flex flex-col h-[50px] p-5 items-center justify-center">
@@ -525,7 +534,11 @@ export const Combobox = memoGeneric(
525
534
  </InputField.Label>
526
535
  )}
527
536
  {!readOnly && (
528
- <InputField.Button variant="floating" onClick={handleChevronClick}>
537
+ <InputField.Button
538
+ variant="floating"
539
+ onClick={handleChevronClick}
540
+ style={comboboxButtonStyle}
541
+ >
529
542
  <DropdownChevronIcon
530
543
  className={cx(
531
544
  "transition-transform duration-100",
@@ -9,6 +9,7 @@ import { renderIcon } from "./Icons";
9
9
  import { IVirtualizedList, ListView } from "./ListView";
10
10
  import { Spacer } from "./Spacer";
11
11
  import {
12
+ CHECKBOX_INDENT_WIDTH,
12
13
  CHECKBOX_RIGHT_INSET,
13
14
  CHECKBOX_WIDTH,
14
15
  KeyboardShortcut,
@@ -23,6 +24,7 @@ interface ComboboxMenuProps<T extends string> {
23
24
  onHoverIndex: (index: number) => void;
24
25
  listSize: Size;
25
26
  style?: React.CSSProperties;
27
+ indented?: boolean;
26
28
  }
27
29
 
28
30
  export const ComboboxMenu = memoGeneric(
@@ -34,13 +36,10 @@ export const ComboboxMenu = memoGeneric(
34
36
  onHoverIndex,
35
37
  listSize,
36
38
  style,
39
+ indented,
37
40
  }: ComboboxMenuProps<T>,
38
41
  forwardedRef: ForwardedRef<IVirtualizedList>
39
42
  ) {
40
- const hasCheckedItems = items
41
- .filter((item) => item.type === undefined)
42
- .some((item) => item.checked);
43
-
44
43
  return (
45
44
  <ListView.Root
46
45
  ref={forwardedRef}
@@ -59,6 +58,15 @@ export const ComboboxMenu = memoGeneric(
59
58
  if (item.type === "sectionHeader") {
60
59
  return (
61
60
  <ListView.Row key={item.id} isSectionHeader>
61
+ {indented && (
62
+ <Spacer.Horizontal
63
+ size={
64
+ CHECKBOX_WIDTH -
65
+ CHECKBOX_RIGHT_INSET +
66
+ CHECKBOX_INDENT_WIDTH
67
+ }
68
+ />
69
+ )}
62
70
  {item.title}
63
71
  </ListView.Row>
64
72
  );
@@ -82,11 +90,12 @@ export const ComboboxMenu = memoGeneric(
82
90
  }}
83
91
  >
84
92
  <div className="flex flex-1 min-w-0 items-center">
93
+ {indented && <Spacer.Horizontal size={CHECKBOX_INDENT_WIDTH} />}
85
94
  {item.checked ? (
86
95
  <div className={styles.itemIndicatorStyle}>
87
96
  <CheckIcon />
88
97
  </div>
89
- ) : hasCheckedItems ? (
98
+ ) : indented ? (
90
99
  <Spacer.Horizontal
91
100
  size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET}
92
101
  />
@@ -25,22 +25,27 @@ const StyledOverlay = forwardRef<
25
25
  const StyledContent = forwardRef<
26
26
  React.ElementRef<typeof DialogPrimitive.Content>,
27
27
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
28
- >(({ className, ...props }, ref) => (
29
- <DialogPrimitive.Content
30
- ref={ref}
31
- className={cx(
32
- `
28
+ >(({ className, children, ...props }, ref) => {
29
+ return (
30
+ <DialogPrimitive.Content
31
+ ref={ref}
32
+ className={cx(
33
+ `
33
34
  fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
34
35
  w-[90vw] max-w-[450px] max-h-[85vh] p-dialog-padding rounded-[2px]
35
36
  font-sans text-heading5 font-normal leading-[19px] text-text-muted
36
- bg-popover-background overflow-y-auto pointer-events-all z-[1000] focus:outline-none
37
- shadow-[0_10px_38px_-10px_hsla(206,22%,7%,.35),0_10px_20px_-15px_hsla(206,22%,7%,.2)]
37
+ bg-popover-background pointer-events-all z-[1000] focus:outline-none
38
+ shadow-[0_10px_38px_-10px_hsla(206,22%,7%,.35),0_10px_20px_-15px_hsla(206,22%,7%,.2)]
39
+ flex flex-col
38
40
  `,
39
- className
40
- )}
41
- {...props}
42
- />
43
- ));
41
+ className
42
+ )}
43
+ {...props}
44
+ >
45
+ {children}
46
+ </DialogPrimitive.Content>
47
+ );
48
+ });
44
49
 
45
50
  const StyledTitle = forwardRef<
46
51
  React.ElementRef<typeof DialogPrimitive.Title>,
@@ -70,20 +75,6 @@ const StyledDescription = forwardRef<
70
75
  />
71
76
  ));
72
77
 
73
- const CloseButtonContainer = forwardRef<
74
- HTMLDivElement,
75
- React.HTMLAttributes<HTMLDivElement>
76
- >(({ className, ...props }, ref) => (
77
- <div
78
- ref={ref}
79
- className={cx(
80
- `absolute top-dialog-padding right-dialog-padding z-[1] bg-popover-background p-[4px_6px] rounded-[2px] border border-[rgba(128,128,128,0.2)] `,
81
- className
82
- )}
83
- {...props}
84
- />
85
- ));
86
-
87
78
  export interface IDialog {
88
79
  containsElement: (element: HTMLElement) => boolean;
89
80
  }
@@ -141,11 +132,17 @@ export const Dialog = forwardRef(function Dialog(
141
132
  })}
142
133
  >
143
134
  {showCloseButton && (
144
- <CloseButtonContainer>
145
- <DialogPrimitive.Close asChild>
146
- <IconButton iconName="Cross1Icon" />
147
- </DialogPrimitive.Close>
148
- </CloseButtonContainer>
135
+ <DialogPrimitive.Close asChild>
136
+ <IconButton
137
+ iconName="Cross1Icon"
138
+ className="z-[1]"
139
+ style={{
140
+ position: "absolute",
141
+ top: "var(--dialog-padding)",
142
+ right: "var(--dialog-padding)",
143
+ }}
144
+ />
145
+ </DialogPrimitive.Close>
149
146
  )}
150
147
  {title && (
151
148
  <>
@@ -0,0 +1,98 @@
1
+ import { useControlledOrUncontrolled } from "@noya-app/react-utils";
2
+ import * as Dialog from "@radix-ui/react-dialog";
3
+ import * as React from "react";
4
+ import { useImperativeHandle } from "react";
5
+ import { cx } from "../utils/classNames";
6
+ import { SidebarRef } from "./workspace/types";
7
+
8
+ export type DrawerProps = {
9
+ open?: boolean;
10
+ onOpenChange?: (open: boolean) => void;
11
+ trigger: React.ReactNode;
12
+ children: React.ReactNode;
13
+ title?: React.ReactNode;
14
+ className?: string;
15
+ id?: string;
16
+ style?: React.CSSProperties;
17
+ positioning?: "fixed" | "absolute";
18
+ side?: "left" | "right";
19
+ portalled?: boolean;
20
+ portalContainer?: HTMLElement | null;
21
+ };
22
+
23
+ export const Drawer = React.memo(
24
+ React.forwardRef(function Drawer(
25
+ {
26
+ open,
27
+ onOpenChange,
28
+ trigger,
29
+ children,
30
+ title,
31
+ className,
32
+ id,
33
+ style,
34
+ positioning = "fixed",
35
+ side = "left",
36
+ portalled = true,
37
+ portalContainer,
38
+ }: DrawerProps,
39
+ forwardedRef: React.ForwardedRef<SidebarRef>
40
+ ) {
41
+ const [internalOpen, setInternalOpen] = useControlledOrUncontrolled({
42
+ defaultValue: false,
43
+ value: open,
44
+ onChange: onOpenChange,
45
+ });
46
+
47
+ useImperativeHandle(forwardedRef, () => ({
48
+ expand: () => {
49
+ setInternalOpen(true);
50
+ },
51
+ collapse: () => {
52
+ setInternalOpen(false);
53
+ },
54
+ isExpanded: () => {
55
+ return internalOpen;
56
+ },
57
+ }));
58
+
59
+ const inner = (
60
+ <>
61
+ <Dialog.Overlay
62
+ className={cx(
63
+ "inset-0 bg-black/50",
64
+ positioning === "absolute" ? "absolute" : "fixed"
65
+ )}
66
+ />
67
+ <Dialog.Content
68
+ id={id}
69
+ style={style}
70
+ className={cx(
71
+ `top-0 bottom-0 z-50 overflow-y-auto bg-sidebar-background rounded-none max-h-none max-w-none sm:max-w-sm w-full border-divider-strong`,
72
+ positioning === "absolute" ? "absolute" : "fixed",
73
+ side === "left" ? "left-0 border-r" : "right-0 border-l",
74
+ className
75
+ )}
76
+ >
77
+ {title && <Dialog.Title>{title}</Dialog.Title>}
78
+ {children}
79
+ </Dialog.Content>
80
+ </>
81
+ );
82
+
83
+ return (
84
+ <Dialog.Root
85
+ key="dialog"
86
+ open={internalOpen}
87
+ onOpenChange={setInternalOpen}
88
+ >
89
+ <Dialog.Trigger key="trigger">{trigger}</Dialog.Trigger>
90
+ {portalled ? (
91
+ <Dialog.Portal container={portalContainer}>{inner}</Dialog.Portal>
92
+ ) : (
93
+ inner
94
+ )}
95
+ </Dialog.Root>
96
+ );
97
+ })
98
+ );