@noya-app/noya-designsystem 0.1.45 → 0.1.46

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.
@@ -0,0 +1,243 @@
1
+ import React, { useImperativeHandle, useState } from "react";
2
+ import {
3
+ forwardRefGeneric,
4
+ memoGeneric,
5
+ } from "../../../noya-react-utils/src/utils/reactGenerics";
6
+ import { cx } from "../utils/classNames";
7
+ import { updateSelection } from "../utils/selection";
8
+ import { CollectionProps, CollectionRef } from "./Collection";
9
+ import { EditableText } from "./EditableText";
10
+ import { GridView } from "./GridView";
11
+ import { ListView } from "./ListView";
12
+
13
+ export type GridViewSize = "xxs" | "xs" | "small" | "medium" | "large" | "xl";
14
+
15
+ export const getGridSize = (size: GridViewSize) => {
16
+ // Define size ranges that allow items to grow/shrink within bounds
17
+ const sizes: Record<
18
+ Exclude<GridViewSize, number>,
19
+ {
20
+ minColumnWidth: string;
21
+ gap: number;
22
+ }
23
+ > = {
24
+ xxs: {
25
+ minColumnWidth: "36px",
26
+ gap: 5,
27
+ },
28
+ xs: {
29
+ minColumnWidth: "87px",
30
+ gap: 9,
31
+ },
32
+ small: {
33
+ minColumnWidth: "160px",
34
+ gap: 20,
35
+ },
36
+ medium: {
37
+ minColumnWidth: "220px",
38
+ gap: 20,
39
+ },
40
+ large: {
41
+ minColumnWidth: "280px",
42
+ gap: 24,
43
+ },
44
+ xl: {
45
+ minColumnWidth: "400px",
46
+ gap: 32,
47
+ },
48
+ };
49
+
50
+ return sizes[size];
51
+ };
52
+
53
+ const emptyArray: string[] = [];
54
+
55
+ export const Grid = memoGeneric(
56
+ forwardRefGeneric(function Grid<T, M extends string = string>(
57
+ props: CollectionProps<T, M> & { ref?: React.ForwardedRef<CollectionRef> },
58
+ ref: React.ForwardedRef<CollectionRef>
59
+ ) {
60
+ const {
61
+ className,
62
+ items,
63
+ getId,
64
+ getName,
65
+ // expandable,
66
+ // getExpanded,
67
+ getRenamable,
68
+ // getDepth,
69
+ // setExpanded,
70
+ menuItems,
71
+ onSelectMenuItem,
72
+ onRename,
73
+ renderThumbnail,
74
+ renderAction,
75
+ renderDetail,
76
+ onSelectionChange,
77
+ selectedIds = emptyArray,
78
+ // itemRoleDescription = "clickable item",
79
+ // detailPosition = "end",
80
+ size = "medium",
81
+ testHoveredId,
82
+ testRenamingId,
83
+ scrollable = false,
84
+ // acceptsDrop,
85
+ // onMoveItem,
86
+ // onFilesDrop,
87
+ renamable,
88
+ onDoubleClickItem,
89
+ } = props;
90
+
91
+ const [hoveredId, setHoveredId] = useState<string>();
92
+ const [internalRenamingId, setRenamingId] = useState<string>();
93
+
94
+ useImperativeHandle(ref, () => ({
95
+ editName: (id: string) => {
96
+ setRenamingId(id);
97
+ },
98
+ }));
99
+
100
+ const currentHoveredId = testHoveredId ?? hoveredId;
101
+ const renamingId = testRenamingId ?? internalRenamingId;
102
+
103
+ const handleSelect = (itemId: string, event?: ListView.ClickInfo) => {
104
+ if (!onSelectionChange) return;
105
+
106
+ setRenamingId(undefined);
107
+ const newSelectedIds = updateSelection(
108
+ items.map(getId),
109
+ selectedIds,
110
+ itemId,
111
+ event
112
+ );
113
+ onSelectionChange(newSelectedIds, event);
114
+ };
115
+
116
+ const handleMenuAction = (action: M) => {
117
+ if (!onSelectMenuItem) return;
118
+
119
+ const selectedItems = items.filter((item) =>
120
+ selectedIds.includes(getId(item))
121
+ );
122
+ onSelectMenuItem(action, selectedItems);
123
+ };
124
+
125
+ // const fileDropTargetRef = useRef<HTMLDivElement>(null);
126
+ // const { isDropTargetActive, dropTargetProps } = useFileDropTarget(
127
+ // fileDropTargetRef,
128
+ // onFilesDrop
129
+ // );
130
+
131
+ const { minColumnWidth, gap } = getGridSize(size);
132
+
133
+ return (
134
+ <GridView.Root
135
+ minColumnWidth={minColumnWidth}
136
+ scrollable={scrollable}
137
+ className={className}
138
+ gap={gap}
139
+ >
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>
196
+ }
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);
205
+ }
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
+ });
222
+ }
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>
240
+ </GridView.Root>
241
+ );
242
+ })
243
+ );
@@ -1,5 +1,5 @@
1
1
  import { KeyModifiers } from "@noya-app/noya-keymap";
2
- import { forwardRefGeneric } from "@noya-app/react-utils";
2
+ import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
3
3
  import React, {
4
4
  createContext,
5
5
  CSSProperties,
@@ -20,48 +20,6 @@ import { ScrollArea } from "./ScrollArea";
20
20
  import { Spacer } from "./Spacer";
21
21
  import { Tooltip } from "./Tooltip";
22
22
 
23
- export type GridViewSize = "xxs" | "xs" | "small" | "medium" | "large" | "xl";
24
-
25
- const sizes: Record<
26
- GridViewSize,
27
- {
28
- itemWidth: number;
29
- itemHeight: number;
30
- gap: number;
31
- }
32
- > = {
33
- xxs: {
34
- itemWidth: 36,
35
- itemHeight: 36,
36
- gap: 5,
37
- },
38
- xs: {
39
- itemWidth: 87,
40
- itemHeight: 87,
41
- gap: 9,
42
- },
43
- small: {
44
- itemWidth: 160,
45
- itemHeight: 170,
46
- gap: 20,
47
- },
48
- medium: {
49
- itemWidth: 220,
50
- itemHeight: 220,
51
- gap: 16,
52
- },
53
- large: {
54
- itemWidth: 280,
55
- itemHeight: 280,
56
- gap: 24,
57
- },
58
- xl: {
59
- itemWidth: 400,
60
- itemHeight: 400,
61
- gap: 32,
62
- },
63
- };
64
-
65
23
  const ItemTitle = ({
66
24
  showBackground,
67
25
  children,
@@ -71,10 +29,11 @@ const ItemTitle = ({
71
29
  }) => {
72
30
  return (
73
31
  <span
74
- className={`font-sans text-heading5 leading-[1] text-text font-medium select-none whitespace-pre overflow-hidden text-ellipsis ${
32
+ className={cx(
33
+ "font-sans text-heading5 text-text-muted font-medium",
75
34
  showBackground &&
76
- "bg-sidebar-background border border-dividerSubtle rounded-[2px] backdrop-blur-[4px] p-[2px_4px]"
77
- }`}
35
+ "bg-sidebar-background border border-divider-subtle rounded-[2px] backdrop-blur-[4px] p-[2px_4px]"
36
+ )}
78
37
  >
79
38
  {children}
80
39
  </span>
@@ -90,10 +49,11 @@ const ItemDescription = ({
90
49
  }) => {
91
50
  return (
92
51
  <span
93
- className={`font-sans text-heading5 font-normal leading-[1] text-[0.7rem] text-text-muted select-none whitespace-pre overflow-hidden text-ellipsis ${
52
+ className={cx(
53
+ "font-sans text-sm text-text-muted select-none truncate",
94
54
  showBackground &&
95
- "bg-sidebar-background border border-dividerSubtle rounded-[2px] backdrop-blur-[4px] p-[2px_4px]"
96
- }`}
55
+ "bg-sidebar-background border border-divider-subtle rounded-[2px] backdrop-blur-[4px] p-[2px_4px]"
56
+ )}
97
57
  >
98
58
  {children}
99
59
  </span>
@@ -109,7 +69,7 @@ const SectionTitle = ({
109
69
  }) => {
110
70
  return (
111
71
  <span
112
- className={`font-sans text-heading3 font-medium user-select-none whitespace-pre overflow-hidden text-ellipsis ${last ? "text-text" : "text-text-muted"}`}
72
+ className={`font-sans text-heading3 font-medium user-select-none truncate ${last ? "text-text" : "text-text-muted"}`}
113
73
  >
114
74
  {children}
115
75
  </span>
@@ -123,14 +83,19 @@ interface ItemProps<MenuItemType extends string = string> {
123
83
  loading?: boolean;
124
84
  selected?: boolean;
125
85
  onClick?: (event: React.MouseEvent) => void;
126
- onPress?: (options: KeyModifiers) => void;
86
+ onSelect?: (options: KeyModifiers) => void;
127
87
  onDoubleClick?: () => void;
128
88
  onHoverChange?: (isHovering: boolean) => void;
129
89
  children?: ReactNode;
130
90
  menuItems?: MenuItem<MenuItemType>[];
131
91
  onSelectMenuItem?: (value: MenuItemType) => void;
132
92
  onContextMenu?: () => void;
93
+ onMenuOpenChange?: (open: boolean) => void;
133
94
  style?: CSSProperties;
95
+ action?: ReactNode;
96
+ testHoveredId?: string;
97
+ onKeyDown?: (event: React.KeyboardEvent) => void;
98
+ hovered?: boolean;
134
99
  }
135
100
 
136
101
  const GridViewItem = forwardRefGeneric(function GridViewItem<
@@ -143,26 +108,28 @@ const GridViewItem = forwardRefGeneric(function GridViewItem<
143
108
  loading,
144
109
  selected,
145
110
  onClick,
146
- onPress,
111
+ onSelect,
147
112
  onDoubleClick,
148
113
  onHoverChange,
149
114
  children,
150
115
  menuItems,
151
116
  onSelectMenuItem,
152
117
  onContextMenu,
118
+ onMenuOpenChange,
153
119
  style,
120
+ action,
121
+ testHoveredId,
122
+ onKeyDown,
123
+ hovered = false,
154
124
  }: ItemProps<MenuItemType>,
155
125
  forwardedRef: ForwardedRef<HTMLDivElement>
156
126
  ) {
157
- const [hovered, setHovered] = React.useState(false);
158
-
159
127
  const { hoverProps } = useHover({
160
- onHoverChange: (isHovering) => {
161
- onHoverChange?.(isHovering);
162
- setHovered(isHovering);
163
- },
128
+ onHoverChange,
164
129
  });
165
130
 
131
+ const isHovered = testHoveredId ? testHoveredId === id : hovered;
132
+
166
133
  const { textPosition, bordered, disabled } = useContext(GridViewContext);
167
134
 
168
135
  const handleClick = useCallback(
@@ -171,28 +138,33 @@ const GridViewItem = forwardRefGeneric(function GridViewItem<
171
138
  event.preventDefault();
172
139
 
173
140
  onClick?.(event);
174
- onPress?.(event);
141
+ onSelect?.(event);
175
142
  },
176
- [onClick, onPress]
143
+ [onClick, onSelect]
177
144
  );
178
145
 
179
146
  const handleKeyDown = useCallback(
180
147
  (event: React.KeyboardEvent) => {
148
+ onKeyDown?.(event);
181
149
  if (event.key === "Enter" || event.key === " ") {
182
150
  event.stopPropagation();
183
151
  event.preventDefault();
184
152
 
185
- onPress?.(event);
153
+ onSelect?.(event);
186
154
  } else {
187
155
  return;
188
156
  }
189
157
  },
190
- [onPress]
158
+ [onSelect, onKeyDown]
191
159
  );
192
160
 
193
161
  let element = (
194
162
  <div
195
- className="flex flex-col relative focus:shadow-[0px_0px_0px_1px_var(--sidebar-background),_0px_0px_0px_3px_var(--primary)] focus:outline-none"
163
+ className={cx(
164
+ "flex flex-col relative focus:shadow-[0px_0px_0px_1px_var(--sidebar-background),_0px_0px_0px_3px_var(--primary)] focus:outline-none rounded p-2 -m-2",
165
+ selected && "bg-primary-pastel",
166
+ isHovered && "ring-1 ring-primary ring-inset"
167
+ )}
196
168
  id={id}
197
169
  ref={forwardedRef}
198
170
  {...hoverProps}
@@ -200,18 +172,13 @@ const GridViewItem = forwardRefGeneric(function GridViewItem<
200
172
  onKeyDown={handleKeyDown}
201
173
  >
202
174
  <div
203
- className={`flex flex-1 items-center justify-center
204
- bg-sidebar-background rounded-[2px] overflow-hidden
205
- cursor-pointer
206
- ${
207
- selected
208
- ? "border border-primary"
209
- : bordered
210
- ? "border border-divider"
211
- : "border border-transparent"
212
- }
213
- ${disabled ? "opacity-50" : "hover:opacity-85 active:opacity-70"}
214
- `}
175
+ className={cx(
176
+ "flex items-center justify-center rounded",
177
+ "cursor-pointer",
178
+ bordered ? "border border-divider" : "border border-transparent",
179
+ disabled ? "opacity-50" : "hover:opacity-85 active:opacity-70",
180
+ selected ? "bg-primary-pastel" : "bg-input-background text-text"
181
+ )}
215
182
  onClick={handleClick}
216
183
  onDoubleClick={onDoubleClick}
217
184
  onContextMenu={onContextMenu}
@@ -226,14 +193,19 @@ const GridViewItem = forwardRefGeneric(function GridViewItem<
226
193
  <ItemDescription>{subtitle || " "}</ItemDescription>
227
194
  </>
228
195
  )}
229
- {textPosition === "overlay" && hovered && (title || subtitle) && (
230
- <div className="absolute inset-0 flex flex-col justify-end items-start p-1 pointer-events-none truncate g-0.5">
196
+ {textPosition === "overlay" && isHovered && (title || subtitle) && (
197
+ <div className="absolute inset-0 flex flex-col justify-end items-start p-1 pointer-events-none truncate gap-0.5">
231
198
  {title && <ItemTitle showBackground>{title}</ItemTitle>}
232
199
  {subtitle && (
233
200
  <ItemDescription showBackground>{subtitle}</ItemDescription>
234
201
  )}
235
202
  </div>
236
203
  )}
204
+ {action && (
205
+ <div className="absolute top-[7px] right-[7px] flex flex-col justify-end items-start p-1 truncate gap-0.5">
206
+ {action}
207
+ </div>
208
+ )}
237
209
  {loading && (
238
210
  <div className="flex flex-col justify-start items-end p-1 pointer-events-none animate-shimmer absolute inset-0 bg-gradient-to-r from-white/0 via-[rgb(226,232,240)]/50 to-white/0 bg-length:200%_100%]">
239
211
  <ActivityIndicator opacity={0.5} size={13} />
@@ -244,7 +216,11 @@ const GridViewItem = forwardRefGeneric(function GridViewItem<
244
216
 
245
217
  if (menuItems && onSelectMenuItem) {
246
218
  element = (
247
- <ContextMenu<MenuItemType> items={menuItems} onSelect={onSelectMenuItem}>
219
+ <ContextMenu<MenuItemType>
220
+ items={menuItems}
221
+ onSelect={onSelectMenuItem}
222
+ onOpenChange={onMenuOpenChange}
223
+ >
248
224
  {element}
249
225
  </ContextMenu>
250
226
  );
@@ -269,33 +245,38 @@ const GridViewItem = forwardRefGeneric(function GridViewItem<
269
245
  });
270
246
 
271
247
  type GridViewContextValue = {
272
- size: GridViewSize;
248
+ minColumnWidth: string;
273
249
  textPosition: "overlay" | "below" | "toolip";
274
250
  bordered: boolean;
275
251
  disabled: boolean;
252
+ gap: number;
276
253
  };
277
254
 
278
255
  const GridViewContext = createContext<GridViewContextValue>({
279
- size: "small",
256
+ minColumnWidth: "220px",
280
257
  textPosition: "below",
281
258
  bordered: false,
282
259
  disabled: false,
260
+ gap: 0,
283
261
  });
284
262
 
285
263
  interface GridViewRootProps extends Partial<GridViewContextValue> {
286
264
  children: ReactNode;
287
265
  onClick?: () => void;
288
266
  scrollable?: boolean;
267
+ className?: string;
289
268
  }
290
269
 
291
270
  function GridViewRoot({
292
- size = "small",
271
+ minColumnWidth = "220px",
272
+ gap = 20,
293
273
  children,
294
- scrollable = true,
274
+ scrollable,
295
275
  onClick,
296
276
  textPosition = "below",
297
277
  bordered = false,
298
278
  disabled = false,
279
+ className,
299
280
  }: GridViewRootProps) {
300
281
  const handleClick = useCallback(
301
282
  (event: React.MouseEvent) => {
@@ -309,21 +290,32 @@ function GridViewRoot({
309
290
 
310
291
  const contextValue = useMemo(
311
292
  () => ({
312
- size,
293
+ minColumnWidth,
313
294
  textPosition,
314
295
  bordered,
315
296
  disabled,
297
+ gap,
316
298
  }),
317
- [bordered, disabled, size, textPosition]
299
+ [bordered, disabled, gap, minColumnWidth, textPosition]
318
300
  );
319
301
 
320
302
  return (
321
303
  <GridViewContext.Provider value={contextValue}>
322
304
  <div
323
305
  onClick={handleClick}
324
- className={`flex flex-col ${scrollable ? "flex-1" : "flex-[0_0_auto]"}`}
306
+ className={cx(
307
+ "flex flex-col -mx-3 -mb-1",
308
+ scrollable ? "flex-1" : "flex-[0_0_auto]",
309
+ className
310
+ )}
325
311
  >
326
- {scrollable ? <ScrollArea>{children}</ScrollArea> : children}
312
+ {scrollable ? (
313
+ <ScrollArea>
314
+ <div className="p-3">{children}</div>
315
+ </ScrollArea>
316
+ ) : (
317
+ <div className="p-3">{children}</div>
318
+ )}
327
319
  </div>
328
320
  </GridViewContext.Provider>
329
321
  );
@@ -336,17 +328,15 @@ function GridViewSection({
336
328
  children?: ReactNode;
337
329
  className?: string;
338
330
  }) {
339
- const { size } = useContext(GridViewContext);
340
- const gapStyle = { gap: `${sizes[size].gap}px` };
331
+ const { minColumnWidth, gap } = useContext(GridViewContext);
332
+
333
+ const styles: React.CSSProperties = {
334
+ gridTemplateColumns: `repeat(auto-fill, minmax(${minColumnWidth}, 1fr))`,
335
+ gap: `${gap}px`,
336
+ };
341
337
 
342
338
  return (
343
- <div
344
- className={cx(
345
- `grid-cols-[auto-fill,_minmax(${sizes[size].itemWidth}px,_1fr)] text-text grid `,
346
- className
347
- )}
348
- style={gapStyle}
349
- >
339
+ <div className={cx("grid text-text", className)} style={styles}>
350
340
  {children}
351
341
  </div>
352
342
  );
@@ -372,7 +362,7 @@ function GridViewSectionHeader({ title }: { title: string }) {
372
362
 
373
363
  export namespace GridView {
374
364
  export const Root = memo(GridViewRoot);
375
- export const Item = memo(GridViewItem);
365
+ export const Item = memoGeneric(GridViewItem);
376
366
  export const Section = memo(GridViewSection);
377
367
  export const SectionHeader = memo(GridViewSectionHeader);
378
368
  }