@noya-app/noya-designsystem 0.1.47 → 0.1.49

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 (55) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +18 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +389 -241
  5. package/dist/index.d.ts +389 -241
  6. package/dist/index.js +4802 -3990
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +4715 -3915
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +7 -7
  11. package/src/__tests__/validateDropIndicator.test.ts +263 -0
  12. package/src/components/ActionMenu.tsx +51 -0
  13. package/src/components/ActivityIndicator.tsx +7 -1
  14. package/src/components/BaseToolbar.tsx +2 -2
  15. package/src/components/Checkbox.tsx +2 -2
  16. package/src/components/Chip.tsx +7 -6
  17. package/src/components/Collection.tsx +30 -2
  18. package/src/components/Combobox.tsx +27 -15
  19. package/src/components/ComboboxMenu.tsx +15 -6
  20. package/src/components/Dialog.tsx +17 -12
  21. package/src/components/DimensionInput.tsx +14 -12
  22. package/src/components/Drawer.tsx +98 -0
  23. package/src/components/EditableText.tsx +3 -1
  24. package/src/components/FillInputField.tsx +2 -2
  25. package/src/components/Grid.tsx +161 -111
  26. package/src/components/GridView.tsx +73 -41
  27. package/src/components/InputField.tsx +148 -208
  28. package/src/components/Label.tsx +4 -68
  29. package/src/components/LabeledField.tsx +7 -1
  30. package/src/components/List.tsx +135 -50
  31. package/src/components/ListView.tsx +63 -43
  32. package/src/components/MediaThumbnail.tsx +117 -0
  33. package/src/components/Message.tsx +8 -8
  34. package/src/components/NoyaLogo.tsx +41 -0
  35. package/src/components/SearchCompletionMenu.tsx +30 -16
  36. package/src/components/SegmentedControl.tsx +1 -1
  37. package/src/components/SelectMenu.tsx +28 -21
  38. package/src/components/Slider.tsx +16 -7
  39. package/src/components/Sortable.tsx +125 -62
  40. package/src/components/internal/Menu.tsx +45 -25
  41. package/src/components/internal/MenuViewport.tsx +7 -1
  42. package/src/components/internal/SelectItem.tsx +53 -28
  43. package/src/components/internal/__tests__/Menu.test.tsx +12 -0
  44. package/src/components/workspace/DrawerWorkspaceLayout.tsx +86 -0
  45. package/src/components/workspace/PanelWorkspaceLayout.tsx +102 -0
  46. package/src/components/{WorkspaceLayout.tsx → workspace/WorkspaceLayout.tsx} +109 -106
  47. package/src/components/workspace/types.ts +31 -0
  48. package/src/contexts/DialogContext.tsx +49 -34
  49. package/src/hooks/usePreservePanelSize.tsx +1 -5
  50. package/src/index.css +8 -1
  51. package/src/index.tsx +6 -1
  52. package/src/theme/index.ts +2 -0
  53. package/src/utils/classNames.ts +51 -3
  54. package/src/utils/inputs.ts +21 -0
  55. package/tailwind.config.ts +8 -0
@@ -6,11 +6,13 @@ import {
6
6
  DragOverlay,
7
7
  DragStartEvent,
8
8
  PointerSensor,
9
- Translate,
9
+ UniqueIdentifier,
10
+ useDndContext,
10
11
  useSensor,
11
12
  useSensors,
12
13
  } from "@dnd-kit/core";
13
14
  import {
15
+ horizontalListSortingStrategy,
14
16
  SortableContext,
15
17
  useSortable,
16
18
  verticalListSortingStrategy,
@@ -23,25 +25,25 @@ export type RelativeDropPosition = "above" | "below" | "inside";
23
25
 
24
26
  export type DropValidator = (
25
27
  sourceIndex: number,
26
- destinationIndex: number,
28
+ targetIndex: number,
27
29
  position: RelativeDropPosition
28
30
  ) => boolean;
29
31
 
30
- export const normalizeListDestinationIndex = (
32
+ export const normalizeListTargetIndex = (
31
33
  index: number,
32
34
  position: "above" | "below"
33
35
  ): number => {
34
36
  return position === "above" ? index : index + 1;
35
37
  };
36
38
 
37
- const defaultAcceptsDrop: DropValidator = (
39
+ export const defaultAcceptsDrop: DropValidator = (
38
40
  sourceIndex,
39
- destinationIndex,
41
+ targetIndex,
40
42
  position
41
43
  ) => {
42
44
  if (position === "inside") return false;
43
45
 
44
- const normalized = normalizeListDestinationIndex(destinationIndex, position);
46
+ const normalized = normalizeListTargetIndex(targetIndex, position);
45
47
 
46
48
  if (sourceIndex === normalized || sourceIndex + 1 === normalized) {
47
49
  return false;
@@ -50,35 +52,52 @@ const defaultAcceptsDrop: DropValidator = (
50
52
  return true;
51
53
  };
52
54
 
53
- const SortableItemContext = React.createContext<{
54
- keys: string[];
55
- position: Translate;
55
+ export type SortableItemContextProps = {
56
+ keys: UniqueIdentifier[];
56
57
  acceptsDrop: DropValidator;
57
- setActivatorEvent: (event: PointerEvent) => void;
58
58
  axis: "x" | "y";
59
- }>({
59
+ position: { x: number; y: number };
60
+ setActivatorEvent: (event: PointerEvent) => void;
61
+ getDropTargetParentIndex?: (index: number) => number | undefined;
62
+ };
63
+
64
+ const SortableItemContext = React.createContext<SortableItemContextProps>({
60
65
  keys: [],
61
- position: { x: 0, y: 0 },
62
66
  acceptsDrop: defaultAcceptsDrop,
63
- setActivatorEvent: () => {},
64
67
  axis: "y",
68
+ position: { x: 0, y: 0 },
69
+ setActivatorEvent: () => {},
70
+ getDropTargetParentIndex: undefined,
65
71
  });
66
72
 
67
- function validateDropIndicator(
68
- acceptsDrop: DropValidator,
69
- keys: string[],
70
- activeId: string,
71
- overId: string,
72
- offsetStart: number,
73
- elementStart: number,
74
- elementSize: number
75
- ): RelativeDropPosition | undefined {
76
- const activeIndex = keys.indexOf(activeId);
77
- const overIndex = keys.indexOf(overId);
73
+ type ValidateDropIndicatorParams = {
74
+ acceptsDrop: DropValidator; // Function that validates if a drop is allowed
75
+ keys: UniqueIdentifier[]; // Array of all sortable item IDs
76
+ activeId: UniqueIdentifier; // ID of item being dragged
77
+ overId: UniqueIdentifier; // ID of item being dragged over
78
+ offsetStart: number; // Current drag position (x or y coordinate)
79
+ elementStart: number; // Start position of target element (left or top)
80
+ elementSize: number; // Size of target element (width or height)
81
+ };
82
+
83
+ export function validateDropIndicator({
84
+ acceptsDrop,
85
+ keys,
86
+ activeId,
87
+ overId,
88
+ offsetStart,
89
+ elementStart,
90
+ elementSize,
91
+ }: ValidateDropIndicatorParams): RelativeDropPosition | undefined {
92
+ const activeIndex = keys.findIndex((id) => id === activeId); // index of item being dragged
93
+ const overIndex = keys.findIndex((id) => id === overId); // index of item being dragged over
94
+
95
+ if (activeIndex === -1 || overIndex === -1) return undefined;
78
96
 
79
97
  const acceptsDropInside = acceptsDrop(activeIndex, overIndex, "inside");
80
98
 
81
- // If we're in the center of the element, prefer dropping inside
99
+ // If cursor is in the middle third of the element (between 1/3 and 2/3)
100
+ // and dropping inside is allowed, show "inside" indicator
82
101
  if (
83
102
  offsetStart >= elementStart + elementSize / 3 &&
84
103
  offsetStart <= elementStart + (elementSize * 2) / 3 &&
@@ -89,7 +108,6 @@ function validateDropIndicator(
89
108
  // Are we over the top or bottom half of the element?
90
109
  const indicator =
91
110
  offsetStart < elementStart + elementSize / 2 ? "above" : "below";
92
-
93
111
  // Drop above or below if possible, falling back to inside
94
112
  return acceptsDrop(activeIndex, overIndex, indicator)
95
113
  ? indicator
@@ -105,13 +123,13 @@ function validateDropIndicator(
105
123
  type UseSortableReturnType = ReturnType<typeof useSortable>;
106
124
 
107
125
  interface ItemProps<T> {
108
- id: string;
126
+ id: UniqueIdentifier;
109
127
  disabled?: boolean;
110
128
  children: (
111
129
  props: {
112
130
  ref: React.Ref<T>;
113
131
  relativeDropPosition?: RelativeDropPosition;
114
- [key: string]: any;
132
+ style?: React.CSSProperties;
115
133
  } & UseSortableReturnType["attributes"]
116
134
  ) => JSX.Element;
117
135
  }
@@ -121,18 +139,25 @@ function SortableItem<T extends HTMLElement>({
121
139
  disabled,
122
140
  children,
123
141
  }: ItemProps<T>) {
124
- const { keys, position, acceptsDrop, setActivatorEvent, axis } =
125
- React.useContext(SortableItemContext);
142
+ const {
143
+ keys,
144
+ position,
145
+ acceptsDrop,
146
+ setActivatorEvent,
147
+ axis,
148
+ getDropTargetParentIndex,
149
+ } = React.useContext(SortableItemContext);
126
150
  const sortable = useSortable({ id, disabled });
151
+ const { activatorEvent } = useDndContext();
127
152
 
128
153
  const {
129
154
  active,
130
- activatorEvent,
131
155
  attributes,
132
156
  listeners,
133
157
  setNodeRef,
134
158
  isDragging,
135
159
  index,
160
+ activeIndex,
136
161
  overIndex,
137
162
  over,
138
163
  } = sortable;
@@ -147,23 +172,40 @@ function SortableItem<T extends HTMLElement>({
147
172
  const offsetTop = eventY + position.y;
148
173
 
149
174
  const ref = React.useCallback((node: T) => setNodeRef(node), [setNodeRef]);
175
+ const parentIndex =
176
+ overIndex === -1 ? undefined : getDropTargetParentIndex?.(overIndex);
177
+ const isValidParent =
178
+ parentIndex === undefined || parentIndex === -1
179
+ ? false
180
+ : acceptsDrop(activeIndex, parentIndex, "inside");
181
+ const overIdAcceptsDrop =
182
+ overIndex === -1
183
+ ? undefined
184
+ : acceptsDrop(activeIndex, overIndex, "inside");
185
+
186
+ const relativeDropPosition =
187
+ index >= 0 && index === overIndex && !isDragging && active && over
188
+ ? validateDropIndicator({
189
+ acceptsDrop,
190
+ keys,
191
+ activeId: active.id,
192
+ overId: over.id,
193
+ offsetStart: axis === "x" ? offsetLeft : offsetTop,
194
+ elementStart: axis === "x" ? over.rect.left : over.rect.top,
195
+ elementSize: axis === "x" ? over.rect.width : over.rect.height,
196
+ })
197
+ : undefined;
198
+
199
+ const showDragInsideIndicatorOnParent =
200
+ !overIdAcceptsDrop && isValidParent && parentIndex === index;
150
201
 
151
202
  return children({
152
203
  ref,
153
204
  ...attributes,
154
205
  ...listeners,
155
206
  relativeDropPosition:
156
- index >= 0 && index === overIndex && !isDragging && active && over
157
- ? validateDropIndicator(
158
- acceptsDrop,
159
- keys,
160
- active.id,
161
- over.id,
162
- axis === "x" ? offsetLeft : offsetTop,
163
- axis === "x" ? over.rect.offsetLeft : over.rect.offsetTop,
164
- axis === "x" ? over.rect.width : over.rect.height
165
- )
166
- : undefined,
207
+ relativeDropPosition ??
208
+ (showDragInsideIndicatorOnParent ? "inside" : undefined),
167
209
  });
168
210
  }
169
211
 
@@ -172,16 +214,17 @@ function SortableItem<T extends HTMLElement>({
172
214
  * ------------------------------------------------------------------------- */
173
215
 
174
216
  interface RootProps {
175
- keys: string[];
217
+ keys: UniqueIdentifier[];
176
218
  children: React.ReactNode;
177
219
  renderOverlay?: (index: number) => React.ReactNode;
178
220
  onMoveItem?: (
179
221
  sourceIndex: number,
180
- destinationIndex: number,
222
+ targetIndex: number,
181
223
  position: RelativeDropPosition
182
224
  ) => void;
183
- acceptsDrop?: DropValidator;
184
- axis?: "x" | "y";
225
+ acceptsDrop?: SortableItemContextProps["acceptsDrop"];
226
+ axis?: SortableItemContextProps["axis"];
227
+ getDropTargetParentIndex?: SortableItemContextProps["getDropTargetParentIndex"];
185
228
  }
186
229
 
187
230
  function SortableRoot({
@@ -191,6 +234,7 @@ function SortableRoot({
191
234
  renderOverlay,
192
235
  acceptsDrop = defaultAcceptsDrop,
193
236
  axis = "y",
237
+ getDropTargetParentIndex,
194
238
  }: RootProps) {
195
239
  const sensors = useSensors(
196
240
  useSensor(PointerSensor, {
@@ -202,16 +246,19 @@ function SortableRoot({
202
246
 
203
247
  const [activeIndex, setActiveIndex] = React.useState<number | undefined>();
204
248
  const activatorEvent = React.useRef<PointerEvent | null>(null);
249
+ const [position, setPosition] = React.useState<{ x: number; y: number }>({
250
+ x: 0,
251
+ y: 0,
252
+ });
205
253
 
206
254
  const setActivatorEvent = React.useCallback((event: PointerEvent) => {
207
255
  activatorEvent.current = event;
208
256
  }, []);
209
257
 
210
- const [position, setPosition] = React.useState<Translate>({ x: 0, y: 0 });
211
-
212
258
  const handleDragStart = React.useCallback(
213
259
  (event: DragStartEvent) => {
214
- setActiveIndex(keys.indexOf(event.active.id));
260
+ const index = keys.findIndex((id) => id === event.active.id);
261
+ setActiveIndex(index);
215
262
  },
216
263
  [keys]
217
264
  );
@@ -225,25 +272,26 @@ function SortableRoot({
225
272
  const { active, over } = event;
226
273
 
227
274
  setActiveIndex(undefined);
228
-
229
275
  if (over && active.id !== over.id) {
230
- const oldIndex = keys.indexOf(active.id);
231
- const newIndex = keys.indexOf(over.id);
276
+ const oldIndex = keys.findIndex((id) => id === active.id);
277
+ const newIndex = keys.findIndex((id) => id === over.id);
278
+
279
+ if (oldIndex === -1 || newIndex === -1) return;
232
280
 
233
281
  const eventX = activatorEvent.current?.clientX ?? 0;
234
282
  const eventY = activatorEvent.current?.clientY ?? 0;
235
283
  const offsetLeft = eventX + position.x;
236
284
  const offsetTop = eventY + position.y;
237
285
 
238
- const indicator = validateDropIndicator(
286
+ const indicator = validateDropIndicator({
239
287
  acceptsDrop,
240
288
  keys,
241
- active.id,
242
- over.id,
243
- axis === "x" ? offsetLeft : offsetTop,
244
- axis === "x" ? over.rect.offsetLeft : over.rect.offsetTop,
245
- axis === "x" ? over.rect.width : over.rect.height
246
- );
289
+ activeId: active.id,
290
+ overId: over.id,
291
+ offsetStart: axis === "x" ? offsetLeft : offsetTop,
292
+ elementStart: axis === "x" ? over.rect.left : over.rect.top,
293
+ elementSize: axis === "x" ? over.rect.width : over.rect.height,
294
+ });
247
295
 
248
296
  if (!indicator) return;
249
297
 
@@ -262,14 +310,22 @@ function SortableRoot({
262
310
  return (
263
311
  <SortableItemContext.Provider
264
312
  value={React.useMemo(
265
- () => ({
313
+ (): SortableItemContextProps => ({
266
314
  keys,
267
315
  acceptsDrop,
268
316
  position,
269
317
  setActivatorEvent,
270
318
  axis,
319
+ getDropTargetParentIndex,
271
320
  }),
272
- [acceptsDrop, axis, keys, position, setActivatorEvent]
321
+ [
322
+ acceptsDrop,
323
+ axis,
324
+ getDropTargetParentIndex,
325
+ keys,
326
+ position,
327
+ setActivatorEvent,
328
+ ]
273
329
  )}
274
330
  >
275
331
  <DndContext
@@ -279,7 +335,14 @@ function SortableRoot({
279
335
  onDragMove={handleDragMove}
280
336
  onDragEnd={handleDragEnd}
281
337
  >
282
- <SortableContext items={keys} strategy={verticalListSortingStrategy}>
338
+ <SortableContext
339
+ items={keys}
340
+ strategy={
341
+ axis === "x"
342
+ ? horizontalListSortingStrategy
343
+ : verticalListSortingStrategy
344
+ }
345
+ >
283
346
  {children}
284
347
  </SortableContext>
285
348
  {mounted &&
@@ -62,10 +62,18 @@ export type SeparatorItem = {
62
62
  type: "separator";
63
63
  };
64
64
 
65
+ export const separator: SeparatorItem = {
66
+ type: "separator",
67
+ };
68
+
69
+ type FalsyReactNode = false | "" | null | undefined;
70
+
71
+ export type MenuItemIcon = IconName | React.ReactElement | FalsyReactNode;
72
+
65
73
  type BaseMenuItem = {
66
74
  checked?: boolean;
67
75
  disabled?: boolean;
68
- icon?: Exclude<ReactNode, string> | IconName;
76
+ icon?: MenuItemIcon;
69
77
  role?: MenuItemRole;
70
78
  alwaysInclude?: boolean;
71
79
  title: NonNullable<ReactNode>;
@@ -83,6 +91,7 @@ export type SelectableMenuItem<T extends string> = BaseMenuItem & {
83
91
  type?: undefined;
84
92
  shortcut?: string;
85
93
  value: T;
94
+ testSelected?: boolean;
86
95
  };
87
96
 
88
97
  type SubMenuItem<T extends string> = BaseMenuItem & {
@@ -132,39 +141,48 @@ export const isSelectableMenuItemWithScore = (
132
141
  ): item is ScoredSelectableMenuItem<string> =>
133
142
  isSelectableMenuItem(item) && "index" in item && "score" in item;
134
143
 
135
- export const CHECKBOX_WIDTH = 16;
144
+ export const getMenuItemKey = <T extends string>(
145
+ item: MenuItem<T>,
146
+ index: number
147
+ ): string => {
148
+ switch (item.type) {
149
+ case "separator":
150
+ return index.toString();
151
+ case "sectionHeader":
152
+ return item.id;
153
+ case "submenu":
154
+ return item.id;
155
+ default:
156
+ return item.value;
157
+ }
158
+ };
159
+
160
+ export const CHECKBOX_WIDTH = 15;
136
161
  export const CHECKBOX_RIGHT_INSET = 8;
162
+ export const CHECKBOX_INDENT_WIDTH = 6;
137
163
 
138
164
  export const styles = {
139
- separatorStyle: "h-px bg-divider mx-4 my-1",
140
-
165
+ separatorStyle: "h-px bg-divider mx-3 my-1",
166
+ selectedItemStyle:
167
+ "focus:outline-none focus:text-white focus:bg-primary focus:kbd:text-white",
168
+ testSelectedItemStyle: "outline-none text-white bg-primary kbd:text-white",
141
169
  itemStyle: ({ disabled }: { disabled?: boolean }) => `
142
170
  flex-none select-none cursor-pointer rounded
143
- py-1.5 px-3
144
- focus:outline-none focus:text-white focus:bg-primary
145
- focus:kbd:text-white
171
+ py-1.5 px-2
146
172
  active:bg-primary-light
147
173
  transition-colors
148
174
  flex items-center
149
175
  font-sans text-button font-medium
150
176
  ${disabled ? "text-text-disabled" : ""}
151
177
  `,
152
-
153
- itemIndicatorStyle: `
154
- flex items-center
155
- relative
156
- -left-2.5
157
- -mr-2
158
- `,
159
-
160
- contentStyle: `
161
- rounded
162
- bg-popover-background
163
- text-text
164
- shadow-[0_2px_4px_rgba(0,0,0,0.2),_0_0_12px_rgba(0,0,0,0.1)]
165
- z-menu
166
- py-1
167
- `,
178
+ itemIndicator: {
179
+ className: "flex items-center relative -left-2.5",
180
+ style: {
181
+ marginRight: -CHECKBOX_RIGHT_INSET,
182
+ },
183
+ },
184
+ contentStyle:
185
+ "rounded bg-popover-background text-text shadow-[0_2px_4px_rgba(0,0,0,0.2),_0_0_12px_rgba(0,0,0,0.1)] z-menu py-1",
168
186
  };
169
187
 
170
188
  function getKeyboardShortcuts<T extends string>(
@@ -256,12 +274,14 @@ export const SectionHeader = memo(function SectionHeader({
256
274
  variant === "label"
257
275
  ? `text-label ${textStyles.label} !font-bold text-text-disabled`
258
276
  : "font-sans text-heading5 font-normal",
259
- "bg-listview-raised-background flex items-center py-1.5 px-4",
277
+ "bg-listview-raised-background flex items-center py-1.5 px-3",
260
278
  isFirst && "-mt-1"
261
279
  )}
262
280
  >
263
281
  {indented && (
264
- <Spacer.Horizontal size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET} />
282
+ <Spacer.Horizontal
283
+ size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET + CHECKBOX_INDENT_WIDTH}
284
+ />
265
285
  )}
266
286
  {title}
267
287
  </span>
@@ -2,6 +2,7 @@ import { ChevronRightIcon } from "@noya-app/noya-icons";
2
2
  import * as RadixContextMenu from "@radix-ui/react-context-menu";
3
3
  import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
4
4
  import React from "react";
5
+ import { cx } from "../..";
5
6
  import { MenuItem, SectionHeader, styles } from "./Menu";
6
7
  import { SelectItem } from "./SelectItem";
7
8
 
@@ -15,6 +16,7 @@ type MenuItemComponent = React.ForwardRefExoticComponent<
15
16
  children?: React.ReactNode;
16
17
  value: string;
17
18
  onSelect?: (event: any) => void;
19
+ onClick?: (event: React.MouseEvent<any>) => void;
18
20
  }>
19
21
  >;
20
22
  type MenuCheckboxItemComponent = React.FC<{
@@ -94,7 +96,10 @@ export const MenuViewport = <T extends string>({
94
96
  return (
95
97
  <Components.Sub key={item.id}>
96
98
  <Components.SubTrigger
97
- className={styles.itemStyle({ disabled: item.disabled })}
99
+ className={cx(
100
+ styles.itemStyle({ disabled: item.disabled }),
101
+ styles.selectedItemStyle
102
+ )}
98
103
  asChild
99
104
  onClick={(e) => {
100
105
  e.stopPropagation();
@@ -141,6 +146,7 @@ export const MenuViewport = <T extends string>({
141
146
  onSelect={onSelect}
142
147
  Components={Components}
143
148
  indented={hasCheckedItem}
149
+ testSelected={item.testSelected}
144
150
  >
145
151
  {item.title ?? item.value}
146
152
  </SelectItem>
@@ -3,8 +3,10 @@ import { renderIcon } from "../Icons";
3
3
  import { CheckIcon } from "@noya-app/noya-icons";
4
4
  import * as Select from "@radix-ui/react-select";
5
5
  import React, { useCallback } from "react";
6
+ import { cx } from "../../utils/classNames";
6
7
  import { Spacer } from "../Spacer";
7
8
  import {
9
+ CHECKBOX_INDENT_WIDTH,
8
10
  CHECKBOX_RIGHT_INSET,
9
11
  CHECKBOX_WIDTH,
10
12
  KeyboardShortcut,
@@ -24,6 +26,7 @@ export const SelectItem = React.forwardRef(
24
26
  onSelect,
25
27
  shortcut,
26
28
  indented,
29
+ testSelected,
27
30
  ...props
28
31
  }: Select.SelectItemProps & {
29
32
  icon?: React.ReactNode;
@@ -32,6 +35,7 @@ export const SelectItem = React.forwardRef(
32
35
  onSelect?: (value: any) => void;
33
36
  shortcut?: string;
34
37
  indented?: boolean;
38
+ testSelected?: boolean;
35
39
  },
36
40
  forwardedRef: React.ForwardedRef<HTMLDivElement>
37
41
  ) => {
@@ -40,50 +44,71 @@ export const SelectItem = React.forwardRef(
40
44
  onSelect?.(value);
41
45
  }, [onSelect, value]);
42
46
 
47
+ // Prevent the click from propagating outside the menu
48
+ const cancelClickPropagation = useCallback(
49
+ (event: React.MouseEvent<HTMLDivElement>) => {
50
+ event.stopPropagation();
51
+ },
52
+ []
53
+ );
54
+
43
55
  if (checked && Components.CheckboxItem) {
44
56
  return (
45
- <Components.CheckboxItem
46
- checked={checked}
47
- disabled={disabled}
48
- onSelect={handleSelectItem}
49
- className={styles.itemStyle({ disabled })}
50
- >
51
- {Components.ItemIndicator && (
52
- <Components.ItemIndicator className={styles.itemIndicatorStyle}>
53
- <CheckIcon />
54
- </Components.ItemIndicator>
55
- )}
56
- {icon && (
57
- <>
58
- {renderIcon(icon)}
59
- <Spacer.Horizontal size={8} />
60
- </>
61
- )}
62
- {children}
63
- {shortcut && (
64
- <>
65
- <Spacer.Horizontal />
66
- <Spacer.Horizontal size={24} />
67
- <KeyboardShortcut shortcut={shortcut} />
68
- </>
69
- )}
70
- </Components.CheckboxItem>
57
+ <div className="px-1">
58
+ <Components.CheckboxItem
59
+ checked={checked}
60
+ disabled={disabled}
61
+ onSelect={handleSelectItem}
62
+ className={cx(
63
+ styles.itemStyle({ disabled }),
64
+ styles.selectedItemStyle,
65
+ testSelected && styles.testSelectedItemStyle
66
+ )}
67
+ >
68
+ <Spacer.Horizontal size={CHECKBOX_INDENT_WIDTH} />
69
+ {Components.ItemIndicator && (
70
+ <Components.ItemIndicator {...styles.itemIndicator}>
71
+ <CheckIcon />
72
+ </Components.ItemIndicator>
73
+ )}
74
+ {icon && (
75
+ <>
76
+ {renderIcon(icon)}
77
+ <Spacer.Horizontal size={8} />
78
+ </>
79
+ )}
80
+ {children}
81
+ {shortcut && (
82
+ <>
83
+ <Spacer.Horizontal />
84
+ <Spacer.Horizontal size={24} />
85
+ <KeyboardShortcut shortcut={shortcut} />
86
+ </>
87
+ )}
88
+ </Components.CheckboxItem>
89
+ </div>
71
90
  );
72
91
  }
73
92
 
74
93
  return (
75
94
  <div className="px-1">
76
95
  <Components.Item
77
- className={styles.itemStyle({ disabled })}
96
+ className={cx(
97
+ styles.itemStyle({ disabled }),
98
+ styles.selectedItemStyle,
99
+ testSelected && styles.testSelectedItemStyle
100
+ )}
78
101
  disabled={disabled}
79
102
  value={value}
80
103
  ref={forwardedRef}
104
+ onClick={cancelClickPropagation}
81
105
  onSelect={handleSelectItem}
82
106
  {...props}
83
107
  >
84
108
  <div className="flex flex-1 items-center">
109
+ {indented && <Spacer.Horizontal size={CHECKBOX_INDENT_WIDTH} />}
85
110
  {checked ? (
86
- <div className={styles.itemIndicatorStyle}>
111
+ <div {...styles.itemIndicator}>
87
112
  <CheckIcon />
88
113
  </div>
89
114
  ) : indented ? (
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { styles } from "../Menu";
3
+
4
+ describe("Menu", () => {
5
+ test("styles.selectedItemStyle should have the same styles as styles.testSelectedItemStyle without the focus: prefix", () => {
6
+ const selectedWithoutFocus = styles.selectedItemStyle.replace(
7
+ /focus:/g,
8
+ ""
9
+ );
10
+ expect(selectedWithoutFocus).toEqual(styles.testSelectedItemStyle);
11
+ });
12
+ });