@noya-app/noya-designsystem 0.1.77 → 0.1.79
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 +20 -12
- package/CHANGELOG.md +15 -0
- package/dist/chunk-D57E6H3M.mjs +36 -0
- package/dist/chunk-D57E6H3M.mjs.map +1 -0
- package/dist/chunk-FJ6ZGZIA.mjs +43 -0
- package/dist/chunk-FJ6ZGZIA.mjs.map +1 -0
- package/dist/emojis.d.mts +1 -0
- package/dist/emojis.d.ts +1 -0
- package/dist/emojis.js +31 -0
- package/dist/emojis.js.map +1 -0
- package/dist/emojis.mjs +8 -0
- package/dist/emojis.mjs.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +142 -90
- package/dist/index.d.ts +142 -90
- package/dist/index.js +30559 -6412
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +35081 -10928
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -17
- package/src/__tests__/__snapshots__/fuzzyScorer.test.ts.snap +1 -1
- package/src/components/Avatar.tsx +10 -10
- package/src/components/Banner.tsx +1 -1
- package/src/components/BaseToolbar.tsx +1 -1
- package/src/components/Button.tsx +2 -1
- package/src/components/Chip.tsx +1 -1
- package/src/components/ColorSwatchControl.tsx +22 -14
- package/src/components/CommandPalette.tsx +10 -6
- package/src/components/ContextMenu.tsx +249 -38
- package/src/components/Dialog.tsx +70 -67
- package/src/components/Drawer.tsx +56 -17
- package/src/components/DropdownMenu.tsx +305 -46
- package/src/components/EditableText.tsx +1 -1
- package/src/components/EmojiPicker.tsx +645 -0
- package/src/components/GridView.tsx +5 -1
- package/src/components/IVirtualizedList.tsx +21 -1
- package/src/components/InputField.tsx +2 -10
- package/src/components/InspectorContainer.tsx +4 -2
- package/src/components/Message.tsx +5 -16
- package/src/components/Popover.tsx +73 -107
- package/src/components/Progress.tsx +18 -18
- package/src/components/ScrollArea.tsx +66 -31
- package/src/components/ScrollableSidebar.tsx +1 -1
- package/src/components/SegmentedControl.tsx +166 -38
- package/src/components/SelectMenu.tsx +193 -101
- package/src/components/Slider.tsx +40 -38
- package/src/components/Switch.tsx +4 -4
- package/src/components/TextArea.tsx +1 -1
- package/src/components/Toast.tsx +99 -26
- package/src/components/Toolbar.tsx +114 -16
- package/src/components/Tooltip.tsx +18 -8
- package/src/components/Virtualized.tsx +193 -14
- package/src/components/VisuallyHidden.tsx +20 -0
- package/src/components/__tests__/Virtualized.math.test.ts +426 -1
- package/src/components/__tests__/Virtualized.test.tsx +129 -1
- package/src/components/ai-assistant/AIAssistantLayout.tsx +11 -6
- package/src/components/internal/Menu.tsx +4 -0
- package/src/components/listView/ListViewEditableRowTitle.tsx +1 -1
- package/src/components/listView/ListViewRoot.tsx +5 -1
- package/src/components/resizablePanels/Panel.tsx +94 -0
- package/src/components/resizablePanels/PanelGroup.tsx +498 -0
- package/src/components/resizablePanels/PanelGroupContext.tsx +14 -0
- package/src/components/resizablePanels/PanelResizeHandle.tsx +61 -0
- package/src/components/resizablePanels/index.ts +7 -0
- package/src/components/resizablePanels/types.ts +65 -0
- package/src/components/workspace/DrawerWorkspaceLayout.tsx +22 -7
- package/src/components/workspace/PanelWorkspaceLayout.tsx +30 -84
- package/src/components/workspace/WorkspaceLayout.tsx +28 -58
- package/src/components/workspace/types.ts +6 -4
- package/src/contexts/DialogContext.tsx +15 -24
- package/src/emojis.ts +1 -0
- package/src/hooks/useTriggerToggle.ts +95 -0
- package/src/index.css +2 -0
- package/src/index.tsx +1 -2
- package/src/theme/proseTheme.ts +2 -0
- package/src/utils/mergeProps.ts +57 -0
- package/src/utils/skinTone.ts +90 -0
- package/tsup.config.ts +3 -1
- package/src/__tests__/workspaceLayout.test.ts +0 -281
- package/src/components/ScrollArea2.tsx +0 -76
- package/src/components/internal/MenuViewport.tsx +0 -178
- package/src/components/internal/SelectItem.tsx +0 -138
- package/src/components/workspace/panelStorage.ts +0 -216
|
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
buildMeasurements,
|
|
4
4
|
calculateScrollOffsetForIndex,
|
|
5
|
+
calculateStickyRows,
|
|
5
6
|
calculateVisibleRange,
|
|
6
7
|
clampGapValue,
|
|
7
8
|
findFirstVisibleIndex,
|
|
@@ -120,7 +121,9 @@ test("calculateScrollOffsetForIndex scrolls down when item below viewport", () =
|
|
|
120
121
|
currentScrollTop: 0,
|
|
121
122
|
});
|
|
122
123
|
|
|
123
|
-
|
|
124
|
+
// With default "auto" alignment, scrolls minimum distance to show item at bottom
|
|
125
|
+
// Item 4 ends at 150, viewport is 60, so scrollTop = 150 - 60 = 90
|
|
126
|
+
expect(offset).toBe(90);
|
|
124
127
|
});
|
|
125
128
|
|
|
126
129
|
test("calculateScrollOffsetForIndex clamps to end of content", () => {
|
|
@@ -151,3 +154,425 @@ test("calculateScrollOffsetForIndex aligns bottom when item taller than viewport
|
|
|
151
154
|
|
|
152
155
|
expect(offset).toBe(180);
|
|
153
156
|
});
|
|
157
|
+
|
|
158
|
+
// Alignment option tests
|
|
159
|
+
test("calculateScrollOffsetForIndex with align='start' always aligns to top", () => {
|
|
160
|
+
// Item is below viewport - should align to top (start)
|
|
161
|
+
const offset = calculateScrollOffsetForIndex({
|
|
162
|
+
measurements: extendedMeasurements,
|
|
163
|
+
height: 60,
|
|
164
|
+
totalHeight: 210,
|
|
165
|
+
targetIndex: 4,
|
|
166
|
+
currentScrollTop: 0,
|
|
167
|
+
align: "start",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Item 4 starts at 120, so scroll to 120 (top-aligned)
|
|
171
|
+
expect(offset).toBe(120);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("calculateScrollOffsetForIndex with align='start' scrolls even when visible", () => {
|
|
175
|
+
// Item is visible but not at top - should still scroll to top-align it
|
|
176
|
+
const offset = calculateScrollOffsetForIndex({
|
|
177
|
+
measurements: extendedMeasurements,
|
|
178
|
+
height: 90,
|
|
179
|
+
totalHeight: 210,
|
|
180
|
+
targetIndex: 2,
|
|
181
|
+
currentScrollTop: 30, // Item 2 (60-90) is visible within viewport (30-120)
|
|
182
|
+
align: "start",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Should scroll to align item 2 at top (start at 60)
|
|
186
|
+
expect(offset).toBe(60);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("calculateScrollOffsetForIndex with align='end' always aligns to bottom", () => {
|
|
190
|
+
// Item is above viewport - should align to bottom (end)
|
|
191
|
+
const offset = calculateScrollOffsetForIndex({
|
|
192
|
+
measurements: extendedMeasurements,
|
|
193
|
+
height: 60,
|
|
194
|
+
totalHeight: 210,
|
|
195
|
+
targetIndex: 1,
|
|
196
|
+
currentScrollTop: 120,
|
|
197
|
+
align: "end",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Item 1 ends at 60, viewport is 60px, so scroll to 60 - 60 = 0
|
|
201
|
+
expect(offset).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("calculateScrollOffsetForIndex with align='end' scrolls even when visible", () => {
|
|
205
|
+
// Item is visible but not at bottom - should still scroll to bottom-align it
|
|
206
|
+
const offset = calculateScrollOffsetForIndex({
|
|
207
|
+
measurements: extendedMeasurements,
|
|
208
|
+
height: 90,
|
|
209
|
+
totalHeight: 210,
|
|
210
|
+
targetIndex: 2,
|
|
211
|
+
currentScrollTop: 60, // Item 2 (60-90) is visible at top of viewport (60-150)
|
|
212
|
+
align: "end",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Should scroll to align item 2 at bottom (end at 90, viewport 90, so scrollTop = 90 - 90 = 0)
|
|
216
|
+
expect(offset).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("calculateScrollOffsetForIndex with align='center' centers item in viewport", () => {
|
|
220
|
+
const offset = calculateScrollOffsetForIndex({
|
|
221
|
+
measurements: extendedMeasurements,
|
|
222
|
+
height: 90,
|
|
223
|
+
totalHeight: 210,
|
|
224
|
+
targetIndex: 3,
|
|
225
|
+
currentScrollTop: 0,
|
|
226
|
+
align: "center",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Item 3: start=90, size=30, viewport=90
|
|
230
|
+
// Center offset = 90 - (90 - 30) / 2 = 90 - 30 = 60
|
|
231
|
+
expect(offset).toBe(60);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("calculateScrollOffsetForIndex with align='center' clamps to bounds", () => {
|
|
235
|
+
const offset = calculateScrollOffsetForIndex({
|
|
236
|
+
measurements: extendedMeasurements,
|
|
237
|
+
height: 90,
|
|
238
|
+
totalHeight: 210,
|
|
239
|
+
targetIndex: 0,
|
|
240
|
+
currentScrollTop: 100,
|
|
241
|
+
align: "center",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Item 0: start=0, size=30, viewport=90
|
|
245
|
+
// Center offset = 0 - (90 - 30) / 2 = -30, clamped to 0
|
|
246
|
+
expect(offset).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("calculateScrollOffsetForIndex with align='auto' returns undefined when visible", () => {
|
|
250
|
+
const offset = calculateScrollOffsetForIndex({
|
|
251
|
+
measurements: extendedMeasurements,
|
|
252
|
+
height: 90,
|
|
253
|
+
totalHeight: 210,
|
|
254
|
+
targetIndex: 2,
|
|
255
|
+
currentScrollTop: 30, // Item 2 (60-90) is fully visible within viewport (30-120)
|
|
256
|
+
align: "auto",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(offset).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("calculateScrollOffsetForIndex with align='auto' scrolls minimum distance (item below)", () => {
|
|
263
|
+
const offset = calculateScrollOffsetForIndex({
|
|
264
|
+
measurements: extendedMeasurements,
|
|
265
|
+
height: 60,
|
|
266
|
+
totalHeight: 210,
|
|
267
|
+
targetIndex: 4,
|
|
268
|
+
currentScrollTop: 0,
|
|
269
|
+
align: "auto",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Item 4 ends at 150, viewport is 60, so minimum scroll puts it at bottom
|
|
273
|
+
// scrollTop = 150 - 60 = 90
|
|
274
|
+
expect(offset).toBe(90);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("calculateScrollOffsetForIndex with align='auto' scrolls minimum distance (item above)", () => {
|
|
278
|
+
const offset = calculateScrollOffsetForIndex({
|
|
279
|
+
measurements: extendedMeasurements,
|
|
280
|
+
height: 60,
|
|
281
|
+
totalHeight: 210,
|
|
282
|
+
targetIndex: 1,
|
|
283
|
+
currentScrollTop: 120,
|
|
284
|
+
align: "auto",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Item 1 starts at 30, so scroll to show it at top
|
|
288
|
+
expect(offset).toBe(30);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Padding option tests
|
|
292
|
+
test("calculateScrollOffsetForIndex with padding number applies to start alignment", () => {
|
|
293
|
+
const offset = calculateScrollOffsetForIndex({
|
|
294
|
+
measurements: extendedMeasurements,
|
|
295
|
+
height: 60,
|
|
296
|
+
totalHeight: 210,
|
|
297
|
+
targetIndex: 3,
|
|
298
|
+
currentScrollTop: 0,
|
|
299
|
+
align: "start",
|
|
300
|
+
padding: 10,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Item 3 starts at 90, with 10px padding above: 90 - 10 = 80
|
|
304
|
+
expect(offset).toBe(80);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("calculateScrollOffsetForIndex with padding number applies to end alignment", () => {
|
|
308
|
+
const offset = calculateScrollOffsetForIndex({
|
|
309
|
+
measurements: extendedMeasurements,
|
|
310
|
+
height: 60,
|
|
311
|
+
totalHeight: 210,
|
|
312
|
+
targetIndex: 2,
|
|
313
|
+
currentScrollTop: 150,
|
|
314
|
+
align: "end",
|
|
315
|
+
padding: 10,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Item 2 ends at 90, viewport 60, with 10px padding below: 90 - 60 + 10 = 40
|
|
319
|
+
expect(offset).toBe(40);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("calculateScrollOffsetForIndex with padding object applies top padding", () => {
|
|
323
|
+
const offset = calculateScrollOffsetForIndex({
|
|
324
|
+
measurements: extendedMeasurements,
|
|
325
|
+
height: 60,
|
|
326
|
+
totalHeight: 210,
|
|
327
|
+
targetIndex: 3,
|
|
328
|
+
currentScrollTop: 0,
|
|
329
|
+
align: "start",
|
|
330
|
+
padding: { top: 15 },
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Item 3 starts at 90, with 15px padding above: 90 - 15 = 75
|
|
334
|
+
expect(offset).toBe(75);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("calculateScrollOffsetForIndex with padding object applies bottom padding", () => {
|
|
338
|
+
const offset = calculateScrollOffsetForIndex({
|
|
339
|
+
measurements: extendedMeasurements,
|
|
340
|
+
height: 60,
|
|
341
|
+
totalHeight: 210,
|
|
342
|
+
targetIndex: 2,
|
|
343
|
+
currentScrollTop: 150,
|
|
344
|
+
align: "end",
|
|
345
|
+
padding: { bottom: 20 },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Item 2 ends at 90, viewport 60, with 20px padding below: 90 - 60 + 20 = 50
|
|
349
|
+
expect(offset).toBe(50);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("calculateScrollOffsetForIndex with auto alignment uses padding when scrolling up", () => {
|
|
353
|
+
const offset = calculateScrollOffsetForIndex({
|
|
354
|
+
measurements: extendedMeasurements,
|
|
355
|
+
height: 60,
|
|
356
|
+
totalHeight: 210,
|
|
357
|
+
targetIndex: 1,
|
|
358
|
+
currentScrollTop: 120,
|
|
359
|
+
align: "auto",
|
|
360
|
+
padding: { top: 10, bottom: 5 },
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Item 1 starts at 30, scrolling up uses top padding: 30 - 10 = 20
|
|
364
|
+
expect(offset).toBe(20);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("calculateScrollOffsetForIndex with auto alignment uses padding when scrolling down", () => {
|
|
368
|
+
const offset = calculateScrollOffsetForIndex({
|
|
369
|
+
measurements: extendedMeasurements,
|
|
370
|
+
height: 60,
|
|
371
|
+
totalHeight: 210,
|
|
372
|
+
targetIndex: 4,
|
|
373
|
+
currentScrollTop: 0,
|
|
374
|
+
align: "auto",
|
|
375
|
+
padding: { top: 10, bottom: 15 },
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Item 4 ends at 150, viewport 60, scrolling down uses bottom padding: 150 - 60 + 15 = 105
|
|
379
|
+
expect(offset).toBe(105);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("calculateScrollOffsetForIndex with padding clamps to bounds", () => {
|
|
383
|
+
const offset = calculateScrollOffsetForIndex({
|
|
384
|
+
measurements: extendedMeasurements,
|
|
385
|
+
height: 60,
|
|
386
|
+
totalHeight: 210,
|
|
387
|
+
targetIndex: 0,
|
|
388
|
+
currentScrollTop: 100,
|
|
389
|
+
align: "start",
|
|
390
|
+
padding: 20,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Item 0 starts at 0, with 20px padding: 0 - 20 = -20, clamped to 0
|
|
394
|
+
expect(offset).toBe(0);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// calculateStickyRows tests
|
|
398
|
+
|
|
399
|
+
test("calculateStickyRows returns empty array when no rows are sticky", () => {
|
|
400
|
+
const measurements: ItemMeasurement[] = [
|
|
401
|
+
{ index: 0, size: 20, start: 0, end: 20 },
|
|
402
|
+
{ index: 1, size: 20, start: 20, end: 40 },
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
const result = calculateStickyRows({
|
|
406
|
+
measurements,
|
|
407
|
+
scrollTop: 0,
|
|
408
|
+
isStickyRow: () => false,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(result).toEqual([]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("calculateStickyRows returns empty array when scrolled above first sticky row", () => {
|
|
415
|
+
const measurements: ItemMeasurement[] = [
|
|
416
|
+
{ index: 0, size: 20, start: 0, end: 20 },
|
|
417
|
+
{ index: 1, size: 24, start: 20, end: 44 },
|
|
418
|
+
{ index: 2, size: 20, start: 44, end: 64 },
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
// Only row 1 is sticky, but we haven't scrolled past it yet
|
|
422
|
+
const result = calculateStickyRows({
|
|
423
|
+
measurements,
|
|
424
|
+
scrollTop: 10, // Still above row 1 which starts at 20
|
|
425
|
+
isStickyRow: (index) => index === 1,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(result).toEqual([]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("calculateStickyRows shows first sticky row at scrollTop 0", () => {
|
|
432
|
+
const measurements: ItemMeasurement[] = [
|
|
433
|
+
{ index: 0, size: 24, start: 0, end: 24 },
|
|
434
|
+
{ index: 1, size: 20, start: 24, end: 44 },
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
const result = calculateStickyRows({
|
|
438
|
+
measurements,
|
|
439
|
+
scrollTop: 0,
|
|
440
|
+
isStickyRow: (index) => index === 0,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
expect(result).toEqual([{ index: 0, stickyTop: 0 }]);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("calculateStickyRows keeps sticky row at top while scrolling within section", () => {
|
|
447
|
+
const measurements: ItemMeasurement[] = [
|
|
448
|
+
{ index: 0, size: 24, start: 0, end: 24 },
|
|
449
|
+
{ index: 1, size: 20, start: 24, end: 44 },
|
|
450
|
+
{ index: 2, size: 20, start: 44, end: 64 },
|
|
451
|
+
{ index: 3, size: 24, start: 64, end: 88 },
|
|
452
|
+
];
|
|
453
|
+
|
|
454
|
+
const isStickyRow = (index: number) => index === 0 || index === 3;
|
|
455
|
+
|
|
456
|
+
// Scrolling within the first section (before row 3 approaches)
|
|
457
|
+
const result = calculateStickyRows({
|
|
458
|
+
measurements,
|
|
459
|
+
scrollTop: 30,
|
|
460
|
+
isStickyRow,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(result).toEqual([{ index: 0, stickyTop: 0 }]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("calculateStickyRows pushes current sticky row up as next approaches", () => {
|
|
467
|
+
const measurements: ItemMeasurement[] = [
|
|
468
|
+
{ index: 0, size: 24, start: 0, end: 24 },
|
|
469
|
+
{ index: 1, size: 20, start: 24, end: 44 },
|
|
470
|
+
{ index: 2, size: 24, start: 44, end: 68 },
|
|
471
|
+
];
|
|
472
|
+
|
|
473
|
+
const isStickyRow = (index: number) => index === 0 || index === 2;
|
|
474
|
+
|
|
475
|
+
// Row 0 has size 24, row 2 starts at 44
|
|
476
|
+
// Push starts when scrollTop + 24 > 44, i.e., scrollTop > 20
|
|
477
|
+
// At scrollTop 30: pushDistance = 30 + 24 - 44 = 10
|
|
478
|
+
const result = calculateStickyRows({
|
|
479
|
+
measurements,
|
|
480
|
+
scrollTop: 30,
|
|
481
|
+
isStickyRow,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
expect(result).toEqual([{ index: 0, stickyTop: -10 }]);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("calculateStickyRows transitions to next sticky row when fully pushed out", () => {
|
|
488
|
+
const measurements: ItemMeasurement[] = [
|
|
489
|
+
{ index: 0, size: 24, start: 0, end: 24 },
|
|
490
|
+
{ index: 1, size: 20, start: 24, end: 44 },
|
|
491
|
+
{ index: 2, size: 24, start: 44, end: 68 },
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
const isStickyRow = (index: number) => index === 0 || index === 2;
|
|
495
|
+
|
|
496
|
+
// At scrollTop 44, row 2 takes over
|
|
497
|
+
const result = calculateStickyRows({
|
|
498
|
+
measurements,
|
|
499
|
+
scrollTop: 44,
|
|
500
|
+
isStickyRow,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(result).toEqual([{ index: 2, stickyTop: 0 }]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("calculateStickyRows handles multiple sticky rows in sequence", () => {
|
|
507
|
+
const measurements: ItemMeasurement[] = [
|
|
508
|
+
{ index: 0, size: 30, start: 0, end: 30 },
|
|
509
|
+
{ index: 1, size: 20, start: 30, end: 50 },
|
|
510
|
+
{ index: 2, size: 30, start: 50, end: 80 },
|
|
511
|
+
{ index: 3, size: 20, start: 80, end: 100 },
|
|
512
|
+
{ index: 4, size: 30, start: 100, end: 130 },
|
|
513
|
+
];
|
|
514
|
+
|
|
515
|
+
const isStickyRow = (index: number) => index === 0 || index === 2 || index === 4;
|
|
516
|
+
|
|
517
|
+
// Scrolled past row 2, row 2 is now sticky
|
|
518
|
+
const result1 = calculateStickyRows({
|
|
519
|
+
measurements,
|
|
520
|
+
scrollTop: 60,
|
|
521
|
+
isStickyRow,
|
|
522
|
+
});
|
|
523
|
+
expect(result1).toEqual([{ index: 2, stickyTop: 0 }]);
|
|
524
|
+
|
|
525
|
+
// Scrolled past row 4, row 4 is now sticky
|
|
526
|
+
const result2 = calculateStickyRows({
|
|
527
|
+
measurements,
|
|
528
|
+
scrollTop: 100,
|
|
529
|
+
isStickyRow,
|
|
530
|
+
});
|
|
531
|
+
expect(result2).toEqual([{ index: 4, stickyTop: 0 }]);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("calculateStickyRows handles last sticky row without next", () => {
|
|
535
|
+
const measurements: ItemMeasurement[] = [
|
|
536
|
+
{ index: 0, size: 24, start: 0, end: 24 },
|
|
537
|
+
{ index: 1, size: 20, start: 24, end: 44 },
|
|
538
|
+
{ index: 2, size: 20, start: 44, end: 64 },
|
|
539
|
+
];
|
|
540
|
+
|
|
541
|
+
// Only row 0 is sticky - no next sticky row to push it
|
|
542
|
+
const result = calculateStickyRows({
|
|
543
|
+
measurements,
|
|
544
|
+
scrollTop: 40,
|
|
545
|
+
isStickyRow: (index) => index === 0,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
expect(result).toEqual([{ index: 0, stickyTop: 0 }]);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("calculateStickyRows handles empty measurements", () => {
|
|
552
|
+
const result = calculateStickyRows({
|
|
553
|
+
measurements: [],
|
|
554
|
+
scrollTop: 0,
|
|
555
|
+
isStickyRow: () => true,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(result).toEqual([]);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("calculateStickyRows push distance calculation at boundary", () => {
|
|
562
|
+
const measurements: ItemMeasurement[] = [
|
|
563
|
+
{ index: 0, size: 24, start: 0, end: 24 },
|
|
564
|
+
{ index: 1, size: 20, start: 24, end: 44 },
|
|
565
|
+
{ index: 2, size: 24, start: 44, end: 68 },
|
|
566
|
+
];
|
|
567
|
+
|
|
568
|
+
const isStickyRow = (index: number) => index === 0 || index === 2;
|
|
569
|
+
|
|
570
|
+
// At scrollTop 20: pushDistance = 20 + 24 - 44 = 0 (no push yet)
|
|
571
|
+
const result = calculateStickyRows({
|
|
572
|
+
measurements,
|
|
573
|
+
scrollTop: 20,
|
|
574
|
+
isStickyRow,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
expect(result).toEqual([{ index: 0, stickyTop: 0 }]);
|
|
578
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { act, render } from "@testing-library/react";
|
|
2
2
|
import { expect, test } from "bun:test";
|
|
3
3
|
import React from "react";
|
|
4
|
-
import { Virtualized } from "../Virtualized";
|
|
4
|
+
import { calculateStickyRows, Virtualized } from "../Virtualized";
|
|
5
5
|
|
|
6
6
|
test("virtualized window updates visible rows when scrolling", () => {
|
|
7
7
|
const items = Array.from({ length: 100 }, (_, index) => `Row ${index + 1}`);
|
|
@@ -43,6 +43,7 @@ test("accounts for padding and gap", () => {
|
|
|
43
43
|
);
|
|
44
44
|
|
|
45
45
|
const scroller = container.firstChild as HTMLDivElement;
|
|
46
|
+
// No sticky rows, so first child is inner (totalContent)
|
|
46
47
|
const inner = scroller.firstChild as HTMLDivElement;
|
|
47
48
|
const firstRow = inner.firstChild as HTMLDivElement;
|
|
48
49
|
const secondRow = firstRow.nextSibling as HTMLDivElement;
|
|
@@ -83,3 +84,130 @@ test("supports ScrollArea as the scroller", () => {
|
|
|
83
84
|
expect(queryByTestId("scroll-row-0")).toBeNull();
|
|
84
85
|
expect(getByTestId("scroll-row-10")).toBeTruthy();
|
|
85
86
|
});
|
|
87
|
+
|
|
88
|
+
test("calculateStickyRows returns empty array when no rows are sticky", () => {
|
|
89
|
+
const measurements = [
|
|
90
|
+
{ index: 0, size: 20, start: 0, end: 20 },
|
|
91
|
+
{ index: 1, size: 20, start: 20, end: 40 },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const result = calculateStickyRows({
|
|
95
|
+
measurements,
|
|
96
|
+
scrollTop: 0,
|
|
97
|
+
isStickyRow: () => false,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(result).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("calculateStickyRows shows only current sticky row", () => {
|
|
104
|
+
const measurements = [
|
|
105
|
+
{ index: 0, size: 24, start: 0, end: 24 },
|
|
106
|
+
{ index: 1, size: 20, start: 24, end: 44 },
|
|
107
|
+
{ index: 2, size: 24, start: 44, end: 68 },
|
|
108
|
+
{ index: 3, size: 20, start: 68, end: 88 },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
// Row 0 and 2 are sticky (like section headers)
|
|
112
|
+
const isStickyRow = (index: number) => index === 0 || index === 2;
|
|
113
|
+
|
|
114
|
+
// At scrollTop 0, row 0 is at its natural position (start=0)
|
|
115
|
+
const result1 = calculateStickyRows({
|
|
116
|
+
measurements,
|
|
117
|
+
scrollTop: 0,
|
|
118
|
+
isStickyRow,
|
|
119
|
+
});
|
|
120
|
+
expect(result1).toEqual([{ index: 0, stickyTop: 0 }]);
|
|
121
|
+
|
|
122
|
+
// At scrollTop 10, row 0 still sticky at top
|
|
123
|
+
const result2 = calculateStickyRows({
|
|
124
|
+
measurements,
|
|
125
|
+
scrollTop: 10,
|
|
126
|
+
isStickyRow,
|
|
127
|
+
});
|
|
128
|
+
expect(result2).toEqual([{ index: 0, stickyTop: 0 }]);
|
|
129
|
+
|
|
130
|
+
// At scrollTop 44, row 2 becomes the current sticky (replaces row 0)
|
|
131
|
+
const result3 = calculateStickyRows({
|
|
132
|
+
measurements,
|
|
133
|
+
scrollTop: 44,
|
|
134
|
+
isStickyRow,
|
|
135
|
+
});
|
|
136
|
+
expect(result3).toEqual([{ index: 2, stickyTop: 0 }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("calculateStickyRows pushes current sticky up during transition", () => {
|
|
140
|
+
const measurements = [
|
|
141
|
+
{ index: 0, size: 24, start: 0, end: 24 },
|
|
142
|
+
{ index: 1, size: 20, start: 24, end: 44 },
|
|
143
|
+
{ index: 2, size: 24, start: 44, end: 68 },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const isStickyRow = (index: number) => index === 0 || index === 2;
|
|
147
|
+
|
|
148
|
+
// Row 2 starts at 44, row 0 has size 24
|
|
149
|
+
// Push starts when scrollTop + 24 > 44, i.e., scrollTop > 20
|
|
150
|
+
|
|
151
|
+
// At scrollTop 20, no push yet (20 + 24 = 44, exactly at row 2 start)
|
|
152
|
+
const result1 = calculateStickyRows({
|
|
153
|
+
measurements,
|
|
154
|
+
scrollTop: 20,
|
|
155
|
+
isStickyRow,
|
|
156
|
+
});
|
|
157
|
+
expect(result1).toEqual([{ index: 0, stickyTop: 0 }]);
|
|
158
|
+
|
|
159
|
+
// At scrollTop 30, row 0 is pushed up by 10px (30 + 24 - 44 = 10)
|
|
160
|
+
const result2 = calculateStickyRows({
|
|
161
|
+
measurements,
|
|
162
|
+
scrollTop: 30,
|
|
163
|
+
isStickyRow,
|
|
164
|
+
});
|
|
165
|
+
expect(result2).toEqual([{ index: 0, stickyTop: -10 }]);
|
|
166
|
+
|
|
167
|
+
// At scrollTop 44, row 2 takes over completely
|
|
168
|
+
const result3 = calculateStickyRows({
|
|
169
|
+
measurements,
|
|
170
|
+
scrollTop: 44,
|
|
171
|
+
isStickyRow,
|
|
172
|
+
});
|
|
173
|
+
expect(result3).toEqual([{ index: 2, stickyTop: 0 }]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("sticky rows render in overlay when scrolled", () => {
|
|
177
|
+
const items = Array.from({ length: 20 }, (_, index) => `Row ${index}`);
|
|
178
|
+
const isStickyRow = (index: number) => index % 5 === 0; // 0, 5, 10, 15 are sticky
|
|
179
|
+
|
|
180
|
+
const { container, getAllByTestId, queryAllByTestId } = render(
|
|
181
|
+
<Virtualized
|
|
182
|
+
height={100}
|
|
183
|
+
itemCount={items.length}
|
|
184
|
+
getItemHeight={() => 20}
|
|
185
|
+
isStickyRow={isStickyRow}
|
|
186
|
+
renderItem={(index) => (
|
|
187
|
+
<div data-testid={`row-${index}`}>{items[index]}</div>
|
|
188
|
+
)}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const scroller = container.firstChild as HTMLDivElement;
|
|
193
|
+
|
|
194
|
+
// Initially row-0 should appear twice (once in content, once in sticky overlay)
|
|
195
|
+
expect(getAllByTestId("row-0")).toHaveLength(2);
|
|
196
|
+
|
|
197
|
+
// Scroll down past row 5 (starts at 100)
|
|
198
|
+
act(() => {
|
|
199
|
+
scroller.scrollTop = 120;
|
|
200
|
+
scroller.dispatchEvent(new Event("scroll"));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Row 5 should now be the sticky row (only one sticky at a time)
|
|
204
|
+
// Row 5 appears in visible range AND in sticky overlay
|
|
205
|
+
const row5Elements = getAllByTestId("row-5");
|
|
206
|
+
expect(row5Elements.length).toBeGreaterThanOrEqual(1);
|
|
207
|
+
|
|
208
|
+
// Row 0 should NOT be in sticky overlay anymore (replaced by row 5)
|
|
209
|
+
// It may still be in visible range depending on overscan
|
|
210
|
+
const row0Elements = queryAllByTestId("row-0");
|
|
211
|
+
// Row 0 is at position 0-20, with scrollTop 120 and overscan 2, it's not visible
|
|
212
|
+
expect(row0Elements.length).toBeLessThanOrEqual(1);
|
|
213
|
+
});
|
|
@@ -32,13 +32,14 @@ type AIAssistantInputProps = {
|
|
|
32
32
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
33
33
|
onSubmitClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
34
34
|
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
35
|
-
disabled
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
submitDisabled?: boolean;
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
export const AIAssistantInput = forwardRef<
|
|
39
40
|
HTMLDivElement,
|
|
40
41
|
AIAssistantInputProps
|
|
41
|
-
>(({ value, onChange, onSubmitClick, onKeyDown, disabled }, ref) => (
|
|
42
|
+
>(({ value, onChange, onSubmitClick, onKeyDown, disabled, submitDisabled }, ref) => (
|
|
42
43
|
<TextAreaRow
|
|
43
44
|
autoResize
|
|
44
45
|
autoFocus
|
|
@@ -53,7 +54,7 @@ export const AIAssistantInput = forwardRef<
|
|
|
53
54
|
<IconButton
|
|
54
55
|
iconName="ArrowUpIcon"
|
|
55
56
|
onClick={onSubmitClick}
|
|
56
|
-
disabled={!value.trim() || disabled}
|
|
57
|
+
disabled={!value.trim() || submitDisabled || disabled}
|
|
57
58
|
className="n-h-8 n-w-8 n-z-20 n-rounded-full"
|
|
58
59
|
size={20}
|
|
59
60
|
color={cssVars.colors.text}
|
|
@@ -90,14 +91,18 @@ export const AIAssistantLayout = forwardRef<
|
|
|
90
91
|
ref
|
|
91
92
|
) => {
|
|
92
93
|
return (
|
|
93
|
-
<div className={cx("n-flex n-flex-col n-flex-1", className)} style={style}>
|
|
94
|
+
<div className={cx("n-flex n-flex-col n-flex-1 n-min-h-0", className)} style={style}>
|
|
94
95
|
<div className="n-flex-1 n-min-h-0 n-overflow-auto" ref={ref}>
|
|
95
|
-
<div className="n-flex n-flex-col n-gap-
|
|
96
|
+
<div className="n-flex n-flex-col n-gap-4 n-p-6 n-max-w-3xl n-mx-auto">
|
|
96
97
|
{renderMessages()}
|
|
97
98
|
{isLoading ? renderLoadingIndicator?.() : null}
|
|
98
99
|
</div>
|
|
99
100
|
</div>
|
|
100
|
-
<div className="n-border-t n-border-divider n-
|
|
101
|
+
<div className="n-border-t n-border-divider n-px-6 n-py-4">
|
|
102
|
+
<div className="n-max-w-3xl n-mx-auto">
|
|
103
|
+
{renderInput()}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
101
106
|
</div>
|
|
102
107
|
);
|
|
103
108
|
}
|
|
@@ -108,6 +108,10 @@ export type PopoverMenuItem = BaseMenuItem & {
|
|
|
108
108
|
content: ReactNode | ((props: { close: () => void }) => ReactNode);
|
|
109
109
|
shortcut?: string;
|
|
110
110
|
tooltip?: ReactNode;
|
|
111
|
+
/** Class name applied to the popover container (not used for dialogs on small screens) */
|
|
112
|
+
popoverClassName?: string;
|
|
113
|
+
/** Extra props to pass to the trigger button (e.g., data attributes) */
|
|
114
|
+
triggerProps?: Record<string, unknown>;
|
|
111
115
|
};
|
|
112
116
|
|
|
113
117
|
export type NonSelectableMenuItem<T extends string> =
|
|
@@ -68,7 +68,7 @@ export const ListViewEditableRowTitle = memo(function ListViewEditableRowTitle({
|
|
|
68
68
|
onSubmit={onSubmitEditing}
|
|
69
69
|
allowSubmittingWithSameValue
|
|
70
70
|
className={cx(
|
|
71
|
-
"-n-mx-
|
|
71
|
+
"-n-mx-input-padding-x -n-my-1 n-bg-listview-editing-background",
|
|
72
72
|
className
|
|
73
73
|
)}
|
|
74
74
|
onKeyDown={onKeyDown}
|
|
@@ -302,7 +302,11 @@ export const ListViewRoot = memoGeneric(
|
|
|
302
302
|
);
|
|
303
303
|
|
|
304
304
|
const withScrollable = (children: ReactNode) =>
|
|
305
|
-
scrollable ?
|
|
305
|
+
scrollable ? (
|
|
306
|
+
<ScrollArea className="n-flex-1 n-min-h-0">{children}</ScrollArea>
|
|
307
|
+
) : (
|
|
308
|
+
children
|
|
309
|
+
);
|
|
306
310
|
|
|
307
311
|
const getItemHeight = useCallback(
|
|
308
312
|
(index: number) => {
|