@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -11,7 +11,12 @@
11
11
  "require": "./dist/index.js",
12
12
  "types": "./dist/index.d.ts"
13
13
  },
14
- "./index.css": "./dist/index.css"
14
+ "./index.css": "./dist/index.css",
15
+ "./emojis": {
16
+ "import": "./dist/emojis.mjs",
17
+ "require": "./dist/emojis.js",
18
+ "types": "./dist/emojis.d.ts"
19
+ }
15
20
  },
16
21
  "scripts": {
17
22
  "build:css": "tailwindcss -i ./src/index.css -o ./dist/index.css --postcss",
@@ -20,24 +25,23 @@
20
25
  "lint": "eslint . --max-warnings 0"
21
26
  },
22
27
  "dependencies": {
23
- "@base-ui-components/react": "1.0.0-beta.4",
28
+ "@base-ui/react": "1.0.0",
24
29
  "@dnd-kit/core": "6.3.1",
25
30
  "@dnd-kit/sortable": "10.0.0",
26
- "@noya-app/noya-colorpicker": "0.1.32",
31
+ "@noya-app/noya-colorpicker": "0.1.33",
27
32
  "@noya-app/noya-utils": "0.1.9",
28
- "@noya-app/noya-geometry": "0.1.16",
29
- "@noya-app/noya-icons": "0.1.16",
33
+ "@noya-app/noya-geometry": "0.1.17",
34
+ "@noya-app/noya-icons": "0.1.18",
30
35
  "@noya-app/noya-keymap": "0.1.4",
31
- "@noya-app/noya-tailwind-config": "0.1.9",
32
- "radix-ui": "1.4.2",
36
+ "@noya-app/noya-tailwind-config": "0.1.10",
33
37
  "@radix-ui/react-compose-refs": "^1.0.0",
34
38
  "@radix-ui/primitive": "^1.0.1",
35
39
  "@radix-ui/react-utils": "^0.0.5",
36
40
  "@radix-ui/react-popper": "1.2.3",
37
41
  "@radix-ui/utils": "0.0.3",
42
+ "gemoji": "8.1.0",
38
43
  "immer": "9.0.5",
39
44
  "kiwi.js": "^1.1.3",
40
- "react-resizable-panels": "3.0.3",
41
45
  "vscode-fuzzy-scorer": "^0.0.4"
42
46
  },
43
47
  "devDependencies": {
@@ -1,4 +1,4 @@
1
- // Bun Snapshot v1, https://goo.gl/fbAQLP
1
+ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
2
 
3
3
  exports[`filter single match 1`] = `
4
4
  [
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Avatar as AvatarPrimitive } from "radix-ui";
3
+ import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
4
4
  import React, { forwardRef, useMemo } from "react";
5
5
  import { cx } from "../utils/classNames";
6
6
  import { colorFromString } from "../utils/colorFromString";
@@ -21,9 +21,9 @@ const getInnerAvatarStyles = (
21
21
  };
22
22
 
23
23
  const AvatarRoot = React.forwardRef<
24
- React.ComponentRef<typeof AvatarPrimitive.Root>,
25
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> &
26
- Pick<AvatarProps, "size" | "variant" | "backgroundColor" | "disabled">
24
+ HTMLSpanElement,
25
+ Omit<React.HTMLAttributes<HTMLSpanElement>, "className"> &
26
+ Pick<AvatarProps, "size" | "variant" | "backgroundColor" | "disabled" | "className">
27
27
  >(
28
28
  (
29
29
  {
@@ -63,9 +63,9 @@ const AvatarRoot = React.forwardRef<
63
63
  );
64
64
 
65
65
  const AvatarImage = React.forwardRef<
66
- React.ComponentRef<typeof AvatarPrimitive.Image>,
67
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> &
68
- Pick<AvatarProps, "size" | "variant">
66
+ HTMLImageElement,
67
+ Omit<React.ImgHTMLAttributes<HTMLImageElement>, "className"> &
68
+ Pick<AvatarProps, "size" | "variant" | "className">
69
69
  >(({ className, size = 21, variant = "normal", ...props }, ref) => {
70
70
  return (
71
71
  <AvatarPrimitive.Image
@@ -85,9 +85,9 @@ function getMonogram(text?: string) {
85
85
  }
86
86
 
87
87
  const AvatarFallback = React.forwardRef<
88
- React.ComponentRef<typeof AvatarPrimitive.Fallback>,
89
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> &
90
- Pick<AvatarProps, "size" | "variant" | "backgroundColor">
88
+ HTMLSpanElement,
89
+ Omit<React.HTMLAttributes<HTMLSpanElement>, "className"> &
90
+ Pick<AvatarProps, "size" | "variant" | "backgroundColor" | "className">
91
91
  >(
92
92
  (
93
93
  { className, children, style, size = 21, backgroundColor, ...props },
@@ -40,7 +40,7 @@ export function Banner({
40
40
  return (
41
41
  <div
42
42
  className={cx(
43
- "n-flex n-py-1 n-px-1.5 n-items-center n-justify-center n-select-none n-rounded n-gap-1.5 n-min-h-input-height",
43
+ "n-flex n-py-input-padding-y n-px-input-padding-x n-items-center n-justify-center n-select-none n-rounded n-gap-1.5 n-min-h-input-height",
44
44
  variant === "muted" && "n-bg-input-background",
45
45
  variant === "info" &&
46
46
  "n-bg-banner-info-background n-border n-border-banner-info-border",
@@ -13,12 +13,15 @@ export interface BaseToolbarProps {
13
13
  className?: string;
14
14
  innerClassName?: string;
15
15
  enableAbsoluteBreakpoint?: boolean;
16
+ /** Whether to enable container query classes. @default true */
17
+ enableContainerQuery?: boolean;
16
18
  }
17
19
 
18
- const toolbarStyle = {
20
+ /** Shared toolbar style that sets button and input backgrounds to transparent */
21
+ export const toolbarTransparentStyle = {
19
22
  [cssVarNames.colors.buttonBackground]: "transparent",
20
23
  [cssVarNames.colors.inputBackground]: "transparent",
21
- };
24
+ } as const;
22
25
 
23
26
  export function BaseToolbarContainer({
24
27
  children,
@@ -34,7 +37,10 @@ export function BaseToolbarContainer({
34
37
  enableAbsoluteBreakpoint?: boolean;
35
38
  }) {
36
39
  return (
37
- <div className={cx("n-flex n-flex-col", className)} style={toolbarStyle}>
40
+ <div
41
+ className={cx("n-flex n-flex-col", className)}
42
+ style={toolbarTransparentStyle}
43
+ >
38
44
  <div
39
45
  className={cx(
40
46
  "n-flex n-items-center n-bg-sidebar-background n-flex-none n-relative",
@@ -61,10 +67,12 @@ export function BaseToolbar({
61
67
  className,
62
68
  innerClassName,
63
69
  enableAbsoluteBreakpoint = true,
70
+ enableContainerQuery = true,
64
71
  }: BaseToolbarProps) {
65
72
  const childrenContainerClassName = cx(
66
73
  "n-flex n-items-center n-justify-center n-text-text-muted n-pointer-events-none n-inset-0 n-pl-1 n-pr-1 n-min-w-0",
67
- "@xl/toolbar:!n-absolute @xl/toolbar:!n-pl-0 @xl/toolbar:!n-pr-0"
74
+ enableContainerQuery &&
75
+ "@xl/toolbar:!n-absolute @xl/toolbar:!n-pl-0 @xl/toolbar:!n-pr-0"
68
76
  );
69
77
 
70
78
  const childrenElement = children && (
@@ -93,16 +101,22 @@ export function BaseToolbar({
93
101
  </>
94
102
  )}
95
103
  <Spacer.Horizontal size={10} />
104
+ {leftElement && !enableContainerQuery && (
105
+ <>
106
+ {leftElement}
107
+ {childrenElement && <Spacer.Horizontal size={10} />}
108
+ </>
109
+ )}
96
110
  {childrenElement}
97
- {leftElement && (
111
+ {leftElement && enableContainerQuery && (
98
112
  <>
99
113
  {childrenElement && (
100
114
  <>
101
115
  <Spacer.Horizontal size={10} className="@xl/toolbar:!n-hidden" />
102
116
  <DividerVertical className="@xl/toolbar:!n-hidden" />
117
+ <Spacer.Horizontal size={10} className="@xl/toolbar:!n-hidden" />
103
118
  </>
104
119
  )}
105
- <Spacer.Horizontal size={10} className="@xl/toolbar:!n-hidden" />
106
120
  {leftElement}
107
121
  <Spacer.Horizontal size={10} />
108
122
  </>
@@ -41,7 +41,8 @@ const variantStyles: Record<ButtonVariant, string> = {
41
41
 
42
42
  const sizeStyles: Record<ButtonSize | "thin" | "none", string> = {
43
43
  small: "n-px-[2px] n-font-sans n-text-[10px] n-font-medium",
44
- normal: "n-px-[6px] n-py-1 n-font-sans n-text-button n-font-medium",
44
+ normal:
45
+ "n-px-input-padding-x n-py-input-padding-y n-font-sans n-text-button n-font-medium",
45
46
  large: "n-p-3 n-px-4 n-font-sans n-text-heading4 n-font-medium",
46
47
  thin: "n-px-[1px] n-py-1 n-font-sans n-text-button n-font-medium",
47
48
  floating: "n-px-1 n-py-0 n-font-sans n-text-[12px] n-font-medium",
@@ -17,7 +17,7 @@ const BASE_STYLES =
17
17
  const SIZE_STYLES = {
18
18
  xl: "n-text-[12px] n-p-[6px_12px]",
19
19
  large: "n-text-[11px] n-p-[4px_8px]",
20
- medium: "n-text-[10px] n-p-[2px_6px]",
20
+ medium: "n-text-[10px] n-py-0.5 n-px-input-padding-x",
21
21
  small: "n-text-[9px] n-p-[0px_4px]",
22
22
  } as const;
23
23
 
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
4
- import React, { useMemo } from "react";
3
+ import { Toggle } from "@base-ui/react/toggle";
4
+ import { ToggleGroup } from "@base-ui/react/toggle-group";
5
+ import React, { useCallback, useMemo } from "react";
5
6
  import { cx } from "../utils/classNames";
6
7
  import {
7
8
  ColorSwatch,
@@ -40,6 +41,7 @@ export function ColorSwatchControl(props: ColorSwatchControlProps) {
40
41
  id,
41
42
  style,
42
43
  } = props;
44
+
43
45
  const styles = useMemo(() => {
44
46
  return {
45
47
  display: "grid",
@@ -49,6 +51,15 @@ export function ColorSwatchControl(props: ColorSwatchControlProps) {
49
51
  };
50
52
  }, [size, style]);
51
53
 
54
+ const handleValueChange = useCallback(
55
+ (values: string[]) => {
56
+ if (values.length > 0 && onValueChange) {
57
+ onValueChange(values[0] as TokenValue);
58
+ }
59
+ },
60
+ [onValueChange]
61
+ );
62
+
52
63
  const content = (
53
64
  <div className={className} id={id} style={styles}>
54
65
  {options.map((option) => {
@@ -60,33 +71,30 @@ export function ColorSwatchControl(props: ColorSwatchControlProps) {
60
71
  onFocus={onFocus}
61
72
  onBlur={onBlur}
62
73
  disabled={disabled}
63
- // TODO: decide on "checked" visual treatment
64
74
  className={cx(readOnly ? "n-cursor-default" : "n-cursor-pointer")}
65
75
  checked={value === option}
66
76
  />
67
77
  );
68
78
  return (
69
- <ToggleGroupPrimitive.Item
70
- asChild
71
- value={option as string}
79
+ <Toggle
72
80
  key={`${option as string}-toggle-group-item`}
81
+ value={option as string}
73
82
  disabled={readOnly}
74
- >
75
- {item}
76
- </ToggleGroupPrimitive.Item>
83
+ render={item}
84
+ />
77
85
  );
78
86
  })}
79
87
  </div>
80
88
  );
89
+
81
90
  return (
82
- <ToggleGroupPrimitive.Root
83
- type="single"
84
- value={value as string}
85
- onValueChange={onValueChange}
91
+ <ToggleGroup
92
+ value={value ? [value as string] : []}
93
+ onValueChange={handleValueChange}
86
94
  className="n-w-full"
87
95
  disabled={readOnly}
88
96
  >
89
97
  {content}
90
- </ToggleGroupPrimitive.Root>
98
+ </ToggleGroup>
91
99
  );
92
100
  }
@@ -27,6 +27,16 @@ export const CommandPalette = memo(function CommandPalette({
27
27
  setShowCommandPalette(false);
28
28
  }, [setShowCommandPalette]);
29
29
 
30
+ // Focus the menu when the dialog opens
31
+ React.useEffect(() => {
32
+ if (!showCommandPalette) return;
33
+
34
+ menuRef.current?.focus();
35
+ setTimeout(() => {
36
+ menuRef.current?.focus();
37
+ }, 0);
38
+ }, [showCommandPalette]);
39
+
30
40
  return (
31
41
  <Dialog
32
42
  style={{
@@ -45,12 +55,6 @@ export const CommandPalette = memo(function CommandPalette({
45
55
  setShowCommandPalette(value);
46
56
  }}
47
57
  closeOnInteractOutside
48
- onOpenAutoFocus={() => {
49
- menuRef.current?.focus();
50
- setTimeout(() => {
51
- menuRef.current?.focus();
52
- }, 0);
53
- }}
54
58
  >
55
59
  <div className="n-flex n-flex-col n-flex-1">
56
60
  <AutoSizer>
@@ -1,19 +1,31 @@
1
1
  "use client";
2
2
 
3
+ import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
4
+ import { CheckIcon, ChevronRightIcon } from "@noya-app/noya-icons";
3
5
  import { useKeyboardShortcuts } from "@noya-app/noya-keymap";
4
6
  import { memoGeneric } from "@noya-app/react-utils";
5
- import { ContextMenu as RadixContextMenu } from "radix-ui";
6
- import React, { ReactNode, useCallback, useMemo } from "react";
7
+ import React, { ReactNode, useCallback, useMemo, useState } from "react";
7
8
  import {
8
9
  portalScopeProps,
9
10
  usePortalScopeId,
10
11
  } from "../contexts/PortalScopeContext";
12
+ import { cx } from "../utils/classNames";
13
+ import { Icon, IconName } from "./Icons";
11
14
  import {
15
+ CHECKBOX_INDENT_WIDTH,
16
+ CHECKBOX_RIGHT_INSET,
17
+ CHECKBOX_WIDTH,
12
18
  MenuItem,
19
+ MenuItemIcon,
13
20
  getKeyboardShortcutsForMenuItems,
21
+ isSelectableMenuItem,
22
+ KeyboardShortcut,
23
+ SectionHeaderMenuItem as SectionHeaderMenuItemComponent,
14
24
  styles,
15
25
  } from "./internal/Menu";
16
- import { MenuViewport } from "./internal/MenuViewport";
26
+ import { Spacer } from "./Spacer";
27
+ import { cloneElementWithMergedProps } from "../utils/mergeProps";
28
+
17
29
  export interface MenuItemProps<T extends string> {
18
30
  value?: T;
19
31
  children: ReactNode;
@@ -26,17 +38,210 @@ export interface MenuItemProps<T extends string> {
26
38
  items?: MenuItem<T>[];
27
39
  }
28
40
 
29
- const Components = {
30
- Separator: RadixContextMenu.Separator,
31
- ItemText: React.Fragment,
32
- Item: RadixContextMenu.Item,
33
- CheckboxItem: RadixContextMenu.CheckboxItem,
34
- ItemIndicator: RadixContextMenu.ItemIndicator,
35
- SubTrigger: RadixContextMenu.SubTrigger,
36
- SubContent: RadixContextMenu.SubContent,
37
- Sub: RadixContextMenu.Sub,
38
- Portal: RadixContextMenu.Portal,
39
- };
41
+ const itemStyle = (state: { disabled: boolean; highlighted: boolean }) =>
42
+ cx(
43
+ "n-flex-none n-select-none n-cursor-pointer n-rounded",
44
+ "n-py-1.5 n-px-2",
45
+ "n-transition-colors",
46
+ "n-flex n-items-center",
47
+ "n-font-sans n-text-button n-font-medium",
48
+ "focus:n-outline-none",
49
+ state.disabled && "n-text-text-disabled",
50
+ state.highlighted &&
51
+ "n-text-selected-list-item-text n-bg-selected-list-item-background"
52
+ );
53
+
54
+ function MenuItemContent({
55
+ icon,
56
+ checked,
57
+ indented,
58
+ children,
59
+ shortcut,
60
+ isSubmenuTrigger,
61
+ }: {
62
+ icon?: MenuItemIcon;
63
+ checked?: boolean;
64
+ indented?: boolean;
65
+ children: ReactNode;
66
+ shortcut?: string;
67
+ isSubmenuTrigger?: boolean;
68
+ }) {
69
+ return (
70
+ <>
71
+ {indented && <Spacer.Horizontal size={CHECKBOX_INDENT_WIDTH} />}
72
+ {checked ? (
73
+ <span
74
+ className="n-flex n-items-center n-relative -n-left-2.5"
75
+ style={{ marginRight: -CHECKBOX_RIGHT_INSET }}
76
+ >
77
+ <CheckIcon />
78
+ </span>
79
+ ) : indented ? (
80
+ <Spacer.Horizontal size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET} />
81
+ ) : null}
82
+ {icon &&
83
+ (typeof icon === "string" ? (
84
+ <Icon
85
+ name={icon as IconName}
86
+ className="n-mr-2 n-w-4 n-h-4 n-flex-shrink-0"
87
+ />
88
+ ) : (
89
+ <span className="n-mr-2 n-w-4 n-h-4 n-flex-shrink-0 n-flex n-items-center n-justify-center">
90
+ {icon}
91
+ </span>
92
+ ))}
93
+ <div className="n-flex n-items-center n-flex-1">{children}</div>
94
+ {shortcut && (
95
+ <>
96
+ <Spacer.Horizontal size={16} />
97
+ <KeyboardShortcut shortcut={shortcut} />
98
+ </>
99
+ )}
100
+ {isSubmenuTrigger && <ChevronRightIcon className="-n-mr-1" />}
101
+ </>
102
+ );
103
+ }
104
+
105
+ function SubmenuItem<T extends string>({
106
+ item,
107
+ onSelect,
108
+ hasCheckedItem,
109
+ portalScopeId,
110
+ }: {
111
+ item: Extract<MenuItem<T>, { type: "submenu" }>;
112
+ onSelect: ((value: T) => void) | undefined;
113
+ hasCheckedItem: boolean;
114
+ portalScopeId: string | undefined;
115
+ }) {
116
+ const [open, setOpen] = useState(false);
117
+
118
+ const submenuHasCheckedItem = item.items.some(
119
+ (subItem) => isSelectableMenuItem(subItem) && subItem.checked
120
+ );
121
+
122
+ return (
123
+ <BaseContextMenu.SubmenuRoot
124
+ key={item.id}
125
+ open={open}
126
+ onOpenChange={(newOpen, event) => {
127
+ if (event.reason === "focus-out") {
128
+ return;
129
+ }
130
+ setOpen(newOpen);
131
+ }}
132
+ >
133
+ <div className="n-px-1">
134
+ <BaseContextMenu.SubmenuTrigger
135
+ disabled={item.disabled}
136
+ className={itemStyle}
137
+ >
138
+ <MenuItemContent
139
+ icon={item.icon}
140
+ checked={item.checked}
141
+ indented={hasCheckedItem}
142
+ isSubmenuTrigger
143
+ >
144
+ {item.title}
145
+ </MenuItemContent>
146
+ </BaseContextMenu.SubmenuTrigger>
147
+ </div>
148
+ <BaseContextMenu.Portal>
149
+ <BaseContextMenu.Positioner
150
+ {...portalScopeProps(portalScopeId)}
151
+ alignOffset={-5}
152
+ className="n-z-menu n-outline-none"
153
+ >
154
+ <BaseContextMenu.Popup className={styles.contentStyle}>
155
+ <ContextMenuViewport
156
+ items={item.items}
157
+ onSelect={onSelect}
158
+ hasCheckedItem={submenuHasCheckedItem}
159
+ />
160
+ </BaseContextMenu.Popup>
161
+ </BaseContextMenu.Positioner>
162
+ </BaseContextMenu.Portal>
163
+ </BaseContextMenu.SubmenuRoot>
164
+ );
165
+ }
166
+
167
+ function ContextMenuViewport<T extends string>({
168
+ items,
169
+ onSelect,
170
+ hasCheckedItem,
171
+ }: {
172
+ items: MenuItem<T>[];
173
+ onSelect?: (value: T) => void;
174
+ hasCheckedItem: boolean;
175
+ }) {
176
+ const portalScopeId = usePortalScopeId();
177
+
178
+ return (
179
+ <>
180
+ {items.map((item, index) => {
181
+ switch (item.type) {
182
+ case "separator":
183
+ return (
184
+ <BaseContextMenu.Separator
185
+ key={index}
186
+ className={styles.separatorStyle}
187
+ />
188
+ );
189
+
190
+ case "sectionHeader":
191
+ return (
192
+ <SectionHeaderMenuItemComponent
193
+ isFirst={index === 0}
194
+ key={item.id}
195
+ {...item}
196
+ indented={hasCheckedItem}
197
+ />
198
+ );
199
+
200
+ case "popover":
201
+ return null;
202
+
203
+ case "submenu":
204
+ if (item.items) {
205
+ return (
206
+ <SubmenuItem
207
+ key={item.id}
208
+ item={item as Extract<MenuItem<string>, { type: "submenu" }>}
209
+ onSelect={onSelect as ((value: string) => void) | undefined}
210
+ hasCheckedItem={hasCheckedItem}
211
+ portalScopeId={portalScopeId}
212
+ />
213
+ );
214
+ }
215
+ return null;
216
+
217
+ default:
218
+ return (
219
+ <div key={item.value} className="n-px-1">
220
+ <BaseContextMenu.Item
221
+ disabled={item.disabled}
222
+ className={itemStyle}
223
+ onClick={() => {
224
+ if (onSelect && item.value) {
225
+ setTimeout(() => onSelect(item.value), 0);
226
+ }
227
+ }}
228
+ >
229
+ <MenuItemContent
230
+ icon={item.icon}
231
+ checked={item.checked}
232
+ indented={hasCheckedItem}
233
+ shortcut={item.shortcut}
234
+ >
235
+ {item.title ?? item.value}
236
+ </MenuItemContent>
237
+ </BaseContextMenu.Item>
238
+ </div>
239
+ );
240
+ }
241
+ })}
242
+ </>
243
+ );
244
+ }
40
245
 
41
246
  /* ----------------------------------------------------------------------------
42
247
  * Root
@@ -69,41 +274,47 @@ function ContextMenuRoot<T extends string>({
69
274
 
70
275
  useKeyboardShortcuts(keymap);
71
276
 
72
- // We call preventDefault both to:
73
- // - Disable radix-ui's long-press-to-open behavior
74
- // https://github.com/radix-ui/primitives/issues/748#issuecomment-869502837
75
- // - Mark the event as handled, so our ListView root doesn't handle it (a hack)
76
- const onPointerDown = useCallback((event: React.PointerEvent) => {
77
- event.preventDefault();
78
- }, []);
79
-
80
277
  const handleSelectItem = useCallback(
81
278
  (value: T) => {
82
279
  if (!value) return;
83
-
84
280
  onSelect(value);
85
281
  },
86
282
  [onSelect]
87
283
  );
88
284
 
285
+ const handleOpenChange = useCallback(
286
+ (open: boolean) => {
287
+ onOpenChange?.(open);
288
+ },
289
+ [onOpenChange]
290
+ );
291
+
292
+ const hasCheckedItem = items.some(
293
+ (item) => isSelectableMenuItem(item) && item.checked
294
+ );
295
+
89
296
  return (
90
- <RadixContextMenu.Root onOpenChange={onOpenChange}>
91
- <RadixContextMenu.Trigger asChild onPointerDown={onPointerDown}>
92
- {children}
93
- </RadixContextMenu.Trigger>
94
- <RadixContextMenu.Portal>
95
- <RadixContextMenu.Content
297
+ <BaseContextMenu.Root onOpenChange={handleOpenChange}>
298
+ <BaseContextMenu.Trigger
299
+ render={(triggerProps) =>
300
+ cloneElementWithMergedProps(children, triggerProps)
301
+ }
302
+ />
303
+ <BaseContextMenu.Portal>
304
+ <BaseContextMenu.Positioner
96
305
  {...portalScopeProps(portalScopeId)}
97
- className={styles.contentStyle}
306
+ className="n-z-menu n-outline-none"
98
307
  >
99
- <MenuViewport
100
- items={items}
101
- Components={Components}
102
- onSelect={handleSelectItem}
103
- />
104
- </RadixContextMenu.Content>
105
- </RadixContextMenu.Portal>
106
- </RadixContextMenu.Root>
308
+ <BaseContextMenu.Popup className={styles.contentStyle}>
309
+ <ContextMenuViewport
310
+ items={items}
311
+ onSelect={handleSelectItem}
312
+ hasCheckedItem={hasCheckedItem}
313
+ />
314
+ </BaseContextMenu.Popup>
315
+ </BaseContextMenu.Positioner>
316
+ </BaseContextMenu.Portal>
317
+ </BaseContextMenu.Root>
107
318
  );
108
319
  }
109
320