@noya-app/noya-designsystem 0.1.46 → 0.1.48

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 (42) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +16 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +208 -98
  5. package/dist/index.d.ts +208 -98
  6. package/dist/index.js +2148 -1604
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +2153 -1618
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +6 -7
  11. package/src/__tests__/validateDropIndicator.test.ts +263 -0
  12. package/src/components/ActionMenu.tsx +40 -0
  13. package/src/components/ActivityIndicator.tsx +7 -1
  14. package/src/components/Collection.tsx +12 -2
  15. package/src/components/Combobox.tsx +14 -1
  16. package/src/components/ComboboxMenu.tsx +14 -5
  17. package/src/components/Dialog.tsx +28 -31
  18. package/src/components/Drawer.tsx +98 -0
  19. package/src/components/Grid.tsx +57 -27
  20. package/src/components/GridView.tsx +39 -25
  21. package/src/components/InputField.tsx +87 -92
  22. package/src/components/Label.tsx +7 -7
  23. package/src/components/List.tsx +42 -16
  24. package/src/components/ListView.tsx +21 -17
  25. package/src/components/MediaThumbnail.tsx +117 -0
  26. package/src/components/SearchCompletionMenu.tsx +31 -37
  27. package/src/components/SelectMenu.tsx +19 -17
  28. package/src/components/Sortable.tsx +70 -44
  29. package/src/components/Switch.tsx +1 -1
  30. package/src/components/internal/Menu.tsx +38 -23
  31. package/src/components/internal/MenuViewport.tsx +7 -1
  32. package/src/components/internal/SelectItem.tsx +51 -27
  33. package/src/components/internal/__tests__/Menu.test.tsx +12 -0
  34. package/src/components/workspace/DrawerWorkspaceLayout.tsx +86 -0
  35. package/src/components/workspace/PanelWorkspaceLayout.tsx +102 -0
  36. package/src/components/{WorkspaceLayout.tsx → workspace/WorkspaceLayout.tsx} +109 -106
  37. package/src/components/workspace/types.ts +31 -0
  38. package/src/contexts/DialogContext.tsx +91 -30
  39. package/src/hooks/usePreservePanelSize.tsx +1 -5
  40. package/src/index.css +3 -1
  41. package/src/index.tsx +4 -1
  42. package/tailwind.config.ts +1 -0
@@ -1,5 +1,5 @@
1
1
  import { DropdownChevronIcon } from "@noya-app/noya-icons";
2
- import { assignRef, memoGeneric } from "@noya-app/react-utils";
2
+ import { memoGeneric, useMergeRefs, useSize } from "@noya-app/react-utils";
3
3
  import { Property } from "csstype";
4
4
  import React, {
5
5
  createContext,
@@ -11,11 +11,8 @@ import React, {
11
11
  ReactNode,
12
12
  useCallback,
13
13
  useContext,
14
- useEffect,
15
14
  useId,
16
- useLayoutEffect,
17
15
  useMemo,
18
- useRef,
19
16
  useState,
20
17
  } from "react";
21
18
  import { useLabel, useLabelPosition } from "../hooks/useLabel";
@@ -31,27 +28,33 @@ import { Popover } from "./Popover";
31
28
  export type InputFieldSize = "small" | "medium" | "large";
32
29
 
33
30
  type InputFieldContextValue = {
34
- labelSize?: number;
35
- buttonSize?: number;
31
+ labelWidth?: number;
32
+ buttonWidth?: number;
33
+ startWidth?: number;
36
34
  /** @default medium */
37
35
  size: InputFieldSize;
38
36
  isFocused: boolean;
39
37
  onFocusChange: (isFocused: boolean) => void;
40
- inputRef?: ForwardedRef<HTMLInputElement>;
41
- setInputRef?: (ref: ForwardedRef<HTMLInputElement>) => void;
42
- setLabelWidth?: (width: number) => void;
43
- setButtonWidth?: (width: number) => void;
38
+ inputRef: ForwardedRef<HTMLInputElement>;
39
+ buttonRef: ForwardedRef<HTMLButtonElement>;
40
+ labelRef: ForwardedRef<HTMLLabelElement>;
41
+ startRef: ForwardedRef<HTMLSpanElement>;
44
42
  id?: string;
45
43
  label?: ReactNode;
46
44
  labelPosition?: LabelPosition;
47
45
  };
48
46
 
49
47
  const InputFieldContext = createContext<InputFieldContextValue>({
50
- labelSize: undefined,
51
- buttonSize: undefined,
48
+ labelWidth: undefined,
49
+ buttonWidth: undefined,
50
+ startWidth: undefined,
52
51
  size: "medium",
53
52
  isFocused: false,
54
53
  onFocusChange: () => {},
54
+ inputRef: null,
55
+ buttonRef: null,
56
+ labelRef: null,
57
+ startRef: null,
55
58
  });
56
59
 
57
60
  export const useInputFieldContext = () => useContext(InputFieldContext);
@@ -59,16 +62,18 @@ export const useInputFieldContext = () => useContext(InputFieldContext);
59
62
  const noop = () => {};
60
63
 
61
64
  export const getFieldSpacing = ({
62
- buttonSize = 0,
63
- labelSize = 0,
65
+ buttonWidth = 0,
66
+ labelWidth = 0,
67
+ startWidth,
64
68
  variant,
65
69
  }: {
66
- buttonSize?: number;
67
- labelSize?: number;
70
+ buttonWidth?: number;
71
+ labelWidth?: number;
72
+ startWidth?: number;
68
73
  variant?: InputFieldVariant;
69
74
  }) => ({
70
- paddingLeft: variant === "bare" ? "0px" : "6px",
71
- paddingRight: `${variant === "bare" ? 0 : labelSize + buttonSize + (labelSize || buttonSize ? 12 : 6)}px`,
75
+ paddingLeft: `${variant === "bare" ? startWidth : startWidth ? startWidth + 12 : 6}px`,
76
+ paddingRight: `${variant === "bare" ? 0 : labelWidth + buttonWidth + (labelWidth || buttonWidth ? 12 : 6)}px`,
72
77
  });
73
78
 
74
79
  /* ----------------------------------------------------------------------------
@@ -83,31 +88,18 @@ const InputFieldLabel = memo(
83
88
  props: LabelHTMLAttributes<HTMLLabelElement>,
84
89
  forwardedRef: ForwardedRef<HTMLLabelElement>
85
90
  ) {
86
- const { setLabelWidth, size, buttonSize, label, id, labelPosition } =
91
+ const { labelRef, size, buttonWidth, label, id, labelPosition } =
87
92
  useInputFieldContext();
88
93
 
89
- const ref = useRef<HTMLLabelElement | null>(null);
90
-
91
- useLayoutEffect(() => {
92
- if (!setLabelWidth) return;
93
-
94
- const width = ref.current?.getBoundingClientRect().width;
95
-
96
- if (!width) return;
97
-
98
- setLabelWidth(width);
99
- }, [setLabelWidth]);
94
+ const ref = useMergeRefs(labelRef, forwardedRef);
100
95
 
101
96
  if (labelPosition !== "inset") return null;
102
97
 
103
98
  return (
104
99
  <InsetLabel
105
- ref={(element) => {
106
- ref.current = element;
107
- assignRef(forwardedRef, element);
108
- }}
100
+ ref={ref}
109
101
  size={size}
110
- buttonSize={buttonSize}
102
+ buttonWidth={buttonWidth}
111
103
  htmlFor={id}
112
104
  {...props}
113
105
  >
@@ -170,6 +162,7 @@ const InputFieldButton = memo(
170
162
  props: React.ButtonHTMLAttributes<HTMLButtonElement> &
171
163
  ButtonRootProps & {
172
164
  buttonClassName?: string;
165
+ buttonStyle?: React.CSSProperties;
173
166
  variant?: "floating" | "normal";
174
167
  },
175
168
  forwardedRef: ForwardedRef<HTMLButtonElement>
@@ -178,24 +171,16 @@ const InputFieldButton = memo(
178
171
  children,
179
172
  onClick,
180
173
  className,
174
+ style,
181
175
  buttonClassName,
176
+ buttonStyle,
182
177
  variant = "normal",
183
178
  as = "button",
184
179
  ...rest
185
180
  } = props;
186
- const { size, inputRef, setButtonWidth } = useInputFieldContext();
187
-
188
- const ref = useRef<HTMLButtonElement | null>(null);
181
+ const { size, inputRef, buttonRef } = useInputFieldContext();
189
182
 
190
- useLayoutEffect(() => {
191
- if (!setButtonWidth) return;
192
-
193
- const width = ref.current?.getBoundingClientRect().width;
194
-
195
- if (!width) return;
196
-
197
- setButtonWidth(width);
198
- }, [setButtonWidth]);
183
+ const ref = useMergeRefs(buttonRef, forwardedRef);
199
184
 
200
185
  const defaultHandleClick = useCallback(
201
186
  (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -221,6 +206,7 @@ const InputFieldButton = memo(
221
206
 
222
207
  return (
223
208
  <span
209
+ style={style}
224
210
  className={cx(
225
211
  "absolute",
226
212
  size === "large"
@@ -237,10 +223,7 @@ const InputFieldButton = memo(
237
223
  >
238
224
  <Button
239
225
  as={as}
240
- ref={(element) => {
241
- ref.current = element;
242
- assignRef(forwardedRef, element);
243
- }}
226
+ ref={ref}
244
227
  variant="floating"
245
228
  onClick={onClick ?? defaultHandleClick}
246
229
  onPointerDown={handlePointerDown}
@@ -250,6 +233,7 @@ const InputFieldButton = memo(
250
233
  buttonClassName,
251
234
  variant === "floating" && "bg-transparent shadow-none"
252
235
  )}
236
+ style={buttonStyle}
253
237
  {...rest}
254
238
  >
255
239
  {children}
@@ -300,14 +284,18 @@ const InputElement = forwardRef(
300
284
  style,
301
285
  ...props
302
286
  }: InputElementProps,
303
- ref: ForwardedRef<any>
287
+ forwardedRef: ForwardedRef<any>
304
288
  ) => {
305
- const { buttonSize, labelSize, size, id } = useInputFieldContext();
289
+ const { buttonWidth, inputRef, labelWidth, size, id, startWidth } =
290
+ useInputFieldContext();
291
+
292
+ const ref = useMergeRefs(inputRef, forwardedRef);
306
293
 
307
294
  const spacingStyles = getFieldSpacing({
308
- buttonSize,
309
- labelSize,
295
+ buttonWidth,
296
+ labelWidth,
310
297
  variant: $variant,
298
+ startWidth,
311
299
  });
312
300
 
313
301
  const inputClassName = cx(
@@ -367,7 +355,9 @@ const InputFieldInput = forwardRef(function InputFieldInput(
367
355
  { textAlign, variant, ...rest }: InputFieldInputProps,
368
356
  forwardedRef: ForwardedRef<HTMLInputElement>
369
357
  ) {
370
- const { onFocusChange, setInputRef } = useInputFieldContext();
358
+ const { onFocusChange, inputRef } = useInputFieldContext();
359
+
360
+ const ref = useMergeRefs(inputRef, forwardedRef);
371
361
 
372
362
  const handleFocusChange = useCallback(
373
363
  (isFocused: boolean) => {
@@ -376,13 +366,9 @@ const InputFieldInput = forwardRef(function InputFieldInput(
376
366
  [onFocusChange]
377
367
  );
378
368
 
379
- useLayoutEffect(() => {
380
- setInputRef?.(forwardedRef);
381
- }, [forwardedRef, setInputRef]);
382
-
383
369
  return (
384
370
  <InputElement
385
- ref={forwardedRef}
371
+ ref={ref}
386
372
  $variant={variant}
387
373
  $textAlign={textAlign}
388
374
  onFocusChange={handleFocusChange}
@@ -563,11 +549,14 @@ const RootContainer = forwardRef<
563
549
  />
564
550
  ));
565
551
 
552
+ const startStyles = "absolute left-1.5 inset-y-0 flex items-center";
553
+
566
554
  interface InputFieldRootProps {
567
555
  id?: string;
568
556
  children?: ReactNode;
569
557
  width?: number;
570
- labelSize?: number;
558
+ labelWidth?: number;
559
+ startWidth?: number;
571
560
  /** @default medium */
572
561
  size?: InputFieldSize;
573
562
  renderPopoverContent?: (options: { width: number }) => ReactNode;
@@ -579,6 +568,7 @@ interface InputFieldRootProps {
579
568
  onFocusChange?: (isFocused: boolean) => void;
580
569
  style?: React.CSSProperties;
581
570
  className?: string;
571
+ start?: ReactNode;
582
572
  }
583
573
 
584
574
  function InputFieldRoot({
@@ -586,12 +576,14 @@ function InputFieldRoot({
586
576
  children,
587
577
  width,
588
578
  sideOffset = 3,
589
- labelSize,
579
+ labelWidth,
580
+ startWidth,
590
581
  size = "medium",
591
582
  renderPopoverContent,
592
583
  onFocusChange,
593
584
  style,
594
585
  className,
586
+ start,
595
587
  }: InputFieldRootProps) {
596
588
  const { fieldId: id, label } = useLabel({ fieldId: idProp });
597
589
  const randomId = useId();
@@ -599,9 +591,15 @@ function InputFieldRoot({
599
591
 
600
592
  const [isFocused, setIsFocused] = React.useState(false);
601
593
 
602
- const [measuredLabelSize, setMeasuredLabelSize] = React.useState<number>();
603
- const [measuredButtonSize, setMeasuredButtonSize] = React.useState<number>();
604
- const [measuredWidth, setMeasuredWidth] = React.useState<number>();
594
+ const startRef = React.useRef<HTMLSpanElement>(null);
595
+ const labelRef = React.useRef<HTMLLabelElement>(null);
596
+ const buttonRef = React.useRef<HTMLButtonElement>(null);
597
+ const inputRef = React.useRef<HTMLInputElement>(null);
598
+
599
+ const measuredLabelSize = useSize(labelRef, "width");
600
+ const measuredButtonSize = useSize(buttonRef, "width");
601
+ const measuredInputSize = useSize(inputRef, "width");
602
+ const measuredStartSize = useSize(startRef, "width");
605
603
 
606
604
  const handleFocusChange = useCallback(
607
605
  (isFocused: boolean) => {
@@ -611,36 +609,25 @@ function InputFieldRoot({
611
609
  [onFocusChange]
612
610
  );
613
611
 
614
- const [inputRef, setInputRef] =
615
- React.useState<ForwardedRef<HTMLInputElement>>();
616
-
617
- // eslint-disable-next-line @shopify/prefer-early-return
618
- useEffect(() => {
619
- if (inputRef && typeof inputRef !== "function") {
620
- setMeasuredWidth?.(
621
- isFocused ? inputRef?.current?.getBoundingClientRect().width : undefined
622
- );
623
- }
624
- }, [inputRef, isFocused]);
625
-
626
612
  const contextValue = useMemo(
627
613
  (): InputFieldContextValue => ({
628
- labelSize: measuredLabelSize ?? labelSize,
629
- buttonSize: measuredButtonSize,
614
+ labelWidth: measuredLabelSize?.width ?? labelWidth,
615
+ buttonWidth: measuredButtonSize?.width,
616
+ startWidth: measuredStartSize?.width ?? startWidth,
617
+ inputRef,
618
+ buttonRef,
619
+ labelRef,
620
+ startRef,
630
621
  size,
631
622
  isFocused,
632
623
  onFocusChange: handleFocusChange,
633
- inputRef,
634
- setInputRef,
635
- setLabelWidth: setMeasuredLabelSize,
636
- setButtonWidth: setMeasuredButtonSize,
637
624
  id: id ?? randomId,
638
625
  label,
639
626
  labelPosition,
640
627
  }),
641
628
  [
642
629
  measuredLabelSize,
643
- labelSize,
630
+ labelWidth,
644
631
  measuredButtonSize,
645
632
  size,
646
633
  isFocused,
@@ -650,19 +637,27 @@ function InputFieldRoot({
650
637
  randomId,
651
638
  label,
652
639
  labelPosition,
640
+ measuredStartSize,
641
+ startWidth,
653
642
  ]
654
643
  );
655
644
 
656
645
  const rootElement = (
657
646
  <RootContainer $width={width} style={style} className={className}>
658
647
  {children}
648
+ {start && (
649
+ <span ref={startRef} className={startStyles}>
650
+ {start}
651
+ </span>
652
+ )}
659
653
  {label && <InputFieldLabel />}
660
654
  </RootContainer>
661
655
  );
662
656
 
663
657
  const measuredWidthObject = useMemo(
664
- () => (measuredWidth ? { width: measuredWidth + 4 } : undefined),
665
- [measuredWidth]
658
+ () =>
659
+ measuredInputSize ? { width: measuredInputSize.width + 4 } : undefined,
660
+ [measuredInputSize]
666
661
  );
667
662
 
668
663
  return (
@@ -711,11 +706,11 @@ const PrimitiveInputField = ({
711
706
  React.InputHTMLAttributes<HTMLInputElement>,
712
707
  HTMLInputElement
713
708
  >) => {
714
- const { id, labelSize, buttonSize } = useInputFieldContext();
709
+ const { id, labelWidth, buttonWidth } = useInputFieldContext();
715
710
 
716
711
  const spacingStyles = getFieldSpacing({
717
- buttonSize,
718
- labelSize,
712
+ buttonWidth,
713
+ labelWidth,
719
714
  });
720
715
  const memoizedStyles = useMemo(
721
716
  () => ({ ...spacingStyles, ...style }),
@@ -759,7 +754,7 @@ const InputFieldComponent = forwardRef<HTMLInputElement, InputFieldProps>(
759
754
  function InputField({ children, ...props }: InputFieldProps, ref) {
760
755
  const {
761
756
  width,
762
- labelSize,
757
+ labelWidth,
763
758
  size,
764
759
  renderPopoverContent,
765
760
  sideOffset,
@@ -772,7 +767,7 @@ const InputFieldComponent = forwardRef<HTMLInputElement, InputFieldProps>(
772
767
  return (
773
768
  <InputFieldSubcomponents.Root
774
769
  width={width}
775
- labelSize={labelSize}
770
+ labelWidth={labelWidth}
776
771
  size={size}
777
772
  renderPopoverContent={renderPopoverContent}
778
773
  sideOffset={sideOffset}
@@ -19,7 +19,7 @@ export type InsetLabelProps = {
19
19
  /** @default medium */
20
20
  size?: InputFieldSize;
21
21
  /** when used with an InputField.Root and label position is inset, it will add padding to the right of the label to account for the button */
22
- buttonSize?: number;
22
+ buttonWidth?: number;
23
23
  } & LabelHTMLAttributes<HTMLLabelElement>;
24
24
 
25
25
  // apply to <label
@@ -28,26 +28,26 @@ export const insetLabelTextStyles = `font-sans text-label uppercase text-text-di
28
28
  // InsetLabelContainer
29
29
  export const insetLabelStyles = `flex items-center absolute top-0 bottom-0 z-label`;
30
30
  const getInsetLabelPosition = ({
31
- buttonSize,
31
+ buttonWidth,
32
32
  }: {
33
- buttonSize?: number;
33
+ buttonWidth?: number;
34
34
  }): CSSProperties => ({
35
- right: `${6 + (buttonSize ?? 0)}px`,
35
+ right: `${6 + (buttonWidth ?? 0)}px`,
36
36
  });
37
37
 
38
38
  export const InsetLabel = memo(
39
39
  forwardRef<HTMLLabelElement, InsetLabelProps>(function InsetLabel(
40
- { children, buttonSize, size = "medium", className, style, ...props },
40
+ { children, buttonWidth, size = "medium", className, style, ...props },
41
41
  ref
42
42
  ) {
43
43
  const styles = useMemo(
44
44
  () => ({
45
45
  ...getInsetLabelPosition({
46
- buttonSize,
46
+ buttonWidth,
47
47
  }),
48
48
  ...style,
49
49
  }),
50
- [buttonSize, style]
50
+ [buttonWidth, style]
51
51
  );
52
52
 
53
53
  return (
@@ -7,10 +7,10 @@ import React, { useImperativeHandle, useRef, useState } from "react";
7
7
  import { cssVars } from "../theme";
8
8
  import { cx } from "../utils/classNames";
9
9
  import { updateSelection } from "../utils/selection";
10
+ import { ActionMenu } from "./ActionMenu";
10
11
  import { CollectionProps, CollectionRef } from "./Collection";
11
12
  import { ListView } from "./ListView";
12
13
  import { TreeView } from "./TreeView";
13
-
14
14
  const cssVarOverrides = {
15
15
  "--icon-selected": cssVars.colors.primary,
16
16
  };
@@ -19,7 +19,7 @@ const emptyArray: string[] = [];
19
19
 
20
20
  export const List = memoGeneric(
21
21
  forwardRefGeneric(function List<T, M extends string = string>(
22
- props: CollectionProps<T, M> & { ref?: React.ForwardedRef<CollectionRef> },
22
+ props: CollectionProps<T, M>,
23
23
  ref: React.ForwardedRef<CollectionRef>
24
24
  ) {
25
25
  const {
@@ -51,11 +51,12 @@ export const List = memoGeneric(
51
51
  onDoubleClickItem,
52
52
  renamable,
53
53
  selectedIds = emptyArray,
54
+ renderEmptyState,
54
55
  } = props;
55
56
 
56
57
  const [internalHoveredId, setHoveredId] = useState<string>();
57
58
  const [internalRenamingId, setRenamingId] = useState<string>();
58
-
59
+ const [dropdownOpenId, setDropdownOpenId] = useState<string>();
59
60
  const hoveredId = internalHoveredId ?? testHoveredId;
60
61
  const renamingId = testRenamingId ?? internalRenamingId;
61
62
 
@@ -72,13 +73,17 @@ export const List = memoGeneric(
72
73
  onSelectionChange(newSelectedIds, event);
73
74
  };
74
75
 
75
- const handleMenuAction = (action: M) => {
76
+ const handleMenuAction = (action: M, actionItem: T) => {
76
77
  if (!onSelectMenuItem) return;
77
78
 
78
79
  const selectedItems = items.filter((item) =>
79
80
  selectedIds.includes(getId(item))
80
81
  );
81
- onSelectMenuItem(action, selectedItems);
82
+
83
+ onSelectMenuItem(
84
+ action,
85
+ selectedItems.length === 0 ? [actionItem] : selectedItems
86
+ );
82
87
  };
83
88
 
84
89
  const ViewComponent = expandable ? TreeView : ListView;
@@ -98,7 +103,11 @@ export const List = memoGeneric(
98
103
  return (
99
104
  <ViewComponent.Root
100
105
  variant="bare"
101
- className={cx(className, isDropTargetActive && "bg-primary-pastel")}
106
+ className={cx(
107
+ className,
108
+ isDropTargetActive && "bg-primary-pastel",
109
+ scrollable && "basis-0 min-h-0"
110
+ )}
102
111
  containerRef={fileDropTargetRef}
103
112
  scrollable={scrollable}
104
113
  data={items}
@@ -110,6 +119,7 @@ export const List = memoGeneric(
110
119
  sortable={expandable}
111
120
  acceptsDrop={acceptsDrop}
112
121
  onMoveItem={onMoveItem}
122
+ renderEmptyState={renderEmptyState}
113
123
  {...dropTargetProps}
114
124
  renderItem={(
115
125
  item: T,
@@ -117,18 +127,39 @@ export const List = memoGeneric(
117
127
  { isDragging }: { isDragging: boolean }
118
128
  ) => {
119
129
  const id = getId(item);
120
- const isHovered = hoveredId === id && !isDragging;
130
+ const isDropdownOpen = dropdownOpenId === id;
131
+ const isHovered = (hoveredId === id || isDropdownOpen) && !isDragging;
121
132
  const expanded = getExpanded?.(item);
122
133
  const itemIsRenamable = getRenamable?.(item) ?? renamable;
123
134
  const isSelected = selectedIds.includes(id);
124
135
  const isRenaming = itemIsRenamable && onRename && renamingId === id;
125
136
  const isTestingRenaming = testRenamingId === id;
126
137
 
127
- const thumbnail = renderThumbnail?.(item, isSelected);
138
+ const thumbnail = renderThumbnail?.({
139
+ item,
140
+ selected: isSelected,
141
+ });
128
142
  const action =
129
143
  (isHovered || isSelected) &&
130
144
  !isRenaming &&
131
- renderAction?.(item, isSelected);
145
+ (typeof renderAction === "function"
146
+ ? renderAction(item, isSelected)
147
+ : renderAction === "menu" &&
148
+ menuItems && (
149
+ <ActionMenu
150
+ menuItems={menuItems}
151
+ onSelect={(action) => handleMenuAction(action, item)}
152
+ selected={isSelected}
153
+ onOpenChange={(open) => {
154
+ setDropdownOpenId(open ? id : undefined);
155
+
156
+ if (open && !isSelected) {
157
+ handleSelect(id);
158
+ }
159
+ }}
160
+ />
161
+ ));
162
+
132
163
  const end =
133
164
  action ||
134
165
  (detailPosition === "end" && renderDetail?.(item, isSelected));
@@ -172,15 +203,10 @@ export const List = memoGeneric(
172
203
  }
173
204
  }}
174
205
  menuItems={menuItems}
175
- onSelectMenuItem={handleMenuAction}
206
+ onSelectMenuItem={(action) => handleMenuAction(action, item)}
176
207
  onContextMenu={() => {
177
208
  if (!isSelected) {
178
- handleSelect(id, {
179
- shiftKey: false,
180
- altKey: false,
181
- metaKey: false,
182
- ctrlKey: false,
183
- });
209
+ handleSelect(id);
184
210
  }
185
211
  }}
186
212
  tabIndex={0}
@@ -778,6 +778,7 @@ export type ListViewRootProps = {
778
778
  onDragEnter?: React.DragEventHandler<HTMLDivElement>;
779
779
  onDragLeave?: React.DragEventHandler<HTMLDivElement>;
780
780
  onDrop?: React.DragEventHandler<HTMLDivElement>;
781
+ renderEmptyState?: () => ReactNode;
781
782
  };
782
783
 
783
784
  const ListViewRootInner = forwardRefGeneric(function ListViewRootInner<T>(
@@ -811,6 +812,7 @@ const ListViewRootInner = forwardRefGeneric(function ListViewRootInner<T>(
811
812
  onDragEnter,
812
813
  onDragLeave,
813
814
  onDrop,
815
+ renderEmptyState,
814
816
  }: RenderProps<T> & ListViewRootProps,
815
817
  forwardedRef: ForwardedRef<IVirtualizedList>
816
818
  ) {
@@ -1040,23 +1042,25 @@ const ListViewRootInner = forwardRefGeneric(function ListViewRootInner<T>(
1040
1042
  onDragLeave={onDragLeave}
1041
1043
  onDrop={onDrop}
1042
1044
  >
1043
- {withScrollable((scrollElementRef: HTMLDivElement | null) =>
1044
- withSortable(
1045
- virtualized ? (
1046
- <VirtualizedList<T>
1047
- ref={forwardedRef}
1048
- scrollElement={scrollElementRef!}
1049
- items={data}
1050
- size={virtualized}
1051
- getItemHeight={getItemHeight}
1052
- keyExtractor={getKey}
1053
- renderItem={renderWrappedChild}
1054
- />
1055
- ) : (
1056
- range(0, data.length).map(renderWrappedChild)
1057
- )
1058
- )
1059
- )}
1045
+ {data.length === 0 && renderEmptyState
1046
+ ? renderEmptyState()
1047
+ : withScrollable((scrollElementRef: HTMLDivElement | null) =>
1048
+ withSortable(
1049
+ virtualized ? (
1050
+ <VirtualizedList<T>
1051
+ ref={forwardedRef}
1052
+ scrollElement={scrollElementRef!}
1053
+ items={data}
1054
+ size={virtualized}
1055
+ getItemHeight={getItemHeight}
1056
+ keyExtractor={getKey}
1057
+ renderItem={renderWrappedChild}
1058
+ />
1059
+ ) : (
1060
+ range(0, data.length).map(renderWrappedChild)
1061
+ )
1062
+ )
1063
+ )}
1060
1064
  </div>
1061
1065
  </ListViewDraggingContext.Provider>
1062
1066
  );