@noya-app/noya-designsystem 0.1.45 → 0.1.47

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 (39) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +15 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +1149 -1064
  5. package/dist/index.d.ts +1149 -1064
  6. package/dist/index.js +10161 -9662
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +10465 -9972
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +3 -3
  11. package/src/__tests__/combobox.test.ts +137 -89
  12. package/src/components/Breadcrumbs.tsx +29 -0
  13. package/src/components/Collection.tsx +74 -0
  14. package/src/components/Combobox.tsx +69 -52
  15. package/src/components/ComboboxMenu.tsx +37 -19
  16. package/src/components/Dialog.tsx +11 -19
  17. package/src/components/DropdownMenu.tsx +0 -1
  18. package/src/components/EditableText.tsx +203 -0
  19. package/src/components/Grid.tsx +243 -0
  20. package/src/components/GridView.tsx +86 -96
  21. package/src/components/List.tsx +268 -0
  22. package/src/components/ListView.tsx +10 -9
  23. package/src/components/SearchCompletionMenu.tsx +15 -31
  24. package/src/components/SegmentedControl.tsx +7 -4
  25. package/src/components/SelectMenu.tsx +9 -5
  26. package/src/components/Switch.tsx +1 -1
  27. package/src/components/Toolbar.tsx +5 -4
  28. package/src/components/TreeView.tsx +1 -0
  29. package/src/components/WorkspaceLayout.tsx +28 -4
  30. package/src/components/internal/Menu.tsx +55 -14
  31. package/src/components/internal/MenuViewport.tsx +78 -77
  32. package/src/contexts/DialogContext.tsx +53 -7
  33. package/src/index.css +2 -1
  34. package/src/index.tsx +6 -4
  35. package/src/utils/combobox.ts +115 -103
  36. package/src/utils/createSectionedMenu.ts +3 -9
  37. package/src/utils/fuzzyScorer.ts +11 -8
  38. package/src/utils/selection.ts +16 -4
  39. package/src/components/SidebarList.tsx +0 -252
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -23,9 +23,9 @@
23
23
  "@dnd-kit/core": "3.1.1",
24
24
  "@dnd-kit/modifiers": "3.0.0",
25
25
  "@dnd-kit/sortable": "4.0.0",
26
- "@noya-app/noya-colorpicker": "0.1.16",
26
+ "@noya-app/noya-colorpicker": "0.1.17",
27
27
  "@noya-app/noya-utils": "0.1.3",
28
- "@noya-app/noya-geometry": "0.1.6",
28
+ "@noya-app/noya-geometry": "0.1.7",
29
29
  "@noya-app/noya-icons": "0.1.6",
30
30
  "@noya-app/noya-keymap": "0.1.3",
31
31
  "@radix-ui/primitive": "^1.0.0",
@@ -1,20 +1,24 @@
1
1
  import { expect, test } from "bun:test";
2
- import { ComboboxOption, ComboboxState } from "../utils/combobox";
3
- import { IScoredItem } from "../utils/fuzzyScorer";
4
-
5
- const testItems = [
6
- { id: "1", name: "font-bold" },
7
- { id: "2", name: "font-italic" },
8
- { id: "3", name: "font-regular" },
2
+ import {
3
+ isSelectableMenuItem,
4
+ isSelectableMenuItemWithScore,
5
+ MenuItem,
6
+ } from "../components/internal/Menu";
7
+ import { ComboboxState } from "../utils/combobox";
8
+
9
+ const testItems: MenuItem<string>[] = [
10
+ { value: "1", title: "font-bold" },
11
+ { value: "2", title: "font-italic" },
12
+ { value: "3", title: "font-regular" },
9
13
  {
10
14
  type: "sectionHeader" as const,
11
15
  id: "header1",
12
- name: "Border Styles",
16
+ title: "Border Styles",
13
17
  maxVisibleItems: 2,
14
18
  },
15
- { id: "4", name: "border-solid" },
16
- { id: "5", name: "border-dashed" },
17
- { id: "6", name: "border-dotted" },
19
+ { value: "4", title: "border-solid" },
20
+ { value: "5", title: "border-dashed" },
21
+ { value: "6", title: "border-dotted" },
18
22
  ];
19
23
 
20
24
  test("initializes with default values", () => {
@@ -38,7 +42,8 @@ test("filters items based on query", () => {
38
42
  expect(snapshot.filteredItems).toHaveLength(3);
39
43
  expect(
40
44
  snapshot.filteredItems.every(
41
- (item) => item.type !== "sectionHeader" && item.name.includes("font")
45
+ (item) =>
46
+ isSelectableMenuItem(item) && item.title?.toString().includes("font")
42
47
  )
43
48
  ).toBe(true);
44
49
  });
@@ -53,11 +58,16 @@ test("respects section header maxVisibleItems", () => {
53
58
  // 2. Only 2 items (maxVisibleItems) from that section
54
59
  expect(snapshot.filteredItems).toHaveLength(3);
55
60
  expect(snapshot.filteredItems[0].type).toBe("sectionHeader");
56
- expect(snapshot.filteredItems[0].name).toBe("Border Styles");
61
+ expect(snapshot.filteredItems[0].title).toBe("Border Styles");
57
62
 
58
63
  const items = snapshot.filteredItems.slice(1);
59
64
  expect(items).toHaveLength(2); // maxVisibleItems is 2
60
- expect(items.every((item) => item.name.includes("border"))).toBe(true);
65
+ expect(
66
+ items.every(
67
+ (item) =>
68
+ isSelectableMenuItem(item) && item.title?.toString().includes("border")
69
+ )
70
+ ).toBe(true);
61
71
  });
62
72
 
63
73
  test("respects section header maxVisibleItems with empty query", () => {
@@ -67,7 +77,7 @@ test("respects section header maxVisibleItems with empty query", () => {
67
77
 
68
78
  // Find the border section
69
79
  const borderSectionIndex = snapshot.filteredItems.findIndex(
70
- (item) => item.type === "sectionHeader" && item.name === "Border Styles"
80
+ (item) => item.type === "sectionHeader" && item.title === "Border Styles"
71
81
  );
72
82
 
73
83
  // Get items in the border section
@@ -78,7 +88,11 @@ test("respects section header maxVisibleItems with empty query", () => {
78
88
  // Should only show maxVisibleItems (2) items from the border section
79
89
  expect(borderSectionItems).toHaveLength(2);
80
90
  expect(
81
- borderSectionItems.every((item) => item.name.startsWith("border-"))
91
+ borderSectionItems.every(
92
+ (item) =>
93
+ isSelectableMenuItem(item) &&
94
+ item.title?.toString().startsWith("border-")
95
+ )
82
96
  ).toBe(true);
83
97
  });
84
98
 
@@ -110,7 +124,7 @@ test("selects current item", () => {
110
124
  state.moveSelection("down"); // Select second item
111
125
  const selectedItem = state.selectCurrentItem();
112
126
 
113
- expect(selectedItem?.name).toBe("font-italic");
127
+ expect(selectedItem?.title).toBe("font-italic");
114
128
  const snapshot = state.getSnapshot();
115
129
  expect(snapshot.filter).toBe("font-italic");
116
130
  expect(snapshot.lastSubmittedValue).toEqual({
@@ -155,18 +169,27 @@ test("handles empty query", () => {
155
169
 
156
170
  // Verify font items (first 3)
157
171
  const fontItems = snapshot.filteredItems.slice(0, 3);
158
- expect(fontItems.every((item) => item.name.startsWith("font-"))).toBe(true);
172
+ expect(
173
+ fontItems.every(
174
+ (item) =>
175
+ isSelectableMenuItem(item) && item.title?.toString().startsWith("font-")
176
+ )
177
+ ).toBe(true);
159
178
 
160
179
  // Verify border section
161
180
  expect(snapshot.filteredItems[3].type).toBe("sectionHeader");
162
- expect(snapshot.filteredItems[3].name).toBe("Border Styles");
181
+ expect(snapshot.filteredItems[3].title).toBe("Border Styles");
163
182
 
164
183
  // Verify border items (last 2, limited by maxVisibleItems)
165
184
  const borderItems = snapshot.filteredItems.slice(4);
166
185
  expect(borderItems).toHaveLength(2);
167
- expect(borderItems.every((item) => item.name.startsWith("border-"))).toBe(
168
- true
169
- );
186
+ expect(
187
+ borderItems.every(
188
+ (item) =>
189
+ isSelectableMenuItem(item) &&
190
+ item.title?.toString().startsWith("border-")
191
+ )
192
+ ).toBe(true);
170
193
  });
171
194
 
172
195
  test("provides typeahead value", () => {
@@ -220,7 +243,10 @@ test("supports fuzzy matching", () => {
220
243
  const snapshot = state.getSnapshot();
221
244
 
222
245
  expect(snapshot.filteredItems).toHaveLength(1);
223
- expect(snapshot.filteredItems[0].name).toBe("font-bold");
246
+ expect(
247
+ isSelectableMenuItemWithScore(snapshot.filteredItems[0]) &&
248
+ snapshot.filteredItems[0].title === "font-bold"
249
+ ).toBe(true);
224
250
  });
225
251
 
226
252
  test("wraps selection at boundaries", () => {
@@ -237,10 +263,10 @@ test("wraps selection at boundaries", () => {
237
263
  });
238
264
 
239
265
  test("respects alwaysInclude flag", () => {
240
- const itemsWithAlwaysInclude = [
241
- { id: "1", name: "font-bold", alwaysInclude: true },
242
- { id: "2", name: "font-italic" },
243
- { id: "3", name: "font-regular" },
266
+ const itemsWithAlwaysInclude: MenuItem<string>[] = [
267
+ { value: "font-bold", title: "font-bold", alwaysInclude: true },
268
+ { value: "font-italic", title: "font-italic" },
269
+ { value: "font-regular", title: "font-regular" },
244
270
  ];
245
271
 
246
272
  const state = new ComboboxState(itemsWithAlwaysInclude, "", true);
@@ -248,11 +274,15 @@ test("respects alwaysInclude flag", () => {
248
274
  const snapshot = state.getSnapshot();
249
275
 
250
276
  expect(snapshot.filteredItems).toHaveLength(2); // Should include "italic" match and "bold" due to alwaysInclude
251
- expect(snapshot.filteredItems.some((item) => item.name === "font-bold")).toBe(
252
- true
253
- );
254
277
  expect(
255
- snapshot.filteredItems.some((item) => item.name === "font-italic")
278
+ snapshot.filteredItems.some(
279
+ (item) => isSelectableMenuItem(item) && item.title === "font-bold"
280
+ )
281
+ ).toBe(true);
282
+ expect(
283
+ snapshot.filteredItems.some(
284
+ (item) => isSelectableMenuItem(item) && item.title === "font-italic"
285
+ )
256
286
  ).toBe(true);
257
287
  });
258
288
 
@@ -265,24 +295,24 @@ test("handles empty items array", () => {
265
295
  });
266
296
 
267
297
  test("handles multiple section headers", () => {
268
- const itemsWithMultipleSections = [
298
+ const itemsWithMultipleSections: MenuItem<string>[] = [
269
299
  {
270
- type: "sectionHeader" as const,
300
+ type: "sectionHeader",
271
301
  id: "header1",
272
- name: "Fonts",
302
+ title: "Fonts",
273
303
  maxVisibleItems: 2,
274
304
  },
275
- { id: "1", name: "font-bold" },
276
- { id: "2", name: "font-italic" },
277
- { id: "3", name: "font-regular" },
305
+ { value: "font-bold", title: "font-bold" },
306
+ { value: "font-italic", title: "font-italic" },
307
+ { value: "font-regular", title: "font-regular" },
278
308
  {
279
- type: "sectionHeader" as const,
309
+ type: "sectionHeader",
280
310
  id: "header2",
281
- name: "Borders",
311
+ title: "Borders",
282
312
  maxVisibleItems: 1,
283
313
  },
284
- { id: "4", name: "border-solid" },
285
- { id: "5", name: "border-dashed" },
314
+ { value: "border-solid", title: "border-solid" },
315
+ { value: "border-dashed", title: "border-dashed" },
286
316
  ];
287
317
 
288
318
  const state = new ComboboxState(itemsWithMultipleSections, "", true);
@@ -301,37 +331,38 @@ test("handles multiple section headers", () => {
301
331
  state.setFilter("font");
302
332
  snapshot = state.getSnapshot();
303
333
  expect(snapshot.filteredItems).toHaveLength(3); // header + 2 items (maxVisibleItems: 2)
334
+
304
335
  expect(snapshot.filteredItems[0].type).toBe("sectionHeader");
305
- expect(snapshot.filteredItems[0].name).toBe("Fonts");
336
+ expect(snapshot.filteredItems[0].title).toBe("Fonts");
306
337
  expect(snapshot.filteredItems.slice(1)).toHaveLength(2);
307
338
 
308
339
  state.setFilter("border");
309
340
  snapshot = state.getSnapshot();
310
341
  expect(snapshot.filteredItems).toHaveLength(2); // header + 1 item (maxVisibleItems: 1)
311
342
  expect(snapshot.filteredItems[0].type).toBe("sectionHeader");
312
- expect(snapshot.filteredItems[0].name).toBe("Borders");
343
+ expect(snapshot.filteredItems[0].title).toBe("Borders");
313
344
  expect(snapshot.filteredItems.slice(1)).toHaveLength(1);
314
345
  });
315
346
 
316
347
  test("handles multiple section headers with empty query", () => {
317
- const itemsWithMultipleSections = [
348
+ const itemsWithMultipleSections: MenuItem<string>[] = [
318
349
  {
319
- type: "sectionHeader" as const,
350
+ type: "sectionHeader",
320
351
  id: "header1",
321
- name: "Fonts",
352
+ title: "Fonts",
322
353
  maxVisibleItems: 2,
323
354
  },
324
- { id: "1", name: "font-bold" },
325
- { id: "2", name: "font-italic" },
326
- { id: "3", name: "font-regular" },
355
+ { value: "font-bold", title: "font-bold" },
356
+ { value: "font-italic", title: "font-italic" },
357
+ { value: "font-regular", title: "font-regular" },
327
358
  {
328
- type: "sectionHeader" as const,
359
+ type: "sectionHeader",
329
360
  id: "header2",
330
- name: "Borders",
361
+ title: "Borders",
331
362
  maxVisibleItems: 1,
332
363
  },
333
- { id: "4", name: "border-solid" },
334
- { id: "5", name: "border-dashed" },
364
+ { value: "border-solid", title: "border-solid" },
365
+ { value: "border-dashed", title: "border-dashed" },
335
366
  ];
336
367
 
337
368
  const state = new ComboboxState(itemsWithMultipleSections, "", true);
@@ -347,22 +378,30 @@ test("handles multiple section headers with empty query", () => {
347
378
 
348
379
  // Verify fonts section
349
380
  expect(snapshot.filteredItems[0].type).toBe("sectionHeader");
350
- expect(snapshot.filteredItems[0].name).toBe("Fonts");
381
+ expect(snapshot.filteredItems[0].title).toBe("Fonts");
351
382
  expect(snapshot.filteredItems.slice(1, 3)).toHaveLength(2);
352
383
  expect(
353
384
  snapshot.filteredItems
354
385
  .slice(1, 3)
355
- .every((item) => item.name.startsWith("font-"))
386
+ .every(
387
+ (item) =>
388
+ isSelectableMenuItemWithScore(item) &&
389
+ item.title?.toString().startsWith("font-")
390
+ )
356
391
  ).toBe(true);
357
392
 
358
393
  // Verify borders section
359
394
  expect(snapshot.filteredItems[3].type).toBe("sectionHeader");
360
- expect(snapshot.filteredItems[3].name).toBe("Borders");
395
+ expect(snapshot.filteredItems[3].title).toBe("Borders");
361
396
  expect(snapshot.filteredItems.slice(4)).toHaveLength(1);
362
397
  expect(
363
398
  snapshot.filteredItems
364
399
  .slice(4)
365
- .every((item) => item.name.startsWith("border-"))
400
+ .every(
401
+ (item) =>
402
+ isSelectableMenuItemWithScore(item) &&
403
+ item.title?.toString().startsWith("border-")
404
+ )
366
405
  ).toBe(true);
367
406
  });
368
407
 
@@ -375,8 +414,8 @@ test("handles case insensitive matching", () => {
375
414
  expect(
376
415
  snapshot.filteredItems.every(
377
416
  (item) =>
378
- item.type !== "sectionHeader" &&
379
- item.name.toLowerCase().includes("font")
417
+ isSelectableMenuItem(item) &&
418
+ item.title?.toString().toLowerCase().includes("font")
380
419
  )
381
420
  ).toBe(true);
382
421
  });
@@ -387,16 +426,16 @@ test("maintains correct indices after filtering", () => {
387
426
  const snapshot = state.getSnapshot();
388
427
 
389
428
  // Check that indices match original positions
390
- const boldItem = snapshot.filteredItems.find(
391
- (item): item is ComboboxOption & IScoredItem =>
392
- item.type !== "sectionHeader" && item.name === "font-bold"
393
- );
429
+ const boldItem = snapshot.filteredItems
430
+ .filter(isSelectableMenuItemWithScore)
431
+ .find((item) => item.title === "font-bold");
432
+ expect(boldItem?.title).toBe("font-bold");
394
433
  expect(boldItem?.index).toBe(0);
395
434
 
396
- const italicItem = snapshot.filteredItems.find(
397
- (item): item is ComboboxOption & IScoredItem =>
398
- item.type !== "sectionHeader" && item.name === "font-italic"
399
- );
435
+ const italicItem = snapshot.filteredItems
436
+ .filter(isSelectableMenuItemWithScore)
437
+ .find((item) => item.title === "font-italic");
438
+ expect(italicItem?.title).toBe("font-italic");
400
439
  expect(italicItem?.index).toBe(1);
401
440
  });
402
441
 
@@ -408,18 +447,19 @@ test("handles section header selection", () => {
408
447
  let snapshot = state.getSnapshot();
409
448
  // Should select the next selectable item after the header
410
449
  expect(snapshot.selectedIndex).toBe(4);
411
- expect(snapshot.filteredItems[snapshot.selectedIndex].name).toBe(
412
- "border-solid"
413
- );
450
+ const selectedItem = snapshot.filteredItems[snapshot.selectedIndex];
451
+ expect(
452
+ isSelectableMenuItemWithScore(selectedItem) && selectedItem.title
453
+ ).toBe("border-solid");
414
454
 
415
455
  // Try to select a header when it's the last item
416
- const itemsWithHeaderAtEnd = [
417
- { id: "1", name: "item1" },
418
- { id: "2", name: "item2" },
456
+ const itemsWithHeaderAtEnd: MenuItem<string>[] = [
457
+ { value: "item1", title: "item1" },
458
+ { value: "item2", title: "item2" },
419
459
  {
420
460
  type: "sectionHeader" as const,
421
461
  id: "header",
422
- name: "Header",
462
+ title: "Header",
423
463
  },
424
464
  ];
425
465
  const stateWithHeaderAtEnd = new ComboboxState(
@@ -431,17 +471,21 @@ test("handles section header selection", () => {
431
471
  snapshot = stateWithHeaderAtEnd.getSnapshot();
432
472
  // Should select the previous selectable item before the header
433
473
  expect(snapshot.selectedIndex).toBe(1);
434
- expect(snapshot.filteredItems[snapshot.selectedIndex].name).toBe("item2");
474
+ const selectedItemEndHeader = snapshot.filteredItems[snapshot.selectedIndex];
475
+ expect(
476
+ isSelectableMenuItemWithScore(selectedItemEndHeader) &&
477
+ selectedItemEndHeader.value
478
+ ).toBe("item2");
435
479
 
436
480
  // Try to select a header when it's between two sections
437
- const itemsWithHeaderBetween = [
438
- { id: "1", name: "item1" },
481
+ const itemsWithHeaderBetween: MenuItem<string>[] = [
482
+ { value: "item1", title: "item1" },
439
483
  {
440
- type: "sectionHeader" as const,
484
+ type: "sectionHeader",
441
485
  id: "header",
442
- name: "Header",
486
+ title: "Header",
443
487
  },
444
- { id: "2", name: "item2" },
488
+ { value: "item2", title: "item2" },
445
489
  ];
446
490
  const stateWithHeaderBetween = new ComboboxState(
447
491
  itemsWithHeaderBetween,
@@ -452,21 +496,26 @@ test("handles section header selection", () => {
452
496
  snapshot = stateWithHeaderBetween.getSnapshot();
453
497
  // Should select the next selectable item after the header
454
498
  expect(snapshot.selectedIndex).toBe(2);
455
- expect(snapshot.filteredItems[snapshot.selectedIndex].name).toBe("item2");
499
+ const selectedItemBetweenHeader =
500
+ snapshot.filteredItems[snapshot.selectedIndex];
501
+ expect(
502
+ isSelectableMenuItemWithScore(selectedItemBetweenHeader) &&
503
+ selectedItemBetweenHeader.value
504
+ ).toBe("item2");
456
505
  });
457
506
 
458
507
  test("handles section header selection with no selectable items", () => {
459
508
  // Test with only headers
460
- const onlyHeaders = [
509
+ const onlyHeaders: MenuItem<string>[] = [
461
510
  {
462
- type: "sectionHeader" as const,
511
+ type: "sectionHeader",
463
512
  id: "header1",
464
- name: "Header 1",
513
+ title: "Header 1",
465
514
  },
466
515
  {
467
- type: "sectionHeader" as const,
516
+ type: "sectionHeader",
468
517
  id: "header2",
469
- name: "Header 2",
518
+ title: "Header 2",
470
519
  },
471
520
  ];
472
521
  const state = new ComboboxState(onlyHeaders, "", true);
@@ -485,13 +534,12 @@ test("selects specific item when provided", () => {
485
534
  // Get the items we can select from
486
535
  const snapshot = state.getSnapshot();
487
536
  const italicItem = snapshot.filteredItems.find(
488
- (item): item is ComboboxOption & IScoredItem =>
489
- item.type !== "sectionHeader" && item.name === "font-italic"
537
+ (item) => isSelectableMenuItem(item) && item.title === "font-italic"
490
538
  );
491
539
 
492
540
  // Select a specific item, even though it's not the currently selected one
493
541
  const selectedItem = state.selectCurrentItem(italicItem);
494
- expect(selectedItem?.name).toBe("font-italic");
542
+ expect(selectedItem?.title).toBe("font-italic");
495
543
 
496
544
  // Verify the state was updated
497
545
  const newSnapshot = state.getSnapshot();
@@ -0,0 +1,29 @@
1
+ import { forwardRef } from "react";
2
+
3
+ import React from "react";
4
+ import { cx } from "../utils/classNames";
5
+ import { Text, TextProps } from "./Text";
6
+
7
+ type BreadcrumbTextProps = Omit<
8
+ TextProps,
9
+ "variant" | "whiteSpace" | "lineHeight" | "userSelect"
10
+ >;
11
+
12
+ export const BreadcrumbText = forwardRef(function BreadcrumbText(
13
+ { children, className, ...props }: BreadcrumbTextProps,
14
+ ref: React.ForwardedRef<HTMLSpanElement>
15
+ ) {
16
+ return (
17
+ <Text
18
+ {...props}
19
+ variant="small"
20
+ className={cx(
21
+ "hover:text-breadcrumb-text-hover hover:cursor-pointer hover:whitespace-pre hover:select-none whitespace-pre leading-[15px] select-none",
22
+ className
23
+ )}
24
+ ref={ref}
25
+ >
26
+ {children}
27
+ </Text>
28
+ );
29
+ });
@@ -0,0 +1,74 @@
1
+ import React from "react";
2
+ import { forwardRefGeneric } from "../../../noya-react-utils/src/utils/reactGenerics";
3
+ import { Grid, GridViewSize } from "../components/Grid";
4
+ import { MenuItem } from "../components/internal/Menu";
5
+ import { ListView, ListViewRootProps } from "../components/ListView";
6
+ import { List } from "./List";
7
+
8
+ export type CollectionViewType = "grid" | "list";
9
+
10
+ export type CollectionRef = {
11
+ editName: (id: string) => void;
12
+ };
13
+
14
+ export interface CollectionProps<T, M extends string = string> {
15
+ className?: string;
16
+ items: T[];
17
+ getId: (item: T) => string;
18
+ getName: (item: T) => string;
19
+ getExpanded?: (item: T) => boolean | undefined;
20
+ /**
21
+ * Whether directories should be expandable.
22
+ * @default true
23
+ */
24
+ expandable?: boolean;
25
+ /**
26
+ * Whether to allow renaming files
27
+ * @default true
28
+ */
29
+ renamable?: boolean;
30
+ menuItems?: MenuItem<M>[];
31
+ onSelectMenuItem?: (action: M, selectedItems: T[]) => void;
32
+ onRename?: (item: T, newName: string) => void;
33
+ renderThumbnail?: (item: T, selected: boolean) => React.ReactNode;
34
+ renderAction?: (item: T, selected: boolean) => React.ReactNode;
35
+ renderDetail?: (item: T, selected: boolean) => React.ReactNode;
36
+ /** Callback when selection changes. If not provided, selection will be disabled. */
37
+ onSelectionChange?: (
38
+ selectedItemIds: string[],
39
+ event?: ListView.ClickInfo
40
+ ) => void;
41
+ scrollable?: boolean;
42
+ itemRoleDescription?: string;
43
+ /** Position of the detail content. Defaults to 'end'. */
44
+ detailPosition?: "end" | "below";
45
+ /** Size of the list items. Defaults to 'medium'. */
46
+ size?: GridViewSize;
47
+ /** For testing: Override the hover state with a specific item ID */
48
+ testHoveredId?: string;
49
+ /** For testing: Override the renaming state with a specific item ID */
50
+ testRenamingId?: string;
51
+ setExpanded?: (item: T, expanded: boolean) => void;
52
+ acceptsDrop?: ListViewRootProps["acceptsDrop"];
53
+ onMoveItem?: ListViewRootProps["onMoveItem"];
54
+ getDepth?: (item: T) => number;
55
+ onFilesDrop?: (event: React.DragEvent<Element>) => void;
56
+ getRenamable?: (item: T) => boolean;
57
+ /** Currently selected file IDs */
58
+ selectedIds?: string[];
59
+ viewType?: CollectionViewType;
60
+ onDoubleClickItem?: (itemId: string) => void;
61
+ }
62
+
63
+ export const Collection = forwardRefGeneric(function Collection<
64
+ T,
65
+ M extends string = string,
66
+ >(props: CollectionProps<T, M>, ref: React.ForwardedRef<CollectionRef>) {
67
+ const { viewType, ...rest } = props;
68
+
69
+ return viewType === "grid" ? (
70
+ <Grid ref={ref} {...rest} />
71
+ ) : (
72
+ <List ref={ref} {...rest} />
73
+ );
74
+ });