@noya-app/noya-designsystem 0.1.47 → 0.1.49

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 (55) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +18 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +389 -241
  5. package/dist/index.d.ts +389 -241
  6. package/dist/index.js +4802 -3990
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +4715 -3915
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +7 -7
  11. package/src/__tests__/validateDropIndicator.test.ts +263 -0
  12. package/src/components/ActionMenu.tsx +51 -0
  13. package/src/components/ActivityIndicator.tsx +7 -1
  14. package/src/components/BaseToolbar.tsx +2 -2
  15. package/src/components/Checkbox.tsx +2 -2
  16. package/src/components/Chip.tsx +7 -6
  17. package/src/components/Collection.tsx +30 -2
  18. package/src/components/Combobox.tsx +27 -15
  19. package/src/components/ComboboxMenu.tsx +15 -6
  20. package/src/components/Dialog.tsx +17 -12
  21. package/src/components/DimensionInput.tsx +14 -12
  22. package/src/components/Drawer.tsx +98 -0
  23. package/src/components/EditableText.tsx +3 -1
  24. package/src/components/FillInputField.tsx +2 -2
  25. package/src/components/Grid.tsx +161 -111
  26. package/src/components/GridView.tsx +73 -41
  27. package/src/components/InputField.tsx +148 -208
  28. package/src/components/Label.tsx +4 -68
  29. package/src/components/LabeledField.tsx +7 -1
  30. package/src/components/List.tsx +135 -50
  31. package/src/components/ListView.tsx +63 -43
  32. package/src/components/MediaThumbnail.tsx +117 -0
  33. package/src/components/Message.tsx +8 -8
  34. package/src/components/NoyaLogo.tsx +41 -0
  35. package/src/components/SearchCompletionMenu.tsx +30 -16
  36. package/src/components/SegmentedControl.tsx +1 -1
  37. package/src/components/SelectMenu.tsx +28 -21
  38. package/src/components/Slider.tsx +16 -7
  39. package/src/components/Sortable.tsx +125 -62
  40. package/src/components/internal/Menu.tsx +45 -25
  41. package/src/components/internal/MenuViewport.tsx +7 -1
  42. package/src/components/internal/SelectItem.tsx +53 -28
  43. package/src/components/internal/__tests__/Menu.test.tsx +12 -0
  44. package/src/components/workspace/DrawerWorkspaceLayout.tsx +86 -0
  45. package/src/components/workspace/PanelWorkspaceLayout.tsx +102 -0
  46. package/src/components/{WorkspaceLayout.tsx → workspace/WorkspaceLayout.tsx} +109 -106
  47. package/src/components/workspace/types.ts +31 -0
  48. package/src/contexts/DialogContext.tsx +49 -34
  49. package/src/hooks/usePreservePanelSize.tsx +1 -5
  50. package/src/index.css +8 -1
  51. package/src/index.tsx +6 -1
  52. package/src/theme/index.ts +2 -0
  53. package/src/utils/classNames.ts +51 -3
  54. package/src/utils/inputs.ts +21 -0
  55. package/tailwind.config.ts +8 -0
@@ -1,6 +1,7 @@
1
1
  import { round } from "@noya-app/noya-utils";
2
2
  import * as React from "react";
3
3
  import { InputField } from "./InputField";
4
+ import { LabeledField } from "./LabeledField";
4
5
 
5
6
  export type SetNumberMode = "replace" | "adjust";
6
7
 
@@ -50,17 +51,18 @@ export const DimensionInput = React.memo(function DimensionInput({
50
51
  );
51
52
 
52
53
  return (
53
- <InputField.Root id={id} width={size}>
54
- <InputField.NumberInput
55
- value={value === undefined ? value : round(value, 2)}
56
- placeholder={value === undefined ? placeholder : undefined}
57
- onNudge={handleNudgeValue}
58
- disabled={disabled}
59
- {...(trigger === "change"
60
- ? { onChange: handleSetValue }
61
- : { onSubmit: handleSetValue })}
62
- />
63
- {label && <InputField.Label>{label}</InputField.Label>}
64
- </InputField.Root>
54
+ <LabeledField label={label} labelPosition="inset">
55
+ <InputField.Root id={id} width={size}>
56
+ <InputField.NumberInput
57
+ value={value === undefined ? value : round(value, 2)}
58
+ placeholder={value === undefined ? placeholder : undefined}
59
+ onNudge={handleNudgeValue}
60
+ disabled={disabled}
61
+ {...(trigger === "change"
62
+ ? { onChange: handleSetValue }
63
+ : { onSubmit: handleSetValue })}
64
+ />
65
+ </InputField.Root>
66
+ </LabeledField>
65
67
  );
66
68
  });
@@ -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
+ );
@@ -35,6 +35,7 @@ type Props = {
35
35
  textClassName?: string;
36
36
  readOnly?: boolean;
37
37
  onBlur?: () => void;
38
+ tabIndex?: number;
38
39
  /**
39
40
  * If true, will render an unfocused input. This should only be used for visual regression tests.
40
41
  */
@@ -66,6 +67,7 @@ export const EditableText = memo(
66
67
  readOnly,
67
68
  onBlur,
68
69
  testFocused = false,
70
+ tabIndex = 0,
69
71
  ...props
70
72
  }: Props,
71
73
  ref: React.ForwardedRef<EditableTextRef>
@@ -136,7 +138,7 @@ export const EditableText = memo(
136
138
  children: displayValue,
137
139
  onClick: () => setFocused(true),
138
140
  ref: previewRef,
139
- tabIndex: 0,
141
+ tabIndex,
140
142
  onKeyDown: (e: React.KeyboardEvent) => {
141
143
  if (e.key === "Enter" || e.key === " ") {
142
144
  setFocused(true);
@@ -1,8 +1,8 @@
1
1
  import { Sketch } from "@noya-app/noya-file-format";
2
2
  import React, { forwardRef, memo } from "react";
3
+ import { cx } from "../utils/classNames";
3
4
  import { SketchPattern } from "../utils/sketchPattern";
4
5
  import { FillPreviewBackground } from "./FillPreviewBackground";
5
- import { cx } from "../utils/classNames";
6
6
 
7
7
  const FillButton = forwardRef<
8
8
  HTMLButtonElement,
@@ -11,7 +11,7 @@ const FillButton = forwardRef<
11
11
  <button
12
12
  ref={ref}
13
13
  className={cx(
14
- `outline-none p-0 w-[50px] h-[27px] rounded overflow-hidden border-none shadow-[0_0_0_1px_var(--divider)_inset] bg-transparent relative focus:shadow-[0_0_0_1px_var(--sidebar-background),0_0_0_3px_var(--primary)] `,
14
+ `outline-none p-0 w-[50px] h-input-height rounded overflow-hidden border-none shadow-[0_0_0_1px_var(--divider)_inset] bg-transparent relative focus:shadow-[0_0_0_1px_var(--sidebar-background),0_0_0_3px_var(--primary)] `,
15
15
  className
16
16
  )}
17
17
  {...props}
@@ -1,10 +1,12 @@
1
- import React, { useImperativeHandle, useState } from "react";
1
+ import { useFileDropTarget } from "@noya-app/react-utils";
2
+ import React, { useImperativeHandle, useRef, useState } from "react";
2
3
  import {
3
4
  forwardRefGeneric,
4
5
  memoGeneric,
5
6
  } from "../../../noya-react-utils/src/utils/reactGenerics";
6
7
  import { cx } from "../utils/classNames";
7
8
  import { updateSelection } from "../utils/selection";
9
+ import { ActionMenu } from "./ActionMenu";
8
10
  import { CollectionProps, CollectionRef } from "./Collection";
9
11
  import { EditableText } from "./EditableText";
10
12
  import { GridView } from "./GridView";
@@ -35,15 +37,15 @@ export const getGridSize = (size: GridViewSize) => {
35
37
  },
36
38
  medium: {
37
39
  minColumnWidth: "220px",
38
- gap: 20,
40
+ gap: 30,
39
41
  },
40
42
  large: {
41
43
  minColumnWidth: "280px",
42
- gap: 24,
44
+ gap: 40,
43
45
  },
44
46
  xl: {
45
47
  minColumnWidth: "400px",
46
- gap: 32,
48
+ gap: 48,
47
49
  },
48
50
  };
49
51
 
@@ -80,16 +82,21 @@ export const Grid = memoGeneric(
80
82
  size = "medium",
81
83
  testHoveredId,
82
84
  testRenamingId,
85
+ testShowDropIndicatorId,
83
86
  scrollable = false,
84
87
  // acceptsDrop,
85
88
  // onMoveItem,
86
- // onFilesDrop,
89
+ onFilesDrop,
87
90
  renamable,
91
+ onClickItem,
88
92
  onDoubleClickItem,
93
+ renderEmptyState,
94
+ // getDropTargetParentIndex,
89
95
  } = props;
90
96
 
91
97
  const [hoveredId, setHoveredId] = useState<string>();
92
98
  const [internalRenamingId, setRenamingId] = useState<string>();
99
+ const [dropdownOpenId, setDropdownOpenId] = useState<string>();
93
100
 
94
101
  useImperativeHandle(ref, () => ({
95
102
  editName: (id: string) => {
@@ -104,139 +111,182 @@ export const Grid = memoGeneric(
104
111
  if (!onSelectionChange) return;
105
112
 
106
113
  setRenamingId(undefined);
114
+
107
115
  const newSelectedIds = updateSelection(
108
116
  items.map(getId),
109
117
  selectedIds,
110
118
  itemId,
111
119
  event
112
120
  );
121
+
113
122
  onSelectionChange(newSelectedIds, event);
114
123
  };
115
124
 
116
- const handleMenuAction = (action: M) => {
125
+ const handleMenuAction = (action: M, actionItem: T) => {
117
126
  if (!onSelectMenuItem) return;
118
127
 
119
128
  const selectedItems = items.filter((item) =>
120
129
  selectedIds.includes(getId(item))
121
130
  );
122
- onSelectMenuItem(action, selectedItems);
131
+
132
+ onSelectMenuItem(
133
+ action,
134
+ selectedItems.length === 0 ? [actionItem] : selectedItems
135
+ );
123
136
  };
124
137
 
125
- // const fileDropTargetRef = useRef<HTMLDivElement>(null);
126
- // const { isDropTargetActive, dropTargetProps } = useFileDropTarget(
127
- // fileDropTargetRef,
128
- // onFilesDrop
129
- // );
138
+ const fileDropTargetRef = useRef<HTMLDivElement>(null);
139
+ const { isDropTargetActive, dropTargetProps } = useFileDropTarget(
140
+ fileDropTargetRef,
141
+ onFilesDrop
142
+ );
130
143
 
131
144
  const { minColumnWidth, gap } = getGridSize(size);
132
145
 
133
146
  return (
134
147
  <GridView.Root
148
+ role="listbox"
149
+ aria-orientation="horizontal"
150
+ aria-multiselectable={true}
135
151
  minColumnWidth={minColumnWidth}
136
- scrollable={scrollable}
137
- className={className}
152
+ scrollable={items.length > 0 && scrollable}
153
+ contentClassName={cx(items.length === 0 && "flex flex-1")}
138
154
  gap={gap}
155
+ className={cx(isDropTargetActive && "bg-primary-pastel", className)}
156
+ {...dropTargetProps}
157
+ ref={fileDropTargetRef}
139
158
  >
140
- <GridView.Section>
141
- {items.map((item) => {
142
- const id = getId(item);
143
- const isHovered = currentHoveredId === id;
144
- const itemIsRenamable = getRenamable?.(item) ?? renamable;
145
- const isSelected = selectedIds.includes(id);
146
- const isRenaming = itemIsRenamable && onRename && renamingId === id;
147
- const isTestingRenaming = testRenamingId === id;
148
-
149
- const thumbnail = renderThumbnail?.(item, isSelected);
150
- const action =
151
- (isHovered || isSelected) &&
152
- !isRenaming &&
153
- renderAction?.(item, isSelected);
154
-
155
- return (
156
- <GridView.Item<M>
157
- key={id}
158
- id={id}
159
- hovered={isHovered}
160
- onHoverChange={(isHovering) => {
161
- setHoveredId(isHovering ? id : undefined);
162
- }}
163
- title={
164
- <EditableText
165
- value={getName(item)}
166
- focused={isRenaming}
167
- testFocused={isTestingRenaming}
168
- readOnly={!itemIsRenamable || !onRename}
169
- onSubmit={(value) => {
170
- if (value !== getName(item)) {
171
- onRename?.(item, value);
172
- }
173
- setRenamingId(undefined);
174
- }}
175
- onBlur={() => {
176
- setRenamingId(undefined);
177
- }}
178
- // max height ensures preview text and input are one line
179
- textClassName="bg-listview-editing-background max-h-[27px]"
180
- >
181
- {(props) => (
182
- <span
183
- {...props}
184
- className={cx(
185
- isSelected && "text-primary",
186
- "truncate select-none"
187
- )}
188
- />
189
- )}
190
- </EditableText>
191
- }
192
- subtitle={
193
- <span className={cx(isSelected && "text-primary")}>
194
- {renderDetail?.(item, isSelected)}
195
- </span>
159
+ {items.length > 0 ? (
160
+ <GridView.Section>
161
+ {items.map((item) => {
162
+ const id = getId(item);
163
+ const isDropdownOpen = dropdownOpenId === id;
164
+ const isHovered =
165
+ testHoveredId === id ||
166
+ currentHoveredId === id ||
167
+ isDropdownOpen;
168
+ const itemIsRenamable = getRenamable?.(item) ?? renamable;
169
+ const isSelected = selectedIds.includes(id);
170
+ const isRenaming =
171
+ itemIsRenamable && onRename && renamingId === id;
172
+ const isTestingRenaming = testRenamingId === id;
173
+ const thumbnail = renderThumbnail?.({
174
+ item,
175
+ selected: isSelected,
176
+ });
177
+ const onOpenChange = (open: boolean) => {
178
+ setDropdownOpenId(open ? id : undefined);
179
+
180
+ if (open && !isSelected) {
181
+ handleSelect(id);
196
182
  }
197
- loading={false}
198
- selected={isSelected}
199
- onSelect={(event) => {
200
- handleSelect(id, event);
201
- }}
202
- onKeyDown={(event) => {
203
- if (event.key === "Enter") {
204
- setRenamingId(id);
183
+ };
184
+ const action =
185
+ (isHovered || isSelected) &&
186
+ !isRenaming &&
187
+ (typeof renderAction === "function"
188
+ ? renderAction({ item, selected: isSelected, onOpenChange })
189
+ : renderAction === "menu" &&
190
+ menuItems && (
191
+ <ActionMenu
192
+ menuItems={menuItems}
193
+ onSelect={(action) => handleMenuAction(action, item)}
194
+ selected={isSelected}
195
+ onOpenChange={onOpenChange}
196
+ />
197
+ ));
198
+
199
+ return (
200
+ <GridView.Item<M>
201
+ key={id}
202
+ id={id}
203
+ role="option"
204
+ aria-selected={isSelected}
205
+ aria-label={getName(item)}
206
+ hovered={isHovered}
207
+ testShowInsideDropIndicator={testShowDropIndicatorId === id}
208
+ onHoverChange={(isHovering) => {
209
+ setHoveredId(isHovering ? id : undefined);
210
+ }}
211
+ title={
212
+ <EditableText
213
+ tabIndex={isRenaming ? undefined : -1}
214
+ value={getName(item)}
215
+ focused={isRenaming}
216
+ testFocused={isTestingRenaming}
217
+ readOnly={!itemIsRenamable || !onRename}
218
+ onSubmit={(value) => {
219
+ if (value !== getName(item)) {
220
+ onRename?.(item, value);
221
+ }
222
+ setRenamingId(undefined);
223
+ }}
224
+ onBlur={() => {
225
+ setRenamingId(undefined);
226
+ }}
227
+ // max height ensures preview text and input are one line
228
+ textClassName="bg-listview-editing-background max-h-input-height"
229
+ >
230
+ {(props) => (
231
+ <span
232
+ {...props}
233
+ className={cx(
234
+ isSelected && "text-primary",
235
+ "truncate select-none"
236
+ )}
237
+ />
238
+ )}
239
+ </EditableText>
205
240
  }
206
- }}
207
- onDoubleClick={() => {
208
- onDoubleClickItem?.(id);
209
- }}
210
- menuItems={menuItems}
211
- onSelectMenuItem={handleMenuAction}
212
- action={action}
213
- testHoveredId={testHoveredId}
214
- onMenuOpenChange={(open: boolean) => {
215
- if (open && !isSelected) {
216
- handleSelect(id, {
217
- shiftKey: false,
218
- altKey: false,
219
- metaKey: false,
220
- ctrlKey: false,
221
- });
241
+ subtitle={
242
+ <span className={cx(isSelected && "text-primary")}>
243
+ {renderDetail?.(item, isSelected)}
244
+ </span>
222
245
  }
223
- }}
224
- >
225
- {thumbnail && (
226
- <div
227
- className={cx(
228
- "rounded overflow-hidden flex-none flex items-center w-full justify-center aspect-square",
229
- isSelected && "text-primary"
230
- )}
231
- tabIndex={-1}
232
- >
233
- {thumbnail}
234
- </div>
235
- )}
236
- </GridView.Item>
237
- );
238
- })}
239
- </GridView.Section>
246
+ loading={false}
247
+ selected={isSelected}
248
+ onClick={() => {
249
+ onClickItem?.(id);
250
+ }}
251
+ onSelect={(event) => {
252
+ handleSelect(id, event);
253
+ }}
254
+ onKeyDown={(event) => {
255
+ if (event.key === "Enter") {
256
+ setRenamingId(id);
257
+ }
258
+ }}
259
+ onDoubleClick={() => {
260
+ onDoubleClickItem?.(id);
261
+ }}
262
+ menuItems={menuItems}
263
+ onSelectMenuItem={(action) => handleMenuAction(action, item)}
264
+ action={action}
265
+ onMenuOpenChange={(open: boolean) => {
266
+ if (open && !isSelected) {
267
+ handleSelect(id);
268
+ }
269
+ }}
270
+ // getDropTargetParentIndex={getDropTargetParentIndex}
271
+ >
272
+ {thumbnail && (
273
+ <div
274
+ className={cx(
275
+ "rounded overflow-hidden flex-none flex w-full",
276
+ isSelected && "text-primary"
277
+ )}
278
+ tabIndex={-1}
279
+ >
280
+ {thumbnail}
281
+ </div>
282
+ )}
283
+ </GridView.Item>
284
+ );
285
+ })}
286
+ </GridView.Section>
287
+ ) : (
288
+ renderEmptyState?.()
289
+ )}
240
290
  </GridView.Root>
241
291
  );
242
292
  })