@noya-app/noya-designsystem 0.1.66 → 0.1.68

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 (41) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +14 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +81 -5
  5. package/dist/index.d.ts +81 -5
  6. package/dist/index.js +1205 -857
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1047 -698
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +4 -2
  11. package/src/components/Anchor.tsx +30 -0
  12. package/src/components/BaseToolbar.tsx +11 -8
  13. package/src/components/Chip.tsx +16 -2
  14. package/src/components/Collection.tsx +10 -0
  15. package/src/components/Combobox.tsx +30 -21
  16. package/src/components/ComboboxMenu.tsx +49 -18
  17. package/src/components/Dialog.tsx +7 -1
  18. package/src/components/EditableText.tsx +7 -3
  19. package/src/components/Grid.tsx +2 -0
  20. package/src/components/GridView.tsx +52 -9
  21. package/src/components/List.tsx +54 -9
  22. package/src/components/ListView.tsx +44 -0
  23. package/src/components/ScrollArea2.tsx +56 -0
  24. package/src/components/SearchCompletionMenu.tsx +6 -2
  25. package/src/components/TreeView.tsx +4 -0
  26. package/src/components/UserPointer.tsx +34 -7
  27. package/src/components/connected-users-menu/ConnectedUsersMenuLayout.tsx +1 -3
  28. package/src/components/listView/ListViewEditableRowTitle.tsx +23 -1
  29. package/src/components/listView/ListViewRoot.tsx +45 -13
  30. package/src/components/sorting/Sortable.tsx +22 -10
  31. package/src/components/workspace/WorkspaceLayout.tsx +62 -4
  32. package/src/components/workspace/WorkspaceSideContext.tsx +18 -0
  33. package/src/components/workspace/renderPanelChildren.tsx +2 -2
  34. package/src/components/workspace/types.ts +8 -1
  35. package/src/contexts/DialogContext.tsx +22 -10
  36. package/src/index.css +143 -1
  37. package/src/index.tsx +4 -0
  38. package/src/theme/proseTheme.ts +2 -0
  39. package/src/utils/combobox.ts +17 -2
  40. package/src/utils/detailLineClamp.ts +33 -0
  41. package/tailwind.config.ts +3 -1
@@ -15,6 +15,10 @@ import React, {
15
15
  import { useLinkComponent } from "../contexts/LinkComponentContext";
16
16
  import { cssVars } from "../theme";
17
17
  import { cx } from "../utils/classNames";
18
+ import {
19
+ DETAIL_LINE_CLAMP_CLASSES,
20
+ normalizeDetailLineClamp,
21
+ } from "../utils/detailLineClamp";
18
22
  import { updateSelection } from "../utils/selection";
19
23
  import { ActionMenu } from "./ActionMenu";
20
24
  import {
@@ -55,6 +59,7 @@ export const List = memoGeneric(
55
59
  renderAction,
56
60
  renderDetail,
57
61
  renderRight,
62
+ detailLineClamp,
58
63
  onSelectionChange,
59
64
  itemStyle: itemStyleProp,
60
65
  itemClassName: itemClassNameProp,
@@ -82,6 +87,7 @@ export const List = memoGeneric(
82
87
  sortableId,
83
88
  sharedDragProps,
84
89
  virtualized,
90
+ renameSelectsBeforeDot,
85
91
  } = props;
86
92
 
87
93
  const [internalHoveredId, setHoveredId] = useState<string>();
@@ -100,6 +106,8 @@ export const List = memoGeneric(
100
106
  [itemStyleProp]
101
107
  );
102
108
 
109
+ const normalizedDetailLineClamp = normalizeDetailLineClamp(detailLineClamp);
110
+
103
111
  const handleSelect = (itemId: string, event?: ListView.ClickInfo) => {
104
112
  if (!onSelectionChange) return;
105
113
 
@@ -220,12 +228,27 @@ export const List = memoGeneric(
220
228
  />
221
229
  ));
222
230
 
223
- const end =
224
- action ||
225
- (detailPosition === "end" && renderDetail?.(item, isSelected));
231
+ const detailContent = renderDetail?.(item, isSelected);
232
+
233
+ const end = action || (detailPosition === "end" && detailContent);
226
234
  const right = renderRight?.(item, isSelected);
227
- const below =
228
- detailPosition === "below" && renderDetail?.(item, isSelected);
235
+ let below = detailPosition === "below" ? detailContent : undefined;
236
+
237
+ if (below && normalizedDetailLineClamp) {
238
+ const clampClassName =
239
+ DETAIL_LINE_CLAMP_CLASSES[normalizedDetailLineClamp];
240
+
241
+ below = (
242
+ <div
243
+ className={cx(
244
+ "n-overflow-hidden n-w-full n-max-w-full",
245
+ clampClassName
246
+ )}
247
+ >
248
+ {below}
249
+ </div>
250
+ );
251
+ }
229
252
  return (
230
253
  <ViewComponent.Row
231
254
  as={typeof href === "string" ? LinkComponent : undefined}
@@ -242,6 +265,7 @@ export const List = memoGeneric(
242
265
  testShowDropIndicatorId === id ? "inside" : undefined
243
266
  }
244
267
  chevronClassName={cx(expandable && "n-relative n-left-2")}
268
+ chevronAs={href ? "div" : undefined}
245
269
  style={itemStyle}
246
270
  hovered={isHovered}
247
271
  selected={isSelected}
@@ -281,7 +305,10 @@ export const List = memoGeneric(
281
305
  dragIndicatorStyle={dragIndicatorStyle}
282
306
  >
283
307
  <div
284
- className={cx("n-flex n-flex-1 n-items-center n-px-2", rowGapMap[size])}
308
+ className={cx(
309
+ "n-flex n-flex-1 n-items-center n-px-2",
310
+ rowGapMap[size]
311
+ )}
285
312
  >
286
313
  {thumbnail && (
287
314
  <div
@@ -298,13 +325,17 @@ export const List = memoGeneric(
298
325
  )}
299
326
  <div className="n-flex n-flex-col n-flex-1 n-w-0">
300
327
  <div className="n-flex n-items-center n-min-h-input-height n-flex-1 n-gap-2">
301
- <div className="n-flex-1 n-w-0 n-flex n-items-center" tabIndex={-1}>
328
+ <div
329
+ className="n-flex-1 n-w-0 n-flex n-items-center"
330
+ tabIndex={-1}
331
+ >
302
332
  {isRenaming ? (
303
333
  <ViewComponent.EditableRowTitle
304
334
  value={getName(item)}
305
335
  placeholder={getPlaceholder?.(item) ?? "Enter name"}
306
336
  className={fontStyleMap[size]}
307
337
  autoFocus={!isTestingRenaming}
338
+ selectBeforeDot={renameSelectsBeforeDot}
308
339
  onSubmitEditing={(value: string) => {
309
340
  onRename?.(item, value);
310
341
  setRenamingId(undefined);
@@ -337,7 +368,15 @@ export const List = memoGeneric(
337
368
  )}
338
369
  </div>
339
370
  {below && (
340
- <DetailContainer selected={isSelected}>
371
+ <DetailContainer
372
+ selected={isSelected}
373
+ align={
374
+ normalizedDetailLineClamp &&
375
+ normalizedDetailLineClamp > 1
376
+ ? "start"
377
+ : "center"
378
+ }
379
+ >
341
380
  {below}
342
381
  </DetailContainer>
343
382
  )}
@@ -363,13 +402,19 @@ export const List = memoGeneric(
363
402
  const DetailContainer = memo(function DetailContainer({
364
403
  children,
365
404
  selected,
405
+ align = "center",
366
406
  }: {
367
407
  children: React.ReactNode;
368
408
  selected: boolean;
409
+ align?: "center" | "start";
369
410
  }) {
370
411
  return (
371
412
  <div
372
- className={cx("n-flex n-items-center n-gap-2", selected && "n-text-primary")}
413
+ className={cx(
414
+ "n-flex n-gap-2",
415
+ align === "center" ? "n-items-center" : "n-items-start",
416
+ selected && "n-text-primary"
417
+ )}
373
418
  tabIndex={-1}
374
419
  >
375
420
  {children}
@@ -1,6 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { IVirtualizedList } from "./IVirtualizedList";
4
+ import {
5
+ isMenuItemSectionHeader,
6
+ isSelectableMenuItem,
7
+ MenuItem,
8
+ ScoredMenuItem,
9
+ } from "./internal/Menu";
4
10
  import { ListRowContext } from "./listView/ListViewContexts";
5
11
  import { ListViewEditableRowTitle } from "./listView/ListViewEditableRowTitle";
6
12
  import {
@@ -8,6 +14,7 @@ import {
8
14
  ListViewRoot,
9
15
  ROW_HEIGHT,
10
16
  SECTION_HEADER_LABEL_HEIGHT,
17
+ TALL_ROW_HEIGHT,
11
18
  } from "./listView/ListViewRoot";
12
19
  import {
13
20
  ListViewClickInfo,
@@ -32,6 +39,7 @@ export namespace ListView {
32
39
  export const DragIndicator = ListViewDragIndicatorElement;
33
40
  export const rowHeight = ROW_HEIGHT;
34
41
  export const sectionHeaderLabelHeight = SECTION_HEADER_LABEL_HEIGHT;
42
+ export const tallRowHeight = TALL_ROW_HEIGHT;
35
43
  export const calculateHeight = ({
36
44
  menuItemsCount,
37
45
  sectionHeaderCount,
@@ -43,5 +51,41 @@ export namespace ListView {
43
51
  menuItemsCount * rowHeight + sectionHeaderCount * sectionHeaderLabelHeight
44
52
  );
45
53
  };
54
+
55
+ export const calculateHeightWithTallRows = ({
56
+ menuItemsCount,
57
+ sectionHeaderCount,
58
+ tallMenuItemsCount,
59
+ maxBaseRows,
60
+ }: {
61
+ menuItemsCount: number;
62
+ sectionHeaderCount: number;
63
+ tallMenuItemsCount: number;
64
+ /** Maximum number of base-height rows to show before capping */
65
+ maxBaseRows: number;
66
+ }) => {
67
+ const normalRows = menuItemsCount - tallMenuItemsCount;
68
+ const totalHeight =
69
+ normalRows * rowHeight +
70
+ tallMenuItemsCount * tallRowHeight +
71
+ sectionHeaderCount * sectionHeaderLabelHeight;
72
+ const maxHeight = rowHeight * maxBaseRows;
73
+ return Math.min(totalHeight, maxHeight);
74
+ };
75
+ export const getMenuMetrics = <T extends string>(
76
+ items: Array<MenuItem<T> | ScoredMenuItem<T>>
77
+ ) => {
78
+ const sectionHeaderCount = items.filter((item) =>
79
+ isMenuItemSectionHeader(item)
80
+ ).length;
81
+ const menuItemsCount = items.filter((item) =>
82
+ isSelectableMenuItem(item)
83
+ ).length;
84
+ const tallMenuItemsCount = items.filter((item) => {
85
+ return isSelectableMenuItem(item) && item.tooltip !== undefined;
86
+ }).length;
87
+
88
+ return { sectionHeaderCount, menuItemsCount, tallMenuItemsCount };
89
+ };
46
90
  export const normalizeTargetIndex = normalizeListTargetIndex;
47
91
  }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { ScrollArea } from "@base-ui-components/react/scroll-area";
4
+ import * as React from "react";
5
+ import { cx } from "../utils/classNames";
6
+
7
+ const SCROLLBAR_STATE_CLASSES =
8
+ "scroll-component n-opacity-0 n-transition-opacity n-delay-300 n-pointer-events-none data-[hovering]:n-opacity-100 data-[hovering]:n-delay-0 data-[hovering]:n-duration-75 data-[hovering]:n-pointer-events-auto data-[scrolling]:n-opacity-100 data-[scrolling]:n-delay-0 data-[scrolling]:n-duration-75 data-[scrolling]:n-pointer-events-auto";
9
+
10
+ const SCROLLBAR_TRACK_VERTICAL =
11
+ "n-m-1 n-w-1 flex n-justify-center n-rounded-md n-bg-divider-subtle";
12
+ const SCROLLBAR_TRACK_HORIZONTAL =
13
+ "n-m-1 n-h-1 flex n-justify-center n-rounded-md n-bg-divider-subtle";
14
+
15
+ const SCROLLBAR_THUMB_VERTICAL =
16
+ "scroll-component n-w-full n-rounded n-bg-scrollbar";
17
+ const SCROLLBAR_THUMB_HORIZONTAL =
18
+ "scroll-component n-h-full n-rounded n-bg-scrollbar";
19
+
20
+ export function ScrollArea2({
21
+ children,
22
+ className,
23
+ viewportClassName,
24
+ orientation = "vertical",
25
+ }: {
26
+ children: React.ReactNode;
27
+ className?: string;
28
+ viewportClassName?: string;
29
+ orientation?: "vertical" | "horizontal" | "both";
30
+ }) {
31
+ return (
32
+ <ScrollArea.Root className={cx("n-relative", className)}>
33
+ <ScrollArea.Viewport
34
+ className={cx("n-h-full n-overscroll-contain", viewportClassName)}
35
+ >
36
+ {children}
37
+ </ScrollArea.Viewport>
38
+ {(orientation === "vertical" || orientation === "both") && (
39
+ <ScrollArea.Scrollbar
40
+ className={cx(SCROLLBAR_STATE_CLASSES, SCROLLBAR_TRACK_VERTICAL)}
41
+ >
42
+ <ScrollArea.Thumb className={SCROLLBAR_THUMB_VERTICAL} />
43
+ </ScrollArea.Scrollbar>
44
+ )}
45
+ {(orientation === "horizontal" || orientation === "both") && (
46
+ <ScrollArea.Scrollbar
47
+ orientation="horizontal"
48
+ className={cx(SCROLLBAR_STATE_CLASSES, SCROLLBAR_TRACK_HORIZONTAL)}
49
+ >
50
+ <ScrollArea.Thumb className={SCROLLBAR_THUMB_HORIZONTAL} />
51
+ </ScrollArea.Scrollbar>
52
+ )}
53
+ {orientation === "both" && <ScrollArea.Corner />}
54
+ </ScrollArea.Root>
55
+ );
56
+ }
@@ -91,13 +91,17 @@ export const SearchCompletionMenu = forwardRef(function SearchCompletionMenu(
91
91
  onHover?.(results[index]!);
92
92
  }, [index, onHover, results]);
93
93
 
94
- const searchHeight = 31;
94
+ const baseRowHeight = ListView.rowHeight;
95
+ const searchHeight = baseRowHeight;
95
96
 
96
97
  const listSize =
97
98
  results.length > 0
98
99
  ? {
99
100
  width,
100
- height: Math.min(results.length * 31, 31 * 6.5),
101
+ height: Math.min(
102
+ results.length * baseRowHeight,
103
+ baseRowHeight * 6.5
104
+ ),
101
105
  }
102
106
  : {
103
107
  width,
@@ -20,6 +20,7 @@ type TreeRowBaseProps = {
20
20
  icon?: Exclude<ReactNode, string> | IconName;
21
21
  expanded?: boolean;
22
22
  chevronClassName?: string;
23
+ chevronAs?: React.ElementType;
23
24
  onClickChevron?: ({ altKey }: { altKey: boolean }) => void;
24
25
  onDoubleClick?: () => void;
25
26
  };
@@ -33,6 +34,7 @@ const TreeRow = forwardRefGeneric(function TreeRow<MenuItemType extends string>(
33
34
  expanded,
34
35
  onClickChevron,
35
36
  chevronClassName,
37
+ chevronAs,
36
38
  children,
37
39
  ...rest
38
40
  }: TreeViewRowProps<MenuItemType>,
@@ -45,6 +47,7 @@ const TreeRow = forwardRefGeneric(function TreeRow<MenuItemType extends string>(
45
47
  const handleClickChevron = useCallback(
46
48
  (event: React.MouseEvent) => {
47
49
  event.stopPropagation();
50
+ event.preventDefault();
48
51
  stableOnClickChevron?.({ altKey: event.altKey });
49
52
  },
50
53
  [stableOnClickChevron]
@@ -58,6 +61,7 @@ const TreeRow = forwardRefGeneric(function TreeRow<MenuItemType extends string>(
58
61
  <Spacer.Horizontal size={19} />
59
62
  ) : (
60
63
  <IconButton
64
+ as={chevronAs}
61
65
  className={chevronClassName}
62
66
  iconName={expanded ? "ChevronDownIcon2" : "ChevronRightIcon2"}
63
67
  onClick={handleClickChevron}
@@ -42,16 +42,25 @@ const UserNameTag = memo(function UserNameTag({
42
42
  className,
43
43
  style,
44
44
  children,
45
+ variant = "rounded",
46
+ size = "medium",
47
+ bordered = true,
45
48
  }: {
46
49
  backgroundColor: string;
47
50
  className?: string;
48
51
  style?: React.CSSProperties;
49
52
  children: React.ReactNode;
53
+ variant?: "rounded" | "rectangle";
54
+ size?: "small" | "medium";
55
+ bordered?: boolean;
50
56
  }) {
51
57
  return (
52
58
  <span
53
59
  className={cx(
54
- "n-relative n-inline-block n-rounded-full n-text-xs n-font-medium n-leading-none n-text-white n-shadow-sm n-px-2 n-py-1.5 n-border n-border-background",
60
+ "n-relative n-inline-block n-text-xs n-font-medium n-leading-none n-text-white n-shadow-sm",
61
+ bordered && "n-border n-border-background",
62
+ size === "small" ? "n-p-1" : "n-px-2 n-py-1.5",
63
+ variant === "rounded" ? "n-rounded-full" : "n-rounded-none",
55
64
  className
56
65
  )}
57
66
  style={{
@@ -113,6 +122,15 @@ export type UserPointerProps = {
113
122
  backgroundColor?: string;
114
123
  style?: React.CSSProperties;
115
124
  className?: string;
125
+ /**
126
+ * Whether to render the pointer icon. Useful for contexts that only need the
127
+ * name tag styling/positioning without the icon.
128
+ */
129
+ showPointerIcon?: boolean;
130
+ /** Controls the shape of the name tag */
131
+ labelVariant?: "rounded" | "rectangle";
132
+ labelSize?: "small" | "medium";
133
+ bordered?: boolean;
116
134
  };
117
135
 
118
136
  const POINTER_SIZE = 12;
@@ -126,6 +144,10 @@ export const UserPointer = memo(function UserPointer({
126
144
  backgroundColor,
127
145
  style,
128
146
  className,
147
+ showPointerIcon = true,
148
+ labelVariant = "rounded",
149
+ labelSize = "medium",
150
+ bordered = true,
129
151
  }: UserPointerProps) {
130
152
  const userBackgroundColor = useMemo(
131
153
  () => backgroundColor ?? colorFromString(userId ?? name ?? ""),
@@ -154,14 +176,19 @@ export const UserPointer = memo(function UserPointer({
154
176
  >
155
177
  {name && (
156
178
  <div className="n-relative">
157
- <UserPointerIcon
158
- size={POINTER_SIZE}
159
- color={userBackgroundColor}
160
- style={pointerOverlapStyle}
161
- />
179
+ {showPointerIcon && (
180
+ <UserPointerIcon
181
+ size={POINTER_SIZE}
182
+ color={userBackgroundColor}
183
+ style={pointerOverlapStyle}
184
+ />
185
+ )}
162
186
  <UserNameTag
163
187
  backgroundColor={userBackgroundColor}
164
- style={nameTagOverlapStyle}
188
+ style={showPointerIcon ? nameTagOverlapStyle : undefined}
189
+ variant={labelVariant}
190
+ size={labelSize}
191
+ bordered={bordered}
165
192
  >
166
193
  {name}
167
194
  </UserNameTag>
@@ -33,9 +33,7 @@ export const ConnectedUsersMenuLayout = ({
33
33
  }: ConnectedUsersMenuLayoutProps) => {
34
34
  return (
35
35
  <div className="n-flex n-gap-1.5">
36
- <AvatarStack size={INPUT_HEIGHT} className="n-mr-1">
37
- {renderUsers()}
38
- </AvatarStack>
36
+ <AvatarStack size={INPUT_HEIGHT}>{renderUsers()}</AvatarStack>
39
37
  {launchAIAssistant && (
40
38
  <Tooltip content="AI Assistant">
41
39
  <Button
@@ -11,6 +11,12 @@ export interface EditableRowTitleProps {
11
11
  placeholder?: string;
12
12
  className?: string;
13
13
  onKeyDown?: (e: React.KeyboardEvent) => void;
14
+ /**
15
+ * When true, selects only the portion of the value
16
+ * before the last '.' on autofocus (useful for renaming files
17
+ * without selecting the extension). Defaults to false.
18
+ */
19
+ selectBeforeDot?: boolean;
14
20
  }
15
21
 
16
22
  export const ListViewEditableRowTitle = memo(function ListViewEditableRowTitle({
@@ -20,6 +26,7 @@ export const ListViewEditableRowTitle = memo(function ListViewEditableRowTitle({
20
26
  placeholder,
21
27
  className,
22
28
  onKeyDown,
29
+ selectBeforeDot = false,
23
30
  }: EditableRowTitleProps) {
24
31
  const inputRef = useRef<HTMLInputElement | null>(null);
25
32
 
@@ -34,9 +41,24 @@ export const ListViewEditableRowTitle = memo(function ListViewEditableRowTitle({
34
41
 
35
42
  // must be a delay of 1ms or more to ensure any radix ui components have finished any focus behaviors
36
43
  setTimeout(() => {
44
+ if (selectBeforeDot) {
45
+ const index = value.lastIndexOf(".");
46
+ if (index > 0) {
47
+ // Some browsers require calling select() before setSelectionRange
48
+ // to ensure the selection is applied on first focus.
49
+ element.select();
50
+ try {
51
+ element.setSelectionRange(0, index, "forward");
52
+ } catch {
53
+ // Fallback in case setSelectionRange throws (e.g., unsupported type)
54
+ element.select();
55
+ }
56
+ return;
57
+ }
58
+ }
37
59
  element.select();
38
60
  }, 1);
39
- }, [autoFocus]);
61
+ }, [autoFocus, selectBeforeDot, value]);
40
62
 
41
63
  return (
42
64
  <InputField.Input
@@ -19,7 +19,6 @@ import React, {
19
19
  } from "react";
20
20
  import { WindowScroller, WindowScrollerChildProps } from "react-virtualized";
21
21
  import { ListChildComponentProps, VariableSizeList } from "react-window";
22
- import { INPUT_HEIGHT } from "../../theme";
23
22
  import { mergeConflictingClassNames } from "../../utils/classNames";
24
23
  import { IVirtualizedList } from "../IVirtualizedList";
25
24
  import { ScrollArea } from "../ScrollArea";
@@ -49,7 +48,13 @@ import {
49
48
  } from "./types";
50
49
 
51
50
  export const ROW_HEIGHT = 31;
52
- export const SECTION_HEADER_LABEL_HEIGHT = INPUT_HEIGHT;
51
+ // Section headers with variant "label" render as 12px line-height with 6px
52
+ // padding above and below, totaling 24px. Keep this constant in sync with
53
+ // ListViewRow styles (n-leading-3 with 6px vertical padding).
54
+ export const SECTION_HEADER_LABEL_HEIGHT = 24;
55
+ // Some list rows render an extra line (e.g., tooltip/description). Those rows
56
+ // use a taller height.
57
+ export const TALL_ROW_HEIGHT = 50;
53
58
 
54
59
  const VirtualizedListRow = memo(function VirtualizedListRow({
55
60
  index,
@@ -99,9 +104,10 @@ const VirtualizedListInner = forwardRefGeneric(function VirtualizedListInner<T>(
99
104
  useLayoutEffect(() => {
100
105
  listRef.current?.resetAfterIndex(0);
101
106
  }, [
102
- // When items change, we need to re-render the virtualized list,
103
- // since it doesn't currently support row height changes
107
+ // When items or the height resolver change, re-render the virtualized list
108
+ // since VariableSizeList caches sizes per index.
104
109
  items,
110
+ getItemHeight,
105
111
  ]);
106
112
 
107
113
  // Internally, react-virtualized updates these properties. We always want
@@ -223,6 +229,7 @@ export type ListViewRootProps<T> = {
223
229
  sharedDragProps?: SharedDragProps;
224
230
  sortableId?: string;
225
231
  itemHeight?: number;
232
+ getItemHeightForItem?: (item: T, index: number) => number;
226
233
  };
227
234
 
228
235
  export const ListViewRoot = memoGeneric(
@@ -262,6 +269,7 @@ export const ListViewRoot = memoGeneric(
262
269
  sharedDragProps,
263
270
  sortableId,
264
271
  itemHeight,
272
+ getItemHeightForItem,
265
273
  }: ListViewRootProps<T>,
266
274
  forwardedRef: ForwardedRef<IVirtualizedList>
267
275
  ) {
@@ -299,8 +307,19 @@ export const ListViewRoot = memoGeneric(
299
307
 
300
308
  const getItemContextValue = useCallback(
301
309
  (i: number): ListRowContextValue | undefined => {
302
- if (variant === "bare" || variant === "normal")
303
- return defaultContextValue;
310
+ // Always detect section headers for height calculations.
311
+ // For "bare" and "normal" variants we keep default margins/selection
312
+ // but still set isSectionHeader based on the rendered child.
313
+ if (variant === "bare" || variant === "normal") {
314
+ const current = renderChild(i);
315
+
316
+ if (!isValidElement(current)) return defaultContextValue;
317
+
318
+ return {
319
+ ...defaultContextValue,
320
+ isSectionHeader: !!(current as any).props.isSectionHeader,
321
+ };
322
+ }
304
323
 
305
324
  const current = renderChild(i);
306
325
 
@@ -419,16 +438,29 @@ export const ListViewRoot = memoGeneric(
419
438
 
420
439
  const getItemHeight = useCallback(
421
440
  (index: number) => {
441
+ // Section header height takes precedence
442
+ const child = getItemContextValue(index);
443
+ if (child?.isSectionHeader && sectionHeaderVariant === "label") {
444
+ return SECTION_HEADER_LABEL_HEIGHT;
445
+ }
446
+
447
+ // Fixed height for all items if provided
422
448
  if (itemHeight) return itemHeight;
423
449
 
424
- const child = getItemContextValue(index);
425
- const height =
426
- child?.isSectionHeader && sectionHeaderVariant === "label"
427
- ? SECTION_HEADER_LABEL_HEIGHT
428
- : ROW_HEIGHT;
429
- return height;
450
+ // Per-item height callback
451
+ if (getItemHeightForItem) {
452
+ return getItemHeightForItem(data[index], index);
453
+ }
454
+
455
+ return ROW_HEIGHT;
430
456
  },
431
- [getItemContextValue, itemHeight, sectionHeaderVariant]
457
+ [
458
+ getItemContextValue,
459
+ itemHeight,
460
+ sectionHeaderVariant,
461
+ getItemHeightForItem,
462
+ data,
463
+ ]
432
464
  );
433
465
 
434
466
  const getKey = useCallback(
@@ -9,6 +9,7 @@ import {
9
9
  } from "@dnd-kit/sortable";
10
10
  import { Insets } from "@noya-app/noya-geometry";
11
11
  import * as React from "react";
12
+ import { useMemo } from "react";
12
13
  import {
13
14
  ActiveDragContext,
14
15
  ActiveDragContextType,
@@ -145,7 +146,7 @@ function SortableItem<TElement extends HTMLElement>({
145
146
  disabled?: boolean;
146
147
  children: (
147
148
  props: ItemChildrenProps<TElement>,
148
- extraProps: Omit<ItemChildrenExtraProps, "isOverlay">
149
+ extraProps: ItemChildrenExtraProps
149
150
  ) => React.ReactNode;
150
151
  }) {
151
152
  const { acceptsDrop, listId, getDropTargetParentIndex } =
@@ -180,21 +181,35 @@ function SortableItem<TElement extends HTMLElement>({
180
181
  getDropTargetParentIndex,
181
182
  });
182
183
 
183
- return children(
184
- {
184
+ const props = useMemo(
185
+ () => ({
185
186
  ref,
186
187
  ...attributes,
187
188
  ...listeners,
188
189
  style: { opacity: isDragging ? 0.5 : 1 },
189
- },
190
- {
190
+ }),
191
+ [ref, attributes, listeners, isDragging]
192
+ );
193
+
194
+ const extraProps = useMemo(
195
+ () => ({
191
196
  isDragging,
197
+ isOverlay: false,
192
198
  setActivatorNodeRef,
193
199
  setDraggableNodeRef,
194
200
  setDroppableNodeRef,
195
201
  relativeDropPosition,
196
- }
202
+ }),
203
+ [
204
+ isDragging,
205
+ setActivatorNodeRef,
206
+ setDraggableNodeRef,
207
+ setDroppableNodeRef,
208
+ relativeDropPosition,
209
+ ]
197
210
  );
211
+
212
+ return children(props, extraProps);
198
213
  }
199
214
 
200
215
  function SortableRoot_({
@@ -396,10 +411,7 @@ const Sortable_ = function Sortable_<TItem, TElement extends HTMLElement>({
396
411
  {keys.map((key, index) => (
397
412
  <SortableItem<TElement> key={key} id={key}>
398
413
  {(props, extraProps) => {
399
- return renderItem(items[index], props, {
400
- isOverlay: false,
401
- ...extraProps,
402
- });
414
+ return renderItem(items[index], props, extraProps);
403
415
  }}
404
416
  </SortableItem>
405
417
  ))}