@noya-app/noya-designsystem 0.1.40 → 0.1.42

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 (52) 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 +221 -124
  5. package/dist/index.d.ts +221 -124
  6. package/dist/index.js +7464 -6801
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +7867 -7246
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +5 -6
  11. package/src/__tests__/combobox.test.ts +578 -0
  12. package/src/components/ActivityIndicator.tsx +4 -4
  13. package/src/components/AnimatePresence.tsx +5 -5
  14. package/src/components/Avatar.tsx +5 -2
  15. package/src/components/BaseToolbar.tsx +61 -0
  16. package/src/components/Button.tsx +1 -1
  17. package/src/components/Checkbox.tsx +7 -6
  18. package/src/components/Combobox.tsx +220 -324
  19. package/src/components/ComboboxMenu.tsx +88 -0
  20. package/src/components/Dialog.tsx +10 -6
  21. package/src/components/DimensionInput.tsx +4 -4
  22. package/src/components/FillPreviewBackground.tsx +51 -44
  23. package/src/components/FloatingWindow.tsx +5 -2
  24. package/src/components/GradientPicker.tsx +14 -14
  25. package/src/components/IconButton.tsx +6 -12
  26. package/src/components/Icons.tsx +3 -3
  27. package/src/components/InputField.tsx +145 -160
  28. package/src/components/Label.tsx +4 -4
  29. package/src/components/LabeledElementView.tsx +35 -41
  30. package/src/components/ListView.tsx +49 -39
  31. package/src/components/Message.tsx +75 -0
  32. package/src/components/Progress.tsx +2 -2
  33. package/src/components/ScrollArea.tsx +7 -5
  34. package/src/components/SegmentedControl.tsx +171 -0
  35. package/src/components/SelectMenu.tsx +61 -10
  36. package/src/components/Slider.tsx +49 -14
  37. package/src/components/Sortable.tsx +17 -27
  38. package/src/components/Spacer.tsx +28 -9
  39. package/src/components/Switch.tsx +8 -8
  40. package/src/components/TextArea.tsx +23 -13
  41. package/src/components/Toolbar.tsx +134 -0
  42. package/src/components/Tooltip.tsx +10 -7
  43. package/src/components/internal/Menu.tsx +5 -4
  44. package/src/contexts/DesignSystemConfiguration.tsx +15 -24
  45. package/src/contexts/DialogContext.tsx +137 -49
  46. package/src/contexts/ImageDataContext.tsx +6 -12
  47. package/src/index.css +4 -6
  48. package/src/index.tsx +12 -3
  49. package/src/utils/combobox.ts +369 -0
  50. package/tailwind.config.ts +2 -3
  51. package/src/components/RadioGroup.tsx +0 -100
  52. package/src/utils/completions.ts +0 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -23,11 +23,11 @@
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.14",
26
+ "@noya-app/noya-colorpicker": "0.1.15",
27
27
  "@noya-app/noya-utils": "0.1.3",
28
28
  "@noya-app/noya-geometry": "0.1.6",
29
- "@noya-app/noya-icons": "0.1.4",
30
- "@noya-app/noya-keymap": "0.1.2",
29
+ "@noya-app/noya-icons": "0.1.5",
30
+ "@noya-app/noya-keymap": "0.1.3",
31
31
  "@radix-ui/primitive": "^1.0.0",
32
32
  "@radix-ui/react-avatar": "^1.0.1",
33
33
  "@radix-ui/react-compose-refs": "^1.0.0",
@@ -36,9 +36,8 @@
36
36
  "@radix-ui/react-dropdown-menu": "^2.0.1",
37
37
  "@radix-ui/react-popover": "^1.0.2",
38
38
  "@radix-ui/react-progress": "^1.0.1",
39
- "@radix-ui/react-radio-group": "^1.1.0",
40
39
  "@radix-ui/react-scroll-area": "^1.0.2",
41
- "@radix-ui/react-slider": "^1.1.0",
40
+ "@radix-ui/react-slider": "^1.2.2",
42
41
  "@radix-ui/react-switch": "^1.0.1",
43
42
  "@radix-ui/react-toast": "^1.1.4",
44
43
  "@radix-ui/react-toggle-group": "^1.0.1",
@@ -0,0 +1,578 @@
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" },
9
+ {
10
+ type: "sectionHeader" as const,
11
+ id: "header1",
12
+ name: "Border Styles",
13
+ maxVisibleItems: 2,
14
+ },
15
+ { id: "4", name: "border-solid" },
16
+ { id: "5", name: "border-dashed" },
17
+ { id: "6", name: "border-dotted" },
18
+ ];
19
+
20
+ test("initializes with default values", () => {
21
+ const state = new ComboboxState(testItems, "initial", true);
22
+ const snapshot = state.getSnapshot();
23
+
24
+ expect(snapshot.filter).toBe("initial");
25
+ expect(snapshot.selectedIndex).toBe(0);
26
+ expect(snapshot.lastSubmittedValue).toEqual({
27
+ filter: "initial",
28
+ itemName: "initial",
29
+ });
30
+ expect(snapshot.filteredItems).toHaveLength(testItems.length);
31
+ });
32
+
33
+ test("filters items based on query", () => {
34
+ const state = new ComboboxState(testItems, "", true);
35
+ state.setFilter("font");
36
+ const snapshot = state.getSnapshot();
37
+
38
+ expect(snapshot.filteredItems).toHaveLength(3);
39
+ expect(
40
+ snapshot.filteredItems.every(
41
+ (item) => item.type !== "sectionHeader" && item.name.includes("font")
42
+ )
43
+ ).toBe(true);
44
+ });
45
+
46
+ test("respects section header maxVisibleItems", () => {
47
+ const state = new ComboboxState(testItems, "", true);
48
+ state.setFilter("border");
49
+ const snapshot = state.getSnapshot();
50
+
51
+ // When filtering by "border", we should get:
52
+ // 1. The border section header
53
+ // 2. Only 2 items (maxVisibleItems) from that section
54
+ expect(snapshot.filteredItems).toHaveLength(3);
55
+ expect(snapshot.filteredItems[0].type).toBe("sectionHeader");
56
+ expect(snapshot.filteredItems[0].name).toBe("Border Styles");
57
+
58
+ const items = snapshot.filteredItems.slice(1);
59
+ expect(items).toHaveLength(2); // maxVisibleItems is 2
60
+ expect(items.every((item) => item.name.includes("border"))).toBe(true);
61
+ });
62
+
63
+ test("respects section header maxVisibleItems with empty query", () => {
64
+ const state = new ComboboxState(testItems, "", true);
65
+ state.setFilter("");
66
+ const snapshot = state.getSnapshot();
67
+
68
+ // Find the border section
69
+ const borderSectionIndex = snapshot.filteredItems.findIndex(
70
+ (item) => item.type === "sectionHeader" && item.name === "Border Styles"
71
+ );
72
+
73
+ // Get items in the border section
74
+ const borderSectionItems = snapshot.filteredItems.slice(
75
+ borderSectionIndex + 1
76
+ );
77
+
78
+ // Should only show maxVisibleItems (2) items from the border section
79
+ expect(borderSectionItems).toHaveLength(2);
80
+ expect(
81
+ borderSectionItems.every((item) => item.name.startsWith("border-"))
82
+ ).toBe(true);
83
+ });
84
+
85
+ test("moves selection up and down", () => {
86
+ const state = new ComboboxState(testItems, "", true);
87
+ state.setFilter("font");
88
+
89
+ // Move down twice
90
+ state.moveSelection("down");
91
+ state.moveSelection("down");
92
+ expect(state.getSnapshot().selectedIndex).toBe(2);
93
+
94
+ // Move up once
95
+ state.moveSelection("up");
96
+ expect(state.getSnapshot().selectedIndex).toBe(1);
97
+
98
+ // Should skip section headers when moving
99
+ state.setFilter("");
100
+ state.moveSelection("down");
101
+ const snapshot = state.getSnapshot();
102
+ expect(snapshot.filteredItems[snapshot.selectedIndex].type).not.toBe(
103
+ "sectionHeader"
104
+ );
105
+ });
106
+
107
+ test("selects current item", () => {
108
+ const state = new ComboboxState(testItems, "", true);
109
+ state.setFilter("font");
110
+ state.moveSelection("down"); // Select second item
111
+ const selectedItem = state.selectCurrentItem();
112
+
113
+ expect(selectedItem?.name).toBe("font-italic");
114
+ const snapshot = state.getSnapshot();
115
+ expect(snapshot.filter).toBe("font-italic");
116
+ expect(snapshot.lastSubmittedValue).toEqual({
117
+ filter: "font-italic",
118
+ itemName: "font-italic",
119
+ });
120
+ });
121
+
122
+ test("restores last submitted value", () => {
123
+ const state = new ComboboxState(testItems, "", true);
124
+ state.setFilter("font");
125
+ state.selectCurrentItem(); // Select first matching item
126
+ state.setFilter("something else");
127
+ state.restoreLastSubmittedValue();
128
+
129
+ const snapshot = state.getSnapshot();
130
+ expect(snapshot.filter).toBe("font-bold");
131
+ });
132
+
133
+ test("resets state", () => {
134
+ const state = new ComboboxState(testItems, "", true);
135
+ state.setFilter("font");
136
+ state.moveSelection("down");
137
+ state.selectCurrentItem();
138
+
139
+ state.reset("new");
140
+ const snapshot = state.getSnapshot();
141
+ expect(snapshot.filter).toBe("new");
142
+ expect(snapshot.selectedIndex).toBe(0);
143
+ });
144
+
145
+ test("handles empty query", () => {
146
+ const state = new ComboboxState(testItems, "", true);
147
+ state.setFilter("");
148
+ const snapshot = state.getSnapshot();
149
+
150
+ // With empty query, we should get:
151
+ // - 3 font items (no maxVisibleItems limit)
152
+ // - 1 border section header
153
+ // - 2 border items (maxVisibleItems: 2)
154
+ expect(snapshot.filteredItems).toHaveLength(6);
155
+
156
+ // Verify font items (first 3)
157
+ const fontItems = snapshot.filteredItems.slice(0, 3);
158
+ expect(fontItems.every((item) => item.name.startsWith("font-"))).toBe(true);
159
+
160
+ // Verify border section
161
+ expect(snapshot.filteredItems[3].type).toBe("sectionHeader");
162
+ expect(snapshot.filteredItems[3].name).toBe("Border Styles");
163
+
164
+ // Verify border items (last 2, limited by maxVisibleItems)
165
+ const borderItems = snapshot.filteredItems.slice(4);
166
+ expect(borderItems).toHaveLength(2);
167
+ expect(borderItems.every((item) => item.name.startsWith("border-"))).toBe(
168
+ true
169
+ );
170
+ });
171
+
172
+ test("provides typeahead value", () => {
173
+ const state = new ComboboxState(testItems, "", true);
174
+ state.setFilter("font");
175
+
176
+ const typeaheadValue = state.getTypeaheadValue();
177
+ expect(typeaheadValue).toBe("font-bold");
178
+ });
179
+
180
+ test("notifies subscribers of changes", () => {
181
+ const state = new ComboboxState(testItems, "", true);
182
+ let callCount = 0;
183
+
184
+ const unsubscribe = state.subscribe(() => {
185
+ callCount++;
186
+ });
187
+
188
+ state.setFilter("font");
189
+ expect(callCount).toBe(1);
190
+
191
+ state.moveSelection("down");
192
+ expect(callCount).toBe(2);
193
+
194
+ state.selectCurrentItem();
195
+ expect(callCount).toBe(3);
196
+
197
+ unsubscribe();
198
+ state.setFilter("test");
199
+ expect(callCount).toBe(3); // Should not increment after unsubscribe
200
+ });
201
+
202
+ test("caches snapshots", () => {
203
+ const state = new ComboboxState(testItems, "", true);
204
+
205
+ const snapshot1 = state.getSnapshot();
206
+ const snapshot2 = state.getSnapshot();
207
+
208
+ // Should return the same object reference when no changes
209
+ expect(snapshot1).toBe(snapshot2);
210
+
211
+ // Should return new snapshot after change
212
+ state.setFilter("font");
213
+ const snapshot3 = state.getSnapshot();
214
+ expect(snapshot3).not.toBe(snapshot1);
215
+ });
216
+
217
+ test("supports fuzzy matching", () => {
218
+ const state = new ComboboxState(testItems, "", true);
219
+ state.setFilter("fb"); // Should match "font-bold"
220
+ const snapshot = state.getSnapshot();
221
+
222
+ expect(snapshot.filteredItems).toHaveLength(1);
223
+ expect(snapshot.filteredItems[0].name).toBe("font-bold");
224
+ });
225
+
226
+ test("wraps selection at boundaries", () => {
227
+ const state = new ComboboxState(testItems, "", true);
228
+ state.setFilter("font");
229
+
230
+ // Move up from first item should wrap to last
231
+ state.moveSelection("up");
232
+ expect(state.getSnapshot().selectedIndex).toBe(2);
233
+
234
+ // Move down from last item should wrap to first
235
+ state.moveSelection("down");
236
+ expect(state.getSnapshot().selectedIndex).toBe(0);
237
+ });
238
+
239
+ 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" },
244
+ ];
245
+
246
+ const state = new ComboboxState(itemsWithAlwaysInclude, "", true);
247
+ state.setFilter("italic");
248
+ const snapshot = state.getSnapshot();
249
+
250
+ 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
+ expect(
255
+ snapshot.filteredItems.some((item) => item.name === "font-italic")
256
+ ).toBe(true);
257
+ });
258
+
259
+ test("handles empty items array", () => {
260
+ const state = new ComboboxState([], "", true);
261
+ const snapshot = state.getSnapshot();
262
+
263
+ expect(snapshot.filteredItems).toHaveLength(0);
264
+ expect(snapshot.selectedIndex).toBe(0);
265
+ });
266
+
267
+ test("handles multiple section headers", () => {
268
+ const itemsWithMultipleSections = [
269
+ {
270
+ type: "sectionHeader" as const,
271
+ id: "header1",
272
+ name: "Fonts",
273
+ maxVisibleItems: 2,
274
+ },
275
+ { id: "1", name: "font-bold" },
276
+ { id: "2", name: "font-italic" },
277
+ { id: "3", name: "font-regular" },
278
+ {
279
+ type: "sectionHeader" as const,
280
+ id: "header2",
281
+ name: "Borders",
282
+ maxVisibleItems: 1,
283
+ },
284
+ { id: "4", name: "border-solid" },
285
+ { id: "5", name: "border-dashed" },
286
+ ];
287
+
288
+ const state = new ComboboxState(itemsWithMultipleSections, "", true);
289
+
290
+ // First test with empty filter - should respect maxVisibleItems
291
+ state.setFilter("");
292
+ let snapshot = state.getSnapshot();
293
+ // Expected:
294
+ // - 1 font header
295
+ // - 2 font items (maxVisibleItems: 2)
296
+ // - 1 border header
297
+ // - 1 border item (maxVisibleItems: 1)
298
+ expect(snapshot.filteredItems).toHaveLength(5);
299
+
300
+ // Now test with filters to verify maxVisibleItems
301
+ state.setFilter("font");
302
+ snapshot = state.getSnapshot();
303
+ expect(snapshot.filteredItems).toHaveLength(3); // header + 2 items (maxVisibleItems: 2)
304
+ expect(snapshot.filteredItems[0].type).toBe("sectionHeader");
305
+ expect(snapshot.filteredItems[0].name).toBe("Fonts");
306
+ expect(snapshot.filteredItems.slice(1)).toHaveLength(2);
307
+
308
+ state.setFilter("border");
309
+ snapshot = state.getSnapshot();
310
+ expect(snapshot.filteredItems).toHaveLength(2); // header + 1 item (maxVisibleItems: 1)
311
+ expect(snapshot.filteredItems[0].type).toBe("sectionHeader");
312
+ expect(snapshot.filteredItems[0].name).toBe("Borders");
313
+ expect(snapshot.filteredItems.slice(1)).toHaveLength(1);
314
+ });
315
+
316
+ test("handles multiple section headers with empty query", () => {
317
+ const itemsWithMultipleSections = [
318
+ {
319
+ type: "sectionHeader" as const,
320
+ id: "header1",
321
+ name: "Fonts",
322
+ maxVisibleItems: 2,
323
+ },
324
+ { id: "1", name: "font-bold" },
325
+ { id: "2", name: "font-italic" },
326
+ { id: "3", name: "font-regular" },
327
+ {
328
+ type: "sectionHeader" as const,
329
+ id: "header2",
330
+ name: "Borders",
331
+ maxVisibleItems: 1,
332
+ },
333
+ { id: "4", name: "border-solid" },
334
+ { id: "5", name: "border-dashed" },
335
+ ];
336
+
337
+ const state = new ComboboxState(itemsWithMultipleSections, "", true);
338
+ state.setFilter("");
339
+ const snapshot = state.getSnapshot();
340
+
341
+ // Expected structure:
342
+ // 1. "Fonts" header
343
+ // 2. 2 font items (maxVisibleItems: 2)
344
+ // 3. "Borders" header
345
+ // 4. 1 border item (maxVisibleItems: 1)
346
+ expect(snapshot.filteredItems).toHaveLength(5);
347
+
348
+ // Verify fonts section
349
+ expect(snapshot.filteredItems[0].type).toBe("sectionHeader");
350
+ expect(snapshot.filteredItems[0].name).toBe("Fonts");
351
+ expect(snapshot.filteredItems.slice(1, 3)).toHaveLength(2);
352
+ expect(
353
+ snapshot.filteredItems
354
+ .slice(1, 3)
355
+ .every((item) => item.name.startsWith("font-"))
356
+ ).toBe(true);
357
+
358
+ // Verify borders section
359
+ expect(snapshot.filteredItems[3].type).toBe("sectionHeader");
360
+ expect(snapshot.filteredItems[3].name).toBe("Borders");
361
+ expect(snapshot.filteredItems.slice(4)).toHaveLength(1);
362
+ expect(
363
+ snapshot.filteredItems
364
+ .slice(4)
365
+ .every((item) => item.name.startsWith("border-"))
366
+ ).toBe(true);
367
+ });
368
+
369
+ test("handles case insensitive matching", () => {
370
+ const state = new ComboboxState(testItems, "", true);
371
+ state.setFilter("FONT");
372
+ const snapshot = state.getSnapshot();
373
+
374
+ expect(snapshot.filteredItems).toHaveLength(3);
375
+ expect(
376
+ snapshot.filteredItems.every(
377
+ (item) =>
378
+ item.type !== "sectionHeader" &&
379
+ item.name.toLowerCase().includes("font")
380
+ )
381
+ ).toBe(true);
382
+ });
383
+
384
+ test("maintains correct indices after filtering", () => {
385
+ const state = new ComboboxState(testItems, "", true);
386
+ state.setFilter("font");
387
+ const snapshot = state.getSnapshot();
388
+
389
+ // 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
+ );
394
+ expect(boldItem?.index).toBe(0);
395
+
396
+ const italicItem = snapshot.filteredItems.find(
397
+ (item): item is ComboboxOption & IScoredItem =>
398
+ item.type !== "sectionHeader" && item.name === "font-italic"
399
+ );
400
+ expect(italicItem?.index).toBe(1);
401
+ });
402
+
403
+ test("handles section header selection", () => {
404
+ const state = new ComboboxState(testItems, "", true);
405
+
406
+ // Try to select the section header (index 3)
407
+ state.setSelectedIndex(3);
408
+ let snapshot = state.getSnapshot();
409
+ // Should select the next selectable item after the header
410
+ expect(snapshot.selectedIndex).toBe(4);
411
+ expect(snapshot.filteredItems[snapshot.selectedIndex].name).toBe(
412
+ "border-solid"
413
+ );
414
+
415
+ // Try to select a header when it's the last item
416
+ const itemsWithHeaderAtEnd = [
417
+ { id: "1", name: "item1" },
418
+ { id: "2", name: "item2" },
419
+ {
420
+ type: "sectionHeader" as const,
421
+ id: "header",
422
+ name: "Header",
423
+ },
424
+ ];
425
+ const stateWithHeaderAtEnd = new ComboboxState(
426
+ itemsWithHeaderAtEnd,
427
+ "",
428
+ true
429
+ );
430
+ stateWithHeaderAtEnd.setSelectedIndex(2); // Try to select header
431
+ snapshot = stateWithHeaderAtEnd.getSnapshot();
432
+ // Should select the previous selectable item before the header
433
+ expect(snapshot.selectedIndex).toBe(1);
434
+ expect(snapshot.filteredItems[snapshot.selectedIndex].name).toBe("item2");
435
+
436
+ // Try to select a header when it's between two sections
437
+ const itemsWithHeaderBetween = [
438
+ { id: "1", name: "item1" },
439
+ {
440
+ type: "sectionHeader" as const,
441
+ id: "header",
442
+ name: "Header",
443
+ },
444
+ { id: "2", name: "item2" },
445
+ ];
446
+ const stateWithHeaderBetween = new ComboboxState(
447
+ itemsWithHeaderBetween,
448
+ "",
449
+ true
450
+ );
451
+ stateWithHeaderBetween.setSelectedIndex(1); // Try to select header
452
+ snapshot = stateWithHeaderBetween.getSnapshot();
453
+ // Should select the next selectable item after the header
454
+ expect(snapshot.selectedIndex).toBe(2);
455
+ expect(snapshot.filteredItems[snapshot.selectedIndex].name).toBe("item2");
456
+ });
457
+
458
+ test("handles section header selection with no selectable items", () => {
459
+ // Test with only headers
460
+ const onlyHeaders = [
461
+ {
462
+ type: "sectionHeader" as const,
463
+ id: "header1",
464
+ name: "Header 1",
465
+ },
466
+ {
467
+ type: "sectionHeader" as const,
468
+ id: "header2",
469
+ name: "Header 2",
470
+ },
471
+ ];
472
+ const state = new ComboboxState(onlyHeaders, "", true);
473
+
474
+ // Try to select any header
475
+ state.setSelectedIndex(0);
476
+ const snapshot = state.getSnapshot();
477
+ // Should keep the initial selection since there are no selectable items
478
+ expect(snapshot.selectedIndex).toBe(0);
479
+ });
480
+
481
+ test("selects specific item when provided", () => {
482
+ const state = new ComboboxState(testItems, "", true);
483
+ state.setFilter("font");
484
+
485
+ // Get the items we can select from
486
+ const snapshot = state.getSnapshot();
487
+ const italicItem = snapshot.filteredItems.find(
488
+ (item): item is ComboboxOption & IScoredItem =>
489
+ item.type !== "sectionHeader" && item.name === "font-italic"
490
+ );
491
+
492
+ // Select a specific item, even though it's not the currently selected one
493
+ const selectedItem = state.selectCurrentItem(italicItem);
494
+ expect(selectedItem?.name).toBe("font-italic");
495
+
496
+ // Verify the state was updated
497
+ const newSnapshot = state.getSnapshot();
498
+ expect(newSnapshot.filter).toBe("font-italic");
499
+ expect(newSnapshot.lastSubmittedValue).toEqual({
500
+ filter: "font-italic",
501
+ itemName: "font-italic",
502
+ });
503
+ });
504
+
505
+ test("notifies subscribers synchronously", () => {
506
+ const state = new ComboboxState(testItems, "", true);
507
+ let callCount = 0;
508
+
509
+ state.subscribe(() => {
510
+ callCount++;
511
+ });
512
+
513
+ state.setFilter("font");
514
+ // Subscribers should be notified immediately
515
+ expect(callCount).toBe(1);
516
+
517
+ // State should be updated immediately
518
+ const snapshot = state.getSnapshot();
519
+ expect(snapshot.filter).toBe("font");
520
+
521
+ state.moveSelection("down");
522
+ expect(callCount).toBe(2);
523
+ });
524
+
525
+ test("handles arbitrary filter submission", () => {
526
+ const state = new ComboboxState(testItems, "", true);
527
+ const arbitraryValue = "custom-value";
528
+
529
+ // Submit an arbitrary value
530
+ state.submitArbitraryFilter(arbitraryValue);
531
+ let snapshot = state.getSnapshot();
532
+
533
+ // Check that the filter and lastSubmittedValue are updated
534
+ expect(snapshot.filter).toBe(arbitraryValue);
535
+ expect(snapshot.lastSubmittedValue).toEqual({
536
+ filter: arbitraryValue,
537
+ itemName: arbitraryValue,
538
+ });
539
+
540
+ // Verify that the arbitrary value persists after filtering
541
+ state.setFilter("font");
542
+ state.restoreLastSubmittedValue();
543
+ snapshot = state.getSnapshot();
544
+ expect(snapshot.filter).toBe(arbitraryValue);
545
+ });
546
+
547
+ test("handles arbitrary filter with setFilter", () => {
548
+ const state = new ComboboxState(testItems, "", false);
549
+ const nonExistentValue = "nonexistent";
550
+
551
+ // Should allow typing any value regardless of allowArbitraryValues
552
+ state.setFilter(nonExistentValue);
553
+ let snapshot = state.getSnapshot();
554
+ expect(snapshot.filter).toBe(nonExistentValue);
555
+ expect(snapshot.filteredItems).toHaveLength(0);
556
+
557
+ // Try to submit the arbitrary value
558
+ state.selectCurrentItem();
559
+ snapshot = state.getSnapshot();
560
+ // Since allowArbitraryValues is false, the filter should be restored to the last valid value
561
+ expect(snapshot.filter).toBe("");
562
+ });
563
+
564
+ test("handles invalid initial value with allowArbitraryValues", () => {
565
+ const invalidInitialValue = "nonexistent";
566
+
567
+ // With allowArbitraryValues = true
568
+ let state = new ComboboxState(testItems, invalidInitialValue, true);
569
+ let snapshot = state.getSnapshot();
570
+ expect(snapshot.filter).toBe(invalidInitialValue);
571
+ expect(snapshot.lastSubmittedValue.filter).toBe(invalidInitialValue);
572
+
573
+ // With allowArbitraryValues = false
574
+ state = new ComboboxState(testItems, invalidInitialValue, false);
575
+ snapshot = state.getSnapshot();
576
+ expect(snapshot.filter).toBe(""); // Should be empty since the value isn't allowed
577
+ expect(snapshot.lastSubmittedValue.filter).toBe(invalidInitialValue); // But keep the initial value in history
578
+ });
@@ -1,4 +1,4 @@
1
- import React, { memo, useMemo } from "react";
1
+ import * as React from "react";
2
2
 
3
3
  interface Props {
4
4
  size?: number;
@@ -7,14 +7,14 @@ interface Props {
7
7
  trackColor?: string;
8
8
  }
9
9
 
10
- export const ActivityIndicator = memo(function ActivityIndicator({
10
+ export const ActivityIndicator = React.memo(function ActivityIndicator({
11
11
  size = 16,
12
12
  opacity = 1,
13
13
  color = "black",
14
14
  trackColor = "rgba(0, 0, 0, 0.1)",
15
15
  }: Props) {
16
- const opacityStyle = useMemo(() => ({ opacity }), [opacity]);
17
- const dynamicStyles = useMemo(
16
+ const opacityStyle = React.useMemo(() => ({ opacity }), [opacity]);
17
+ const dynamicStyles = React.useMemo(
18
18
  () => ({
19
19
  border: `2px solid ${trackColor}`,
20
20
  borderTopColor: color,
@@ -1,12 +1,12 @@
1
1
  import { isDeepEqual } from "@noya-app/noya-utils";
2
2
  import React, {
3
- useEffect,
4
- useState,
5
- useRef,
6
3
  Children,
7
4
  isValidElement,
8
5
  useCallback,
6
+ useEffect,
9
7
  useLayoutEffect,
8
+ useRef,
9
+ useState,
10
10
  } from "react";
11
11
 
12
12
  type AnimatePresenceProps = {
@@ -79,7 +79,7 @@ const PresenceChild = ({
79
79
  ? "entering"
80
80
  : "exited"
81
81
  );
82
- const timeoutRef = useRef<NodeJS.Timeout>();
82
+ const timeoutRef = useRef<number>();
83
83
  const isInitialMount = useRef(true);
84
84
 
85
85
  const mergedAnimationStyles = {
@@ -109,7 +109,7 @@ const PresenceChild = ({
109
109
  });
110
110
  } else if (!isPresent && animationState !== "exited") {
111
111
  setAnimationState("exiting");
112
- timeoutRef.current = setTimeout(() => {
112
+ timeoutRef.current = window.setTimeout(() => {
113
113
  setAnimationState("exited");
114
114
  onExitComplete();
115
115
  }, duration);
@@ -50,7 +50,6 @@ const AvatarRoot = React.forwardRef<
50
50
  style={dynamicStyles}
51
51
  className={cx(
52
52
  "flex items-center justify-center align-middle overflow-hidden select-none cursor-pointer rounded-full",
53
- !disabled && "hover:translate-y-[-1px]",
54
53
  variant !== "bare" && "border border-background",
55
54
  className
56
55
  )}
@@ -126,6 +125,9 @@ export type AvatarProps = {
126
125
  /** @default normal */
127
126
  variant?: "normal" | "bare";
128
127
  disabled?: boolean;
128
+ /** Intended to be used in place of a fallback monogram, like an icon */
129
+ children?: React.ReactNode;
130
+ onClick?: () => void;
129
131
  };
130
132
 
131
133
  export const Avatar = React.memo(
@@ -137,6 +139,7 @@ export const Avatar = React.memo(
137
139
  size = 21,
138
140
  backgroundColor,
139
141
  variant = "normal",
142
+ children,
140
143
  ...props
141
144
  }: AvatarProps,
142
145
  forwardedRef: React.ForwardedRef<HTMLDivElement>
@@ -160,7 +163,7 @@ export const Avatar = React.memo(
160
163
  size={size}
161
164
  variant={variant}
162
165
  >
163
- {getMonogram(name)}
166
+ {children ?? getMonogram(name)}
164
167
  </AvatarFallback>
165
168
  )}
166
169
  </AvatarRoot>