@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +16 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +221 -124
- package/dist/index.d.ts +221 -124
- package/dist/index.js +7464 -6801
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7867 -7246
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -6
- package/src/__tests__/combobox.test.ts +578 -0
- package/src/components/ActivityIndicator.tsx +4 -4
- package/src/components/AnimatePresence.tsx +5 -5
- package/src/components/Avatar.tsx +5 -2
- package/src/components/BaseToolbar.tsx +61 -0
- package/src/components/Button.tsx +1 -1
- package/src/components/Checkbox.tsx +7 -6
- package/src/components/Combobox.tsx +220 -324
- package/src/components/ComboboxMenu.tsx +88 -0
- package/src/components/Dialog.tsx +10 -6
- package/src/components/DimensionInput.tsx +4 -4
- package/src/components/FillPreviewBackground.tsx +51 -44
- package/src/components/FloatingWindow.tsx +5 -2
- package/src/components/GradientPicker.tsx +14 -14
- package/src/components/IconButton.tsx +6 -12
- package/src/components/Icons.tsx +3 -3
- package/src/components/InputField.tsx +145 -160
- package/src/components/Label.tsx +4 -4
- package/src/components/LabeledElementView.tsx +35 -41
- package/src/components/ListView.tsx +49 -39
- package/src/components/Message.tsx +75 -0
- package/src/components/Progress.tsx +2 -2
- package/src/components/ScrollArea.tsx +7 -5
- package/src/components/SegmentedControl.tsx +171 -0
- package/src/components/SelectMenu.tsx +61 -10
- package/src/components/Slider.tsx +49 -14
- package/src/components/Sortable.tsx +17 -27
- package/src/components/Spacer.tsx +28 -9
- package/src/components/Switch.tsx +8 -8
- package/src/components/TextArea.tsx +23 -13
- package/src/components/Toolbar.tsx +134 -0
- package/src/components/Tooltip.tsx +10 -7
- package/src/components/internal/Menu.tsx +5 -4
- package/src/contexts/DesignSystemConfiguration.tsx +15 -24
- package/src/contexts/DialogContext.tsx +137 -49
- package/src/contexts/ImageDataContext.tsx +6 -12
- package/src/index.css +4 -6
- package/src/index.tsx +12 -3
- package/src/utils/combobox.ts +369 -0
- package/tailwind.config.ts +2 -3
- package/src/components/RadioGroup.tsx +0 -100
- 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.
|
|
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.
|
|
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.
|
|
30
|
-
"@noya-app/noya-keymap": "0.1.
|
|
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.
|
|
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
|
|
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<
|
|
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>
|