@noya-app/noya-designsystem 0.1.44 → 0.1.46
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.
- package/.turbo/turbo-build.log +13 -10
- package/CHANGELOG.md +18 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +1194 -878
- package/dist/index.d.ts +1194 -878
- package/dist/index.js +11432 -10219
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11556 -10360
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/combobox.test.ts +137 -89
- package/src/components/AnimatePresence.tsx +10 -1
- package/src/components/Avatar.tsx +2 -0
- package/src/components/Breadcrumbs.tsx +29 -0
- package/src/components/Checkbox.tsx +6 -1
- package/src/components/Collection.tsx +74 -0
- package/src/components/Combobox.tsx +75 -56
- package/src/components/ComboboxMenu.tsx +61 -28
- package/src/components/CommandPalette.tsx +69 -0
- package/src/components/ContextMenu.tsx +33 -134
- package/src/components/DropdownMenu.tsx +46 -155
- package/src/components/EditableText.tsx +203 -0
- package/src/components/Fade.tsx +62 -0
- package/src/components/Grid.tsx +243 -0
- package/src/components/GridView.tsx +86 -96
- package/src/components/InputField.tsx +109 -133
- package/src/components/Label.tsx +81 -7
- package/src/components/LabeledField.tsx +112 -0
- package/src/components/List.tsx +268 -0
- package/src/components/ListView.tsx +65 -61
- package/src/components/Popover.tsx +12 -1
- package/src/components/SearchCompletionMenu.tsx +210 -0
- package/src/components/SegmentedControl.tsx +12 -6
- package/src/components/SelectMenu.tsx +110 -126
- package/src/components/Slider.tsx +18 -26
- package/src/components/Text.tsx +1 -0
- package/src/components/TextArea.tsx +4 -1
- package/src/components/Toolbar.tsx +9 -4
- package/src/components/TreeView.tsx +4 -0
- package/src/components/WorkspaceLayout.tsx +64 -20
- package/src/components/internal/Menu.tsx +107 -18
- package/src/components/internal/MenuViewport.tsx +152 -0
- package/src/components/internal/SelectItem.tsx +111 -0
- package/src/hooks/useIndent.ts +12 -0
- package/src/hooks/useLabel.ts +51 -0
- package/src/hooks/usePreservePanelSize.tsx +50 -19
- package/src/index.tsx +15 -6
- package/src/utils/combobox.ts +116 -101
- package/src/utils/createSectionedMenu.ts +11 -14
- package/src/utils/fuzzyScorer.ts +11 -8
- package/src/utils/selection.ts +48 -0
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
import { CheckIcon, ChevronRightIcon } from "@noya-app/noya-icons";
|
|
2
1
|
import { useKeyboardShortcuts } from "@noya-app/noya-keymap";
|
|
3
2
|
import { memoGeneric } from "@noya-app/react-utils";
|
|
4
3
|
import * as RadixContextMenu from "@radix-ui/react-context-menu";
|
|
5
4
|
import React, { ReactNode, useCallback, useMemo } from "react";
|
|
6
|
-
import { renderIcon } from "./Icons";
|
|
7
|
-
import { Spacer } from "./Spacer";
|
|
8
5
|
import {
|
|
9
|
-
CHECKBOX_RIGHT_INSET,
|
|
10
|
-
CHECKBOX_WIDTH,
|
|
11
|
-
KeyboardShortcut,
|
|
12
6
|
MenuItem,
|
|
13
|
-
SEPARATOR_ITEM,
|
|
14
7
|
getKeyboardShortcutsForMenuItems,
|
|
15
8
|
styles,
|
|
16
9
|
} from "./internal/Menu";
|
|
10
|
+
import { MenuViewport } from "./internal/MenuViewport";
|
|
17
11
|
|
|
18
12
|
export interface MenuItemProps<T extends string> {
|
|
19
13
|
value?: T;
|
|
@@ -27,90 +21,17 @@ export interface MenuItemProps<T extends string> {
|
|
|
27
21
|
items?: MenuItem<T>[];
|
|
28
22
|
}
|
|
29
23
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
// The pointer event within the context menu will bubble outside of the
|
|
42
|
-
// context menu unless we stop it here.
|
|
43
|
-
const handlePointerDown = useCallback(
|
|
44
|
-
(event: React.PointerEvent) => event.stopPropagation(),
|
|
45
|
-
[]
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
const handleSelectItem = useCallback(() => {
|
|
49
|
-
if (!value) return;
|
|
50
|
-
|
|
51
|
-
onSelect(value);
|
|
52
|
-
}, [onSelect, value]);
|
|
53
|
-
|
|
54
|
-
if (checked) {
|
|
55
|
-
return (
|
|
56
|
-
<RadixContextMenu.CheckboxItem
|
|
57
|
-
checked={checked}
|
|
58
|
-
disabled={disabled}
|
|
59
|
-
onSelect={handleSelectItem}
|
|
60
|
-
className={styles.itemStyle({ disabled })}
|
|
61
|
-
>
|
|
62
|
-
<RadixContextMenu.ItemIndicator className={styles.itemIndicatorStyle}>
|
|
63
|
-
<CheckIcon />
|
|
64
|
-
</RadixContextMenu.ItemIndicator>
|
|
65
|
-
{children}
|
|
66
|
-
</RadixContextMenu.CheckboxItem>
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const element = (
|
|
71
|
-
<RadixContextMenu.Item
|
|
72
|
-
className={styles.itemStyle({ disabled })}
|
|
73
|
-
disabled={disabled}
|
|
74
|
-
onSelect={handleSelectItem}
|
|
75
|
-
onPointerDown={handlePointerDown}
|
|
76
|
-
>
|
|
77
|
-
{indented && (
|
|
78
|
-
<Spacer.Horizontal size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET} />
|
|
79
|
-
)}
|
|
80
|
-
{icon && (
|
|
81
|
-
<>
|
|
82
|
-
{renderIcon(icon)}
|
|
83
|
-
<Spacer.Horizontal size={8} />
|
|
84
|
-
</>
|
|
85
|
-
)}
|
|
86
|
-
{children}
|
|
87
|
-
{shortcut && (
|
|
88
|
-
<>
|
|
89
|
-
<Spacer.Horizontal />
|
|
90
|
-
<Spacer.Horizontal size={24} />
|
|
91
|
-
<KeyboardShortcut shortcut={shortcut} />
|
|
92
|
-
</>
|
|
93
|
-
)}
|
|
94
|
-
{items && items.length > 0 && (
|
|
95
|
-
<>
|
|
96
|
-
<Spacer.Horizontal />
|
|
97
|
-
<Spacer.Horizontal size={16} />
|
|
98
|
-
<ChevronRightIcon />
|
|
99
|
-
</>
|
|
100
|
-
)}
|
|
101
|
-
</RadixContextMenu.Item>
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
if (items && items.length > 0) {
|
|
105
|
-
return (
|
|
106
|
-
<ContextMenuRoot isNested items={items} onSelect={onSelect}>
|
|
107
|
-
{element}
|
|
108
|
-
</ContextMenuRoot>
|
|
109
|
-
);
|
|
110
|
-
} else {
|
|
111
|
-
return element;
|
|
112
|
-
}
|
|
113
|
-
});
|
|
24
|
+
const Components = {
|
|
25
|
+
Separator: RadixContextMenu.Separator,
|
|
26
|
+
ItemText: React.Fragment,
|
|
27
|
+
Item: RadixContextMenu.Item,
|
|
28
|
+
CheckboxItem: RadixContextMenu.CheckboxItem,
|
|
29
|
+
ItemIndicator: RadixContextMenu.ItemIndicator,
|
|
30
|
+
SubTrigger: RadixContextMenu.SubTrigger,
|
|
31
|
+
SubContent: RadixContextMenu.SubContent,
|
|
32
|
+
Sub: RadixContextMenu.Sub,
|
|
33
|
+
Portal: RadixContextMenu.Portal,
|
|
34
|
+
};
|
|
114
35
|
|
|
115
36
|
/* ----------------------------------------------------------------------------
|
|
116
37
|
* Root
|
|
@@ -120,7 +41,6 @@ export interface MenuProps<T extends string> {
|
|
|
120
41
|
children: ReactNode;
|
|
121
42
|
items: MenuItem<T>[];
|
|
122
43
|
onSelect: (value: T) => void;
|
|
123
|
-
isNested?: boolean;
|
|
124
44
|
shouldBindKeyboardShortcuts?: boolean;
|
|
125
45
|
onOpenChange?: (open: boolean) => void;
|
|
126
46
|
}
|
|
@@ -129,20 +49,15 @@ function ContextMenuRoot<T extends string>({
|
|
|
129
49
|
items,
|
|
130
50
|
children,
|
|
131
51
|
onSelect,
|
|
132
|
-
isNested,
|
|
133
52
|
shouldBindKeyboardShortcuts,
|
|
134
53
|
onOpenChange,
|
|
135
54
|
}: MenuProps<T>) {
|
|
136
|
-
const hasCheckedItem = items.some(
|
|
137
|
-
(item) => item !== SEPARATOR_ITEM && item.checked
|
|
138
|
-
);
|
|
139
|
-
|
|
140
55
|
const keymap = useMemo(
|
|
141
56
|
() =>
|
|
142
|
-
|
|
57
|
+
shouldBindKeyboardShortcuts === false
|
|
143
58
|
? {}
|
|
144
59
|
: getKeyboardShortcutsForMenuItems(items, onSelect),
|
|
145
|
-
[
|
|
60
|
+
[items, onSelect, shouldBindKeyboardShortcuts]
|
|
146
61
|
);
|
|
147
62
|
|
|
148
63
|
useKeyboardShortcuts(keymap);
|
|
@@ -155,46 +70,30 @@ function ContextMenuRoot<T extends string>({
|
|
|
155
70
|
event.preventDefault();
|
|
156
71
|
}, []);
|
|
157
72
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
73
|
+
const handleSelectItem = useCallback(
|
|
74
|
+
(value: T) => {
|
|
75
|
+
if (!value) return;
|
|
76
|
+
|
|
77
|
+
onSelect(value);
|
|
78
|
+
},
|
|
79
|
+
[onSelect]
|
|
80
|
+
);
|
|
165
81
|
|
|
166
82
|
return (
|
|
167
|
-
<
|
|
168
|
-
<
|
|
83
|
+
<RadixContextMenu.Root onOpenChange={onOpenChange}>
|
|
84
|
+
<RadixContextMenu.Trigger asChild onPointerDown={onPointerDown}>
|
|
169
85
|
{children}
|
|
170
|
-
</
|
|
86
|
+
</RadixContextMenu.Trigger>
|
|
171
87
|
<RadixContextMenu.Portal>
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
) : (
|
|
180
|
-
<ContextMenuItem
|
|
181
|
-
key={item.value ?? index}
|
|
182
|
-
value={item.value}
|
|
183
|
-
indented={hasCheckedItem}
|
|
184
|
-
checked={item.checked ?? false}
|
|
185
|
-
disabled={item.disabled ?? false}
|
|
186
|
-
icon={item.icon}
|
|
187
|
-
onSelect={onSelect}
|
|
188
|
-
items={item.items}
|
|
189
|
-
shortcut={item.shortcut}
|
|
190
|
-
>
|
|
191
|
-
{item.title}
|
|
192
|
-
</ContextMenuItem>
|
|
193
|
-
)
|
|
194
|
-
)}
|
|
195
|
-
</ContentComponent>
|
|
88
|
+
<RadixContextMenu.Content className={styles.contentStyle}>
|
|
89
|
+
<MenuViewport
|
|
90
|
+
items={items}
|
|
91
|
+
Components={Components}
|
|
92
|
+
onSelect={handleSelectItem}
|
|
93
|
+
/>
|
|
94
|
+
</RadixContextMenu.Content>
|
|
196
95
|
</RadixContextMenu.Portal>
|
|
197
|
-
</
|
|
96
|
+
</RadixContextMenu.Root>
|
|
198
97
|
);
|
|
199
98
|
}
|
|
200
99
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { CheckIcon, ChevronRightIcon } from "@noya-app/noya-icons";
|
|
2
1
|
import { useKeyboardShortcuts } from "@noya-app/noya-keymap";
|
|
3
2
|
import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
|
|
4
3
|
import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
|
|
@@ -6,132 +5,45 @@ import React, {
|
|
|
6
5
|
ComponentProps,
|
|
7
6
|
ForwardedRef,
|
|
8
7
|
SyntheticEvent,
|
|
9
|
-
useCallback,
|
|
10
8
|
useMemo,
|
|
11
9
|
} from "react";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
CHECKBOX_RIGHT_INSET,
|
|
17
|
-
CHECKBOX_WIDTH,
|
|
18
|
-
KeyboardShortcut,
|
|
19
|
-
SEPARATOR_ITEM,
|
|
20
|
-
getKeyboardShortcutsForMenuItems,
|
|
21
|
-
styles,
|
|
22
|
-
} from "./internal/Menu";
|
|
23
|
-
|
|
24
|
-
const DropdownMenuItem = memoGeneric(function ContextMenuItem<
|
|
25
|
-
T extends string,
|
|
26
|
-
>({
|
|
27
|
-
value,
|
|
28
|
-
children,
|
|
29
|
-
onSelect,
|
|
30
|
-
checked,
|
|
31
|
-
disabled,
|
|
32
|
-
indented,
|
|
33
|
-
icon,
|
|
34
|
-
items,
|
|
35
|
-
shortcut,
|
|
36
|
-
}: MenuItemProps<T>) {
|
|
37
|
-
const handleSelectItem = useCallback(() => {
|
|
38
|
-
if (!value) return;
|
|
39
|
-
|
|
40
|
-
onSelect(value);
|
|
41
|
-
}, [onSelect, value]);
|
|
42
|
-
|
|
43
|
-
if (checked) {
|
|
44
|
-
return (
|
|
45
|
-
<RadixDropdownMenu.CheckboxItem
|
|
46
|
-
checked={checked}
|
|
47
|
-
disabled={disabled}
|
|
48
|
-
onSelect={handleSelectItem}
|
|
49
|
-
className={styles.itemStyle({ disabled })}
|
|
50
|
-
>
|
|
51
|
-
<RadixDropdownMenu.ItemIndicator className={styles.itemIndicatorStyle}>
|
|
52
|
-
<CheckIcon />
|
|
53
|
-
</RadixDropdownMenu.ItemIndicator>
|
|
54
|
-
{icon && (
|
|
55
|
-
<>
|
|
56
|
-
{renderIcon(icon)}
|
|
57
|
-
<Spacer.Horizontal size={8} />
|
|
58
|
-
</>
|
|
59
|
-
)}
|
|
60
|
-
{children}
|
|
61
|
-
</RadixDropdownMenu.CheckboxItem>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const element = (
|
|
66
|
-
<RadixDropdownMenu.Item
|
|
67
|
-
disabled={disabled}
|
|
68
|
-
onSelect={handleSelectItem}
|
|
69
|
-
className={styles.itemStyle({ disabled })}
|
|
70
|
-
>
|
|
71
|
-
{indented && (
|
|
72
|
-
<Spacer.Horizontal size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET} />
|
|
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
|
-
{items && items.length > 0 && (
|
|
89
|
-
<>
|
|
90
|
-
<Spacer.Horizontal />
|
|
91
|
-
<Spacer.Horizontal size={16} />
|
|
92
|
-
<ChevronRightIcon />
|
|
93
|
-
</>
|
|
94
|
-
)}
|
|
95
|
-
</RadixDropdownMenu.Item>
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
if (items && items.length > 0) {
|
|
99
|
-
return (
|
|
100
|
-
<DropdownMenuRoot
|
|
101
|
-
isNested
|
|
102
|
-
items={items}
|
|
103
|
-
onSelect={onSelect}
|
|
104
|
-
alignOffset={-5}
|
|
105
|
-
>
|
|
106
|
-
{element}
|
|
107
|
-
</DropdownMenuRoot>
|
|
108
|
-
);
|
|
109
|
-
} else {
|
|
110
|
-
return element;
|
|
111
|
-
}
|
|
112
|
-
});
|
|
10
|
+
import { MenuProps } from "./ContextMenu";
|
|
11
|
+
import { getKeyboardShortcutsForMenuItems, styles } from "./internal/Menu";
|
|
12
|
+
import { MenuViewport } from "./internal/MenuViewport";
|
|
113
13
|
|
|
114
14
|
/* ----------------------------------------------------------------------------
|
|
115
15
|
* Root
|
|
116
16
|
* ------------------------------------------------------------------------- */
|
|
117
17
|
|
|
118
|
-
type DropdownRootProps<T extends string> = MenuProps<T> & {
|
|
18
|
+
export type DropdownRootProps<T extends string> = MenuProps<T> & {
|
|
119
19
|
open?: boolean;
|
|
120
20
|
onCloseAutoFocus?: React.EventHandler<SyntheticEvent<unknown>>;
|
|
121
21
|
emptyState?: React.ReactNode;
|
|
22
|
+
contentStyle?: React.CSSProperties;
|
|
122
23
|
} & Pick<
|
|
123
24
|
ComponentProps<typeof RadixDropdownMenu.Content>,
|
|
124
25
|
"side" | "sideOffset" | "align" | "alignOffset" | "collisionPadding"
|
|
125
26
|
>;
|
|
126
27
|
|
|
28
|
+
const Components = {
|
|
29
|
+
Separator: RadixDropdownMenu.Separator,
|
|
30
|
+
ItemText: React.Fragment,
|
|
31
|
+
Item: RadixDropdownMenu.Item,
|
|
32
|
+
CheckboxItem: RadixDropdownMenu.CheckboxItem,
|
|
33
|
+
ItemIndicator: RadixDropdownMenu.ItemIndicator,
|
|
34
|
+
SubTrigger: RadixDropdownMenu.SubTrigger,
|
|
35
|
+
SubContent: RadixDropdownMenu.SubContent,
|
|
36
|
+
Sub: RadixDropdownMenu.Sub,
|
|
37
|
+
Portal: RadixDropdownMenu.Portal,
|
|
38
|
+
};
|
|
39
|
+
|
|
127
40
|
const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
|
|
128
41
|
T extends string,
|
|
129
|
-
>(
|
|
130
|
-
{
|
|
42
|
+
>(props: DropdownRootProps<T>, forwardedRef: ForwardedRef<HTMLElement>) {
|
|
43
|
+
const {
|
|
131
44
|
items,
|
|
132
45
|
children,
|
|
133
46
|
onSelect,
|
|
134
|
-
isNested,
|
|
135
47
|
shouldBindKeyboardShortcuts,
|
|
136
48
|
onOpenChange,
|
|
137
49
|
open,
|
|
@@ -142,77 +54,56 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
|
|
|
142
54
|
alignOffset,
|
|
143
55
|
collisionPadding = 8,
|
|
144
56
|
emptyState,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
) {
|
|
148
|
-
const hasCheckedItem = items.some(
|
|
149
|
-
(item) => item !== SEPARATOR_ITEM && item.checked
|
|
150
|
-
);
|
|
57
|
+
contentStyle,
|
|
58
|
+
} = props;
|
|
151
59
|
|
|
152
60
|
const keymap = useMemo(
|
|
153
61
|
() =>
|
|
154
|
-
|
|
62
|
+
shouldBindKeyboardShortcuts === false
|
|
155
63
|
? {}
|
|
156
64
|
: getKeyboardShortcutsForMenuItems(items, onSelect),
|
|
157
|
-
[
|
|
65
|
+
[items, onSelect, shouldBindKeyboardShortcuts]
|
|
158
66
|
);
|
|
159
67
|
|
|
160
68
|
useKeyboardShortcuts(keymap);
|
|
161
69
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
: RadixDropdownMenu.Content;
|
|
171
|
-
|
|
172
|
-
const contentStyle = useMemo(() => ({ zIndex: 1000 }), []);
|
|
70
|
+
const contentStyles = useMemo(
|
|
71
|
+
() => ({
|
|
72
|
+
maxHeight: "500px",
|
|
73
|
+
overflow: "auto",
|
|
74
|
+
...contentStyle,
|
|
75
|
+
}),
|
|
76
|
+
[contentStyle]
|
|
77
|
+
);
|
|
173
78
|
|
|
174
79
|
return (
|
|
175
|
-
<
|
|
176
|
-
<
|
|
80
|
+
<RadixDropdownMenu.Root onOpenChange={onOpenChange} open={open}>
|
|
81
|
+
<RadixDropdownMenu.Trigger
|
|
82
|
+
ref={forwardedRef as ForwardedRef<any>}
|
|
83
|
+
asChild
|
|
84
|
+
>
|
|
177
85
|
{children}
|
|
178
|
-
</
|
|
86
|
+
</RadixDropdownMenu.Trigger>
|
|
179
87
|
<RadixDropdownMenu.Portal>
|
|
180
|
-
<
|
|
88
|
+
<RadixDropdownMenu.Content
|
|
181
89
|
className={styles.contentStyle}
|
|
182
90
|
side={side}
|
|
183
91
|
sideOffset={sideOffset}
|
|
184
92
|
align={align}
|
|
185
93
|
alignOffset={alignOffset}
|
|
186
|
-
style={
|
|
94
|
+
style={contentStyles}
|
|
187
95
|
collisionPadding={collisionPadding}
|
|
188
96
|
{...({ onCloseAutoFocus } as any)}
|
|
189
97
|
>
|
|
190
98
|
{items.length === 0 ? emptyState : null}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
) : (
|
|
198
|
-
<DropdownMenuItem
|
|
199
|
-
key={item.value ?? index}
|
|
200
|
-
value={item.value}
|
|
201
|
-
indented={hasCheckedItem}
|
|
202
|
-
checked={item.checked ?? false}
|
|
203
|
-
disabled={item.disabled ?? false}
|
|
204
|
-
icon={item.icon}
|
|
205
|
-
onSelect={onSelect}
|
|
206
|
-
items={item.items}
|
|
207
|
-
shortcut={item.shortcut}
|
|
208
|
-
>
|
|
209
|
-
{item.title}
|
|
210
|
-
</DropdownMenuItem>
|
|
211
|
-
)
|
|
212
|
-
)}
|
|
213
|
-
</ContentComponent>
|
|
99
|
+
<MenuViewport
|
|
100
|
+
items={items}
|
|
101
|
+
Components={Components}
|
|
102
|
+
onSelect={onSelect}
|
|
103
|
+
/>
|
|
104
|
+
</RadixDropdownMenu.Content>
|
|
214
105
|
</RadixDropdownMenu.Portal>
|
|
215
|
-
</
|
|
106
|
+
</RadixDropdownMenu.Root>
|
|
216
107
|
);
|
|
217
108
|
});
|
|
218
109
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { cx, InputField } from "@noya-app/noya-designsystem";
|
|
2
|
+
import React, {
|
|
3
|
+
forwardRef,
|
|
4
|
+
memo,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { BreadcrumbText } from "./Breadcrumbs";
|
|
12
|
+
|
|
13
|
+
type RenderPreviewProps = {
|
|
14
|
+
children: string;
|
|
15
|
+
onClick: () => void;
|
|
16
|
+
ref: React.Ref<HTMLElement & HTMLAnchorElement & HTMLSpanElement>;
|
|
17
|
+
tabIndex: number;
|
|
18
|
+
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
19
|
+
style?: React.CSSProperties;
|
|
20
|
+
className?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Props = {
|
|
24
|
+
value: string;
|
|
25
|
+
children?: (props: RenderPreviewProps) => React.ReactNode;
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
onChange?: (value: string) => void;
|
|
28
|
+
onSubmit?: (value: string) => void;
|
|
29
|
+
style?: React.CSSProperties;
|
|
30
|
+
textStyle?: React.CSSProperties;
|
|
31
|
+
className?: string;
|
|
32
|
+
id?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
focused?: boolean;
|
|
35
|
+
textClassName?: string;
|
|
36
|
+
readOnly?: boolean;
|
|
37
|
+
onBlur?: () => void;
|
|
38
|
+
/**
|
|
39
|
+
* If true, will render an unfocused input. This should only be used for visual regression tests.
|
|
40
|
+
*/
|
|
41
|
+
testFocused?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface EditableTextRef {
|
|
45
|
+
startEditing: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const defaultRenderPreview = (props: RenderPreviewProps) => (
|
|
49
|
+
<BreadcrumbText {...props} />
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export const EditableText = memo(
|
|
53
|
+
forwardRef(function EditableText(
|
|
54
|
+
{
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
onSubmit,
|
|
58
|
+
focused: focusedProp,
|
|
59
|
+
disabled,
|
|
60
|
+
style,
|
|
61
|
+
textStyle,
|
|
62
|
+
className,
|
|
63
|
+
textClassName,
|
|
64
|
+
placeholder,
|
|
65
|
+
children = defaultRenderPreview,
|
|
66
|
+
readOnly,
|
|
67
|
+
onBlur,
|
|
68
|
+
testFocused = false,
|
|
69
|
+
...props
|
|
70
|
+
}: Props,
|
|
71
|
+
ref: React.ForwardedRef<EditableTextRef>
|
|
72
|
+
) {
|
|
73
|
+
const [inputValue, setInputValue] = useState(value);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
setInputValue(value);
|
|
77
|
+
}, [value]);
|
|
78
|
+
|
|
79
|
+
const [focusedInternal, setFocused] = useState(false);
|
|
80
|
+
const focused = focusedProp ?? focusedInternal;
|
|
81
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
82
|
+
const previewRef = useRef<HTMLElement & HTMLAnchorElement>(null);
|
|
83
|
+
|
|
84
|
+
const ignoreNextBlur = useRef(false);
|
|
85
|
+
|
|
86
|
+
const handleKeyDown = useCallback(
|
|
87
|
+
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
88
|
+
if (event.key === "Escape") {
|
|
89
|
+
setInputValue(value);
|
|
90
|
+
ignoreNextBlur.current = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Enter and Esc both blur the input
|
|
94
|
+
if (event.key === "Enter" || event.key === "Escape") {
|
|
95
|
+
inputRef.current?.blur();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (event.key === "Escape") {
|
|
99
|
+
previewRef.current?.focus();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// If tab or shift+tab is pressed, let bubble
|
|
103
|
+
if (event.key === "Tab") {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
event.stopPropagation();
|
|
108
|
+
},
|
|
109
|
+
[value]
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
useImperativeHandle(ref, () => ({
|
|
113
|
+
startEditing: () => {
|
|
114
|
+
setFocused(true);
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
function focusInput() {
|
|
119
|
+
inputRef.current?.focus();
|
|
120
|
+
inputRef.current?.setSelectionRange(0, inputRef.current.value.length);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (testFocused) return;
|
|
125
|
+
if (focused) {
|
|
126
|
+
// Use a timeout to focus the input after the next render.
|
|
127
|
+
// The input isn't available even though `focused` was already set to true
|
|
128
|
+
// must be a delay of 1ms or more to ensure any radix ui components have finished any focus behaviors
|
|
129
|
+
setTimeout(focusInput, 1);
|
|
130
|
+
}
|
|
131
|
+
}, [focused, testFocused]);
|
|
132
|
+
|
|
133
|
+
const displayValue = inputValue || placeholder || "";
|
|
134
|
+
|
|
135
|
+
const preview = children({
|
|
136
|
+
children: displayValue,
|
|
137
|
+
onClick: () => setFocused(true),
|
|
138
|
+
ref: previewRef,
|
|
139
|
+
tabIndex: 0,
|
|
140
|
+
onKeyDown: (e: React.KeyboardEvent) => {
|
|
141
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
142
|
+
setFocused(true);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
style: textStyle,
|
|
146
|
+
className: textClassName,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const input = (
|
|
150
|
+
<div className="flex absolute inset-0 -mx-1.5 -my-1">
|
|
151
|
+
<InputField.Root onFocusChange={setFocused}>
|
|
152
|
+
<InputField.Input
|
|
153
|
+
ref={inputRef}
|
|
154
|
+
style={{
|
|
155
|
+
lineHeight: "15px", // Match breadcrumb height
|
|
156
|
+
...textStyle,
|
|
157
|
+
}}
|
|
158
|
+
className={textClassName}
|
|
159
|
+
{...props}
|
|
160
|
+
value={inputValue}
|
|
161
|
+
placeholder={placeholder}
|
|
162
|
+
onKeyDown={handleKeyDown}
|
|
163
|
+
onBlur={() => {
|
|
164
|
+
setFocused(false);
|
|
165
|
+
|
|
166
|
+
if (ignoreNextBlur.current) {
|
|
167
|
+
ignoreNextBlur.current = false;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
onSubmit?.(inputValue);
|
|
172
|
+
onBlur?.();
|
|
173
|
+
}}
|
|
174
|
+
{...(onChange
|
|
175
|
+
? {
|
|
176
|
+
onChange: (value) => {
|
|
177
|
+
setInputValue(value);
|
|
178
|
+
onChange?.(value);
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
: {
|
|
182
|
+
onSubmit: (value) => {
|
|
183
|
+
setInputValue(value);
|
|
184
|
+
onSubmit?.(value);
|
|
185
|
+
setFocused(false);
|
|
186
|
+
},
|
|
187
|
+
onChange: (value) => {
|
|
188
|
+
setInputValue(value);
|
|
189
|
+
},
|
|
190
|
+
})}
|
|
191
|
+
/>
|
|
192
|
+
</InputField.Root>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div className={cx("flex relative", className)} style={style}>
|
|
198
|
+
{preview}
|
|
199
|
+
{focused && !readOnly && input}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
})
|
|
203
|
+
);
|