@noya-app/noya-designsystem 0.1.45 → 0.1.47
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 +10 -10
- package/CHANGELOG.md +15 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +1149 -1064
- package/dist/index.d.ts +1149 -1064
- package/dist/index.js +10161 -9662
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10465 -9972
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/combobox.test.ts +137 -89
- package/src/components/Breadcrumbs.tsx +29 -0
- package/src/components/Collection.tsx +74 -0
- package/src/components/Combobox.tsx +69 -52
- package/src/components/ComboboxMenu.tsx +37 -19
- package/src/components/Dialog.tsx +11 -19
- package/src/components/DropdownMenu.tsx +0 -1
- package/src/components/EditableText.tsx +203 -0
- package/src/components/Grid.tsx +243 -0
- package/src/components/GridView.tsx +86 -96
- package/src/components/List.tsx +268 -0
- package/src/components/ListView.tsx +10 -9
- package/src/components/SearchCompletionMenu.tsx +15 -31
- package/src/components/SegmentedControl.tsx +7 -4
- package/src/components/SelectMenu.tsx +9 -5
- package/src/components/Switch.tsx +1 -1
- package/src/components/Toolbar.tsx +5 -4
- package/src/components/TreeView.tsx +1 -0
- package/src/components/WorkspaceLayout.tsx +28 -4
- package/src/components/internal/Menu.tsx +55 -14
- package/src/components/internal/MenuViewport.tsx +78 -77
- package/src/contexts/DialogContext.tsx +53 -7
- package/src/index.css +2 -1
- package/src/index.tsx +6 -4
- package/src/utils/combobox.ts +115 -103
- package/src/utils/createSectionedMenu.ts +3 -9
- package/src/utils/fuzzyScorer.ts +11 -8
- package/src/utils/selection.ts +16 -4
- package/src/components/SidebarList.tsx +0 -252
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { DropdownChevronIcon } from "@noya-app/noya-icons";
|
|
2
|
+
import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
|
|
2
3
|
import React, {
|
|
3
4
|
FocusEventHandler,
|
|
4
5
|
ForwardedRef,
|
|
5
|
-
forwardRef,
|
|
6
|
-
memo,
|
|
7
6
|
useCallback,
|
|
8
7
|
useEffect,
|
|
9
8
|
useImperativeHandle,
|
|
@@ -13,24 +12,26 @@ import React, {
|
|
|
13
12
|
} from "react";
|
|
14
13
|
import { useLabel } from "../hooks/useLabel";
|
|
15
14
|
import { cx } from "../utils/classNames";
|
|
16
|
-
import {
|
|
17
|
-
ComboboxItem,
|
|
18
|
-
ComboboxOption,
|
|
19
|
-
ComboboxState,
|
|
20
|
-
InternalComboboxItem,
|
|
21
|
-
useComboboxState,
|
|
22
|
-
} from "../utils/combobox";
|
|
15
|
+
import { ComboboxState, useComboboxState } from "../utils/combobox";
|
|
23
16
|
import { ActivityIndicator } from "./ActivityIndicator";
|
|
24
17
|
import { ComboboxMenu } from "./ComboboxMenu";
|
|
25
18
|
import { InputField, InputFieldSize } from "./InputField";
|
|
19
|
+
import {
|
|
20
|
+
MenuItem,
|
|
21
|
+
ScoredMenuItem,
|
|
22
|
+
SelectableMenuItem,
|
|
23
|
+
isMenuItemSectionHeader,
|
|
24
|
+
isNonSelectableMenuItem,
|
|
25
|
+
isSelectableMenuItem,
|
|
26
|
+
} from "./internal/Menu";
|
|
26
27
|
import { ListView } from "./ListView";
|
|
27
28
|
import { Small } from "./Text";
|
|
28
29
|
|
|
29
|
-
export type BaseComboboxProps = {
|
|
30
|
+
export type BaseComboboxProps<T extends string> = {
|
|
30
31
|
id?: string;
|
|
31
32
|
loading?: boolean;
|
|
32
33
|
placeholder?: string;
|
|
33
|
-
items:
|
|
34
|
+
items: MenuItem<T>[];
|
|
34
35
|
onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
|
|
35
36
|
onDeleteWhenEmpty?: () => void;
|
|
36
37
|
size?: InputFieldSize;
|
|
@@ -54,30 +55,34 @@ export type StringModeOnlyProps = {
|
|
|
54
55
|
allowArbitraryValues?: boolean;
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
export type StringModeProps = BaseComboboxProps &
|
|
58
|
+
export type StringModeProps<T extends string> = BaseComboboxProps<T> &
|
|
59
|
+
StringModeOnlyProps;
|
|
58
60
|
|
|
59
|
-
export type OptionModeOnlyProps = {
|
|
61
|
+
export type OptionModeOnlyProps<T extends string> = {
|
|
60
62
|
mode?: "option";
|
|
61
|
-
value?:
|
|
62
|
-
onChange?: (value:
|
|
63
|
-
onSelectItem?: (value:
|
|
64
|
-
onBlur?: (didSubmit: boolean, value:
|
|
65
|
-
onHoverItem?: (value:
|
|
63
|
+
value?: SelectableMenuItem<T>;
|
|
64
|
+
onChange?: (value: SelectableMenuItem<T>) => void;
|
|
65
|
+
onSelectItem?: (value: SelectableMenuItem<T>) => void;
|
|
66
|
+
onBlur?: (didSubmit: boolean, value: SelectableMenuItem<T>) => void;
|
|
67
|
+
onHoverItem?: (value: SelectableMenuItem<T> | undefined) => void;
|
|
66
68
|
allowArbitraryValues?: false;
|
|
67
69
|
};
|
|
68
70
|
|
|
69
|
-
export type OptionModeProps = BaseComboboxProps &
|
|
71
|
+
export type OptionModeProps<T extends string> = BaseComboboxProps<T> &
|
|
72
|
+
OptionModeOnlyProps<T>;
|
|
70
73
|
|
|
71
|
-
export type ComboboxProps
|
|
74
|
+
export type ComboboxProps<T extends string> =
|
|
75
|
+
| StringModeProps<T>
|
|
76
|
+
| OptionModeProps<T>;
|
|
72
77
|
|
|
73
|
-
export interface ComboboxRef {
|
|
78
|
+
export interface ComboboxRef<T extends string> {
|
|
74
79
|
focus(): void;
|
|
75
|
-
setValue(value: string |
|
|
80
|
+
setValue(value: string | SelectableMenuItem<T>): void;
|
|
76
81
|
selectAllInputText(): void;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
export const Combobox =
|
|
80
|
-
|
|
84
|
+
export const Combobox = memoGeneric(
|
|
85
|
+
forwardRefGeneric(function Combobox<T extends string>(
|
|
81
86
|
{
|
|
82
87
|
loading,
|
|
83
88
|
placeholder,
|
|
@@ -94,12 +99,12 @@ export const Combobox = memo(
|
|
|
94
99
|
onFocus,
|
|
95
100
|
onDeleteWhenEmpty,
|
|
96
101
|
...rest
|
|
97
|
-
}: ComboboxProps
|
|
98
|
-
forwardedRef: ForwardedRef<ComboboxRef
|
|
102
|
+
}: ComboboxProps<T>,
|
|
103
|
+
forwardedRef: ForwardedRef<ComboboxRef<T>>
|
|
99
104
|
) {
|
|
100
105
|
// We make a separate props object for the mode-specific props for memoizing
|
|
101
106
|
// since we put the props object into hook deps
|
|
102
|
-
const props = useMemo((): StringModeOnlyProps | OptionModeOnlyProps => {
|
|
107
|
+
const props = useMemo((): StringModeOnlyProps | OptionModeOnlyProps<T> => {
|
|
103
108
|
if (rest.mode === "string") {
|
|
104
109
|
return {
|
|
105
110
|
mode: rest.mode,
|
|
@@ -118,7 +123,7 @@ export const Combobox = memo(
|
|
|
118
123
|
onSelectItem: rest.onSelectItem,
|
|
119
124
|
onHoverItem: rest.onHoverItem,
|
|
120
125
|
onBlur: rest.onBlur,
|
|
121
|
-
} satisfies OptionModeOnlyProps
|
|
126
|
+
} satisfies OptionModeOnlyProps<T>;
|
|
122
127
|
}
|
|
123
128
|
}, [
|
|
124
129
|
rest.mode,
|
|
@@ -136,7 +141,7 @@ export const Combobox = memo(
|
|
|
136
141
|
if (props.mode === "string") {
|
|
137
142
|
return props.value ?? "";
|
|
138
143
|
} else {
|
|
139
|
-
return props.value?.
|
|
144
|
+
return props.value?.title?.toString() ?? "";
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
|
|
@@ -188,7 +193,7 @@ export const Combobox = memo(
|
|
|
188
193
|
);
|
|
189
194
|
} else {
|
|
190
195
|
const selectedItem = comboboxState.getSelectedItem();
|
|
191
|
-
if (selectedItem && selectedItem
|
|
196
|
+
if (selectedItem && isSelectableMenuItem(selectedItem)) {
|
|
192
197
|
props.onBlur?.(
|
|
193
198
|
state.filter === state.lastSubmittedValue.filter,
|
|
194
199
|
selectedItem
|
|
@@ -214,14 +219,14 @@ export const Combobox = memo(
|
|
|
214
219
|
);
|
|
215
220
|
|
|
216
221
|
const handleUpdateSelection = useCallback(
|
|
217
|
-
(item:
|
|
218
|
-
if (item.type
|
|
222
|
+
(item: ScoredMenuItem<T>) => {
|
|
223
|
+
if (item.type !== undefined) return;
|
|
219
224
|
|
|
220
225
|
const selectedItem = comboboxState.selectCurrentItem(item);
|
|
221
|
-
if (!selectedItem
|
|
226
|
+
if (!selectedItem) return;
|
|
222
227
|
|
|
223
228
|
if (props.mode === "string") {
|
|
224
|
-
props.onSelectItem?.(selectedItem.
|
|
229
|
+
props.onSelectItem?.(selectedItem.value);
|
|
225
230
|
props.onHoverItem?.(undefined);
|
|
226
231
|
} else {
|
|
227
232
|
props.onSelectItem?.(selectedItem);
|
|
@@ -236,9 +241,9 @@ export const Combobox = memo(
|
|
|
236
241
|
(index: number) => {
|
|
237
242
|
comboboxState.setSelectedIndex(index);
|
|
238
243
|
const item = comboboxState.getSelectedItem();
|
|
239
|
-
if (item && item
|
|
244
|
+
if (item && isSelectableMenuItem(item)) {
|
|
240
245
|
if (props.mode === "string") {
|
|
241
|
-
props.onHoverItem?.(item.
|
|
246
|
+
props.onHoverItem?.(item.value);
|
|
242
247
|
} else {
|
|
243
248
|
props.onHoverItem?.(item);
|
|
244
249
|
}
|
|
@@ -257,7 +262,7 @@ export const Combobox = memo(
|
|
|
257
262
|
props.onChange?.(value);
|
|
258
263
|
} else {
|
|
259
264
|
const selectedItem = comboboxState.getSelectedItem();
|
|
260
|
-
if (selectedItem && selectedItem
|
|
265
|
+
if (selectedItem && isSelectableMenuItem(selectedItem)) {
|
|
261
266
|
props.onChange?.(selectedItem);
|
|
262
267
|
}
|
|
263
268
|
}
|
|
@@ -311,14 +316,17 @@ export const Combobox = memo(
|
|
|
311
316
|
} else {
|
|
312
317
|
// In autocomplete mode:
|
|
313
318
|
// 1. Always set the filter to the selected item
|
|
314
|
-
// 2. If the filter matches the item
|
|
319
|
+
// 2. If the filter matches the item value, submit it
|
|
315
320
|
event.preventDefault();
|
|
316
321
|
event.stopPropagation();
|
|
317
|
-
if (item
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
+
if (isNonSelectableMenuItem(item)) return;
|
|
323
|
+
if (isSelectableMenuItem(item)) {
|
|
324
|
+
if (item.value === filter) {
|
|
325
|
+
handleUpdateSelection(item);
|
|
326
|
+
} else {
|
|
327
|
+
comboboxState.setFilter(item.value?.toString() ?? "");
|
|
328
|
+
ref.current?.focus();
|
|
329
|
+
}
|
|
322
330
|
}
|
|
323
331
|
handled = true;
|
|
324
332
|
}
|
|
@@ -377,11 +385,11 @@ export const Combobox = memo(
|
|
|
377
385
|
ref.current.setSelectionRange(length, length);
|
|
378
386
|
}
|
|
379
387
|
},
|
|
380
|
-
setValue: (value: string |
|
|
388
|
+
setValue: (value: string | SelectableMenuItem<T>) => {
|
|
381
389
|
if (typeof value === "string") {
|
|
382
390
|
comboboxState.setFilter(value);
|
|
383
391
|
} else {
|
|
384
|
-
comboboxState.setFilter(value.
|
|
392
|
+
comboboxState.setFilter(value.value);
|
|
385
393
|
}
|
|
386
394
|
},
|
|
387
395
|
selectAllInputText: () => {
|
|
@@ -421,6 +429,18 @@ export const Combobox = memo(
|
|
|
421
429
|
handleBlur();
|
|
422
430
|
}, [shouldBlur, handleBlur]);
|
|
423
431
|
|
|
432
|
+
const { sectionHeaderCount, menuItemsCount } = useMemo(
|
|
433
|
+
() => ({
|
|
434
|
+
sectionHeaderCount: filteredItems.filter((item) =>
|
|
435
|
+
isMenuItemSectionHeader(item)
|
|
436
|
+
).length,
|
|
437
|
+
menuItemsCount: filteredItems.filter((item) =>
|
|
438
|
+
isSelectableMenuItem(item)
|
|
439
|
+
).length,
|
|
440
|
+
}),
|
|
441
|
+
[filteredItems]
|
|
442
|
+
);
|
|
443
|
+
|
|
424
444
|
return (
|
|
425
445
|
<InputField.Root
|
|
426
446
|
id={id}
|
|
@@ -428,13 +448,10 @@ export const Combobox = memo(
|
|
|
428
448
|
sideOffset={6}
|
|
429
449
|
renderPopoverContent={({ width }) => {
|
|
430
450
|
const height = Math.min(
|
|
431
|
-
ListView.calculateHeight(
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
.length,
|
|
436
|
-
"label"
|
|
437
|
-
),
|
|
451
|
+
ListView.calculateHeight({
|
|
452
|
+
menuItemsCount,
|
|
453
|
+
sectionHeaderCount,
|
|
454
|
+
}),
|
|
438
455
|
ListView.rowHeight * 10.5
|
|
439
456
|
);
|
|
440
457
|
|
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
import { Size } from "@noya-app/noya-geometry";
|
|
2
2
|
import { CheckIcon } from "@noya-app/noya-icons";
|
|
3
|
-
import
|
|
3
|
+
import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
|
|
4
|
+
import React, { ForwardedRef } from "react";
|
|
4
5
|
import { cx } from "../utils/classNames";
|
|
5
|
-
|
|
6
|
+
|
|
6
7
|
import { fuzzyTokenize } from "../utils/fuzzyScorer";
|
|
7
8
|
import { renderIcon } from "./Icons";
|
|
8
9
|
import { IVirtualizedList, ListView } from "./ListView";
|
|
9
10
|
import { Spacer } from "./Spacer";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
CHECKBOX_RIGHT_INSET,
|
|
13
|
+
CHECKBOX_WIDTH,
|
|
14
|
+
KeyboardShortcut,
|
|
15
|
+
ScoredMenuItem,
|
|
16
|
+
styles,
|
|
17
|
+
} from "./internal/Menu";
|
|
11
18
|
|
|
12
|
-
interface ComboboxMenuProps {
|
|
13
|
-
items:
|
|
19
|
+
interface ComboboxMenuProps<T extends string> {
|
|
20
|
+
items: ScoredMenuItem<T>[];
|
|
14
21
|
selectedIndex: number;
|
|
15
|
-
onSelectItem: (item:
|
|
22
|
+
onSelectItem: (item: ScoredMenuItem<T>) => void;
|
|
16
23
|
onHoverIndex: (index: number) => void;
|
|
17
24
|
listSize: Size;
|
|
18
25
|
style?: React.CSSProperties;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
|
-
export const ComboboxMenu =
|
|
22
|
-
|
|
28
|
+
export const ComboboxMenu = memoGeneric(
|
|
29
|
+
forwardRefGeneric(function ComboboxMenu<T extends string>(
|
|
23
30
|
{
|
|
24
31
|
items,
|
|
25
32
|
selectedIndex,
|
|
@@ -27,14 +34,22 @@ export const ComboboxMenu = memo(
|
|
|
27
34
|
onHoverIndex,
|
|
28
35
|
listSize,
|
|
29
36
|
style,
|
|
30
|
-
}: ComboboxMenuProps
|
|
37
|
+
}: ComboboxMenuProps<T>,
|
|
31
38
|
forwardedRef: ForwardedRef<IVirtualizedList>
|
|
32
39
|
) {
|
|
40
|
+
const hasCheckedItems = items
|
|
41
|
+
.filter((item) => item.type === undefined)
|
|
42
|
+
.some((item) => item.checked);
|
|
43
|
+
|
|
33
44
|
return (
|
|
34
45
|
<ListView.Root
|
|
35
46
|
ref={forwardedRef}
|
|
36
47
|
scrollable
|
|
37
|
-
keyExtractor={(item) =>
|
|
48
|
+
keyExtractor={(item: ScoredMenuItem<T>) => {
|
|
49
|
+
if (item.type === "sectionHeader") return item.id;
|
|
50
|
+
|
|
51
|
+
return item.value;
|
|
52
|
+
}}
|
|
38
53
|
data={items}
|
|
39
54
|
virtualized={listSize}
|
|
40
55
|
pressEventName="onPointerDown"
|
|
@@ -44,19 +59,19 @@ export const ComboboxMenu = memo(
|
|
|
44
59
|
if (item.type === "sectionHeader") {
|
|
45
60
|
return (
|
|
46
61
|
<ListView.Row key={item.id} isSectionHeader>
|
|
47
|
-
{item.
|
|
62
|
+
{item.title}
|
|
48
63
|
</ListView.Row>
|
|
49
64
|
);
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
const tokens = fuzzyTokenize({
|
|
53
|
-
item: item.
|
|
68
|
+
item: typeof item.title === "string" ? item.title : item.value,
|
|
54
69
|
itemScore: item,
|
|
55
70
|
});
|
|
56
71
|
|
|
57
72
|
return (
|
|
58
73
|
<ListView.Row
|
|
59
|
-
key={item.
|
|
74
|
+
key={item.value}
|
|
60
75
|
selected={i === selectedIndex}
|
|
61
76
|
disabled={item.disabled}
|
|
62
77
|
onPress={() => !item.disabled && onSelectItem(item)}
|
|
@@ -66,18 +81,22 @@ export const ComboboxMenu = memo(
|
|
|
66
81
|
}
|
|
67
82
|
}}
|
|
68
83
|
>
|
|
69
|
-
<div className="flex flex-1 items-center">
|
|
70
|
-
{item.checked
|
|
84
|
+
<div className="flex flex-1 min-w-0 items-center">
|
|
85
|
+
{item.checked ? (
|
|
71
86
|
<div className={styles.itemIndicatorStyle}>
|
|
72
87
|
<CheckIcon />
|
|
73
88
|
</div>
|
|
74
|
-
)
|
|
89
|
+
) : hasCheckedItems ? (
|
|
90
|
+
<Spacer.Horizontal
|
|
91
|
+
size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET}
|
|
92
|
+
/>
|
|
93
|
+
) : null}
|
|
75
94
|
{tokens.map((token, j) => (
|
|
76
95
|
<span
|
|
77
|
-
key={`${item.
|
|
96
|
+
key={`${item.value}-token-${j}`}
|
|
78
97
|
className={cx(
|
|
79
98
|
token.type === "match" ? "font-bold" : "font-normal",
|
|
80
|
-
"whitespace-pre"
|
|
99
|
+
"whitespace-pre truncate"
|
|
81
100
|
)}
|
|
82
101
|
>
|
|
83
102
|
{token.text}
|
|
@@ -86,7 +105,6 @@ export const ComboboxMenu = memo(
|
|
|
86
105
|
</div>
|
|
87
106
|
{(item.shortcut || item.icon) && (
|
|
88
107
|
<>
|
|
89
|
-
<Spacer.Horizontal />
|
|
90
108
|
{item.shortcut ? (
|
|
91
109
|
<KeyboardShortcut shortcut={item.shortcut} />
|
|
92
110
|
) : (
|
|
@@ -70,20 +70,6 @@ const StyledDescription = forwardRef<
|
|
|
70
70
|
/>
|
|
71
71
|
));
|
|
72
72
|
|
|
73
|
-
const CloseButtonContainer = forwardRef<
|
|
74
|
-
HTMLDivElement,
|
|
75
|
-
React.HTMLAttributes<HTMLDivElement>
|
|
76
|
-
>(({ className, ...props }, ref) => (
|
|
77
|
-
<div
|
|
78
|
-
ref={ref}
|
|
79
|
-
className={cx(
|
|
80
|
-
`absolute top-dialog-padding right-dialog-padding z-[1] bg-popover-background p-[4px_6px] rounded-[2px] border border-[rgba(128,128,128,0.2)] `,
|
|
81
|
-
className
|
|
82
|
-
)}
|
|
83
|
-
{...props}
|
|
84
|
-
/>
|
|
85
|
-
));
|
|
86
|
-
|
|
87
73
|
export interface IDialog {
|
|
88
74
|
containsElement: (element: HTMLElement) => boolean;
|
|
89
75
|
}
|
|
@@ -141,11 +127,17 @@ export const Dialog = forwardRef(function Dialog(
|
|
|
141
127
|
})}
|
|
142
128
|
>
|
|
143
129
|
{showCloseButton && (
|
|
144
|
-
<
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
130
|
+
<DialogPrimitive.Close asChild>
|
|
131
|
+
<IconButton
|
|
132
|
+
iconName="Cross1Icon"
|
|
133
|
+
className="z-[1]"
|
|
134
|
+
style={{
|
|
135
|
+
position: "absolute",
|
|
136
|
+
top: "var(--dialog-padding)",
|
|
137
|
+
right: "var(--dialog-padding)",
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
</DialogPrimitive.Close>
|
|
149
141
|
)}
|
|
150
142
|
{title && (
|
|
151
143
|
<>
|
|
@@ -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
|
+
);
|