@noya-app/noya-designsystem 0.1.76 → 0.1.78

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 (86) hide show
  1. package/.turbo/turbo-build.log +20 -12
  2. package/CHANGELOG.md +19 -0
  3. package/dist/chunk-D57E6H3M.mjs +36 -0
  4. package/dist/chunk-D57E6H3M.mjs.map +1 -0
  5. package/dist/emojis.d.mts +1 -0
  6. package/dist/emojis.d.ts +1 -0
  7. package/dist/emojis.js +31 -0
  8. package/dist/emojis.js.map +1 -0
  9. package/dist/emojis.mjs +8 -0
  10. package/dist/emojis.mjs.map +1 -0
  11. package/dist/index.css +1 -1
  12. package/dist/index.d.mts +192 -93
  13. package/dist/index.d.ts +192 -93
  14. package/dist/index.js +3355 -1763
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +3622 -2030
  17. package/dist/index.mjs.map +1 -1
  18. package/package.json +13 -9
  19. package/src/__tests__/__snapshots__/fuzzyScorer.test.ts.snap +1 -1
  20. package/src/components/Avatar.tsx +10 -10
  21. package/src/components/Banner.tsx +1 -1
  22. package/src/components/BaseToolbar.tsx +20 -6
  23. package/src/components/Button.tsx +2 -1
  24. package/src/components/Chip.tsx +1 -1
  25. package/src/components/ColorSwatchControl.tsx +22 -14
  26. package/src/components/CommandPalette.tsx +10 -6
  27. package/src/components/ContextMenu.tsx +249 -38
  28. package/src/components/Dialog.tsx +70 -67
  29. package/src/components/Drawer.tsx +56 -17
  30. package/src/components/DropdownMenu.tsx +307 -46
  31. package/src/components/EditableText.tsx +1 -1
  32. package/src/components/EmojiPicker.tsx +645 -0
  33. package/src/components/GridBackground.tsx +40 -0
  34. package/src/components/GridView.tsx +5 -1
  35. package/src/components/IVirtualizedList.tsx +21 -1
  36. package/src/components/InputField.tsx +2 -10
  37. package/src/components/InspectorContainer.tsx +4 -2
  38. package/src/components/Message.tsx +5 -16
  39. package/src/components/OverlayToolbar.tsx +97 -0
  40. package/src/components/Popover.tsx +73 -107
  41. package/src/components/Progress.tsx +18 -18
  42. package/src/components/ScrollArea.tsx +66 -31
  43. package/src/components/ScrollableSidebar.tsx +1 -1
  44. package/src/components/SegmentedControl.tsx +166 -38
  45. package/src/components/SelectMenu.tsx +193 -101
  46. package/src/components/Slider.tsx +40 -38
  47. package/src/components/StackNavigator.tsx +1 -0
  48. package/src/components/Switch.tsx +4 -4
  49. package/src/components/TextArea.tsx +1 -1
  50. package/src/components/Toast.tsx +99 -26
  51. package/src/components/Toolbar.tsx +133 -24
  52. package/src/components/Tooltip.tsx +18 -8
  53. package/src/components/UserPointer.tsx +5 -3
  54. package/src/components/Virtualized.tsx +193 -14
  55. package/src/components/VisuallyHidden.tsx +20 -0
  56. package/src/components/__tests__/Virtualized.math.test.ts +426 -1
  57. package/src/components/__tests__/Virtualized.test.tsx +129 -1
  58. package/src/components/ai-assistant/AIAssistantLayout.tsx +11 -6
  59. package/src/components/internal/Menu.tsx +4 -0
  60. package/src/components/listView/ListViewEditableRowTitle.tsx +1 -1
  61. package/src/components/listView/ListViewRoot.tsx +5 -1
  62. package/src/components/resizablePanels/Panel.tsx +94 -0
  63. package/src/components/resizablePanels/PanelGroup.tsx +498 -0
  64. package/src/components/resizablePanels/PanelGroupContext.tsx +14 -0
  65. package/src/components/resizablePanels/PanelResizeHandle.tsx +61 -0
  66. package/src/components/resizablePanels/index.ts +7 -0
  67. package/src/components/resizablePanels/types.ts +65 -0
  68. package/src/components/workspace/DrawerWorkspaceLayout.tsx +22 -7
  69. package/src/components/workspace/PanelWorkspaceLayout.tsx +30 -84
  70. package/src/components/workspace/WorkspaceLayout.tsx +82 -76
  71. package/src/components/workspace/types.ts +6 -4
  72. package/src/contexts/DialogContext.tsx +15 -24
  73. package/src/emojis.ts +1 -0
  74. package/src/hooks/useTriggerToggle.ts +95 -0
  75. package/src/index.css +2 -0
  76. package/src/index.tsx +3 -2
  77. package/src/theme/proseTheme.ts +22 -0
  78. package/src/utils/mergeProps.ts +57 -0
  79. package/src/utils/skinTone.ts +90 -0
  80. package/tailwind.config.ts +6 -1
  81. package/tsup.config.ts +1 -1
  82. package/src/__tests__/workspaceLayout.test.ts +0 -281
  83. package/src/components/ScrollArea2.tsx +0 -76
  84. package/src/components/internal/MenuViewport.tsx +0 -178
  85. package/src/components/internal/SelectItem.tsx +0 -138
  86. 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
- expect(offset).toBe(120);
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: boolean;
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-2 n-pb-4 n-pt-1">
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-pt-1 n-px-1">{renderInput()}</div>
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-1.5 -n-my-1 n-bg-listview-editing-background",
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 ? <ScrollArea>{children}</ScrollArea> : children;
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) => {