@noya-app/noya-designsystem 0.1.41 → 0.1.43
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 +16 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +268 -147
- package/dist/index.d.ts +268 -147
- package/dist/index.js +7618 -6908
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6161 -5487
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -5
- package/src/__tests__/combobox.test.ts +578 -0
- package/src/components/ActivityIndicator.tsx +4 -4
- package/src/components/AnimatePresence.tsx +5 -5
- package/src/components/Avatar.tsx +5 -2
- package/src/components/BaseToolbar.tsx +61 -0
- package/src/components/Button.tsx +1 -1
- package/src/components/Checkbox.tsx +6 -5
- package/src/components/Combobox.tsx +327 -337
- package/src/components/ComboboxMenu.tsx +88 -0
- package/src/components/Dialog.tsx +10 -6
- package/src/components/DimensionInput.tsx +4 -4
- package/src/components/FillPreviewBackground.tsx +51 -44
- package/src/components/FloatingWindow.tsx +5 -2
- package/src/components/GradientPicker.tsx +14 -14
- package/src/components/IconButton.tsx +6 -12
- package/src/components/Icons.tsx +3 -3
- package/src/components/InputField.tsx +145 -160
- package/src/components/Label.tsx +4 -4
- package/src/components/LabeledElementView.tsx +35 -41
- package/src/components/ListView.tsx +49 -39
- package/src/components/Message.tsx +75 -0
- package/src/components/Progress.tsx +2 -2
- package/src/components/ScrollArea.tsx +7 -5
- package/src/components/SegmentedControl.tsx +171 -0
- package/src/components/SelectMenu.tsx +61 -10
- package/src/components/Slider.tsx +5 -2
- package/src/components/Sortable.tsx +17 -27
- package/src/components/Spacer.tsx +28 -9
- package/src/components/Switch.tsx +8 -8
- package/src/components/TextArea.tsx +59 -12
- package/src/components/Toolbar.tsx +129 -0
- package/src/components/Tooltip.tsx +10 -7
- package/src/components/WorkspaceLayout.tsx +145 -152
- package/src/components/internal/Menu.tsx +5 -4
- package/src/contexts/DesignSystemConfiguration.tsx +15 -24
- package/src/contexts/DialogContext.tsx +137 -49
- package/src/contexts/ImageDataContext.tsx +6 -12
- package/src/index.css +1 -3
- package/src/index.tsx +12 -3
- package/src/utils/combobox.ts +369 -0
- package/tailwind.config.ts +1 -2
- package/src/components/RadioGroup.tsx +0 -100
- package/src/utils/completions.ts +0 -21
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { chunkBy, partition } from "@noya-app/noya-utils";
|
|
1
|
+
import { DropdownChevronIcon } from "@noya-app/noya-icons";
|
|
3
2
|
import React, {
|
|
4
3
|
FocusEventHandler,
|
|
5
4
|
ForwardedRef,
|
|
@@ -8,161 +7,71 @@ import React, {
|
|
|
8
7
|
useCallback,
|
|
9
8
|
useEffect,
|
|
10
9
|
useImperativeHandle,
|
|
11
|
-
useLayoutEffect,
|
|
12
10
|
useMemo,
|
|
13
11
|
useRef,
|
|
14
12
|
useState,
|
|
15
13
|
} from "react";
|
|
14
|
+
import { cx } from "../utils/classNames";
|
|
16
15
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
ComboboxItem,
|
|
17
|
+
ComboboxOption,
|
|
18
|
+
ComboboxState,
|
|
19
|
+
InternalComboboxItem,
|
|
20
|
+
useComboboxState,
|
|
21
|
+
} from "../utils/combobox";
|
|
22
22
|
import { ActivityIndicator } from "./ActivityIndicator";
|
|
23
|
+
import { ComboboxMenu } from "./ComboboxMenu";
|
|
23
24
|
import { InputField, InputFieldSize } from "./InputField";
|
|
24
|
-
import {
|
|
25
|
-
import { Spacer } from "./Spacer";
|
|
25
|
+
import { ListView } from "./ListView";
|
|
26
26
|
import { Small } from "./Text";
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
items: (CompletionItem | CompletionSectionHeader)[],
|
|
30
|
-
query: string
|
|
31
|
-
): CompletionListItem[] {
|
|
32
|
-
const sections = chunkBy(items, (a, b) => b.type !== "sectionHeader");
|
|
33
|
-
|
|
34
|
-
return sections.flatMap((section) => {
|
|
35
|
-
const [headers, regular] = partition(
|
|
36
|
-
section,
|
|
37
|
-
(item): item is CompletionSectionHeader => item.type === "sectionHeader"
|
|
38
|
-
);
|
|
39
|
-
const maxVisibleItems =
|
|
40
|
-
headers.length > 0 ? headers[0].maxVisibleItems : undefined;
|
|
41
|
-
|
|
42
|
-
const scoredItems = fuzzyFilter({
|
|
43
|
-
items: regular.map((item) => item.name),
|
|
44
|
-
query,
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const usedIndexes = new Set(scoredItems.map((item) => item.index));
|
|
48
|
-
const extraItems = regular.flatMap((item, index): CompletionListItem[] =>
|
|
49
|
-
item.alwaysInclude && !usedIndexes.has(index)
|
|
50
|
-
? [{ ...item, index, score: 0 }]
|
|
51
|
-
: []
|
|
52
|
-
);
|
|
53
|
-
let newItems = scoredItems
|
|
54
|
-
.map((item): CompletionListItem => ({ ...item, ...regular[item.index] }))
|
|
55
|
-
.concat(extraItems);
|
|
56
|
-
|
|
57
|
-
if (maxVisibleItems !== undefined) {
|
|
58
|
-
newItems = newItems.slice(0, maxVisibleItems);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (newItems.length === 0) return [];
|
|
62
|
-
|
|
63
|
-
return [...headers, ...newItems];
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface CompletionMenuProps {
|
|
68
|
-
items: CompletionListItem[];
|
|
69
|
-
selectedIndex: number;
|
|
70
|
-
onSelectItem: (item: CompletionListItem) => void;
|
|
71
|
-
onHoverIndex: (index: number) => void;
|
|
72
|
-
listSize: Size;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export const CompletionMenu = memo(
|
|
76
|
-
forwardRef(function CompletionMenu(
|
|
77
|
-
{
|
|
78
|
-
items,
|
|
79
|
-
selectedIndex,
|
|
80
|
-
onSelectItem,
|
|
81
|
-
onHoverIndex,
|
|
82
|
-
listSize,
|
|
83
|
-
}: CompletionMenuProps,
|
|
84
|
-
forwardedRef: ForwardedRef<IVirtualizedList>
|
|
85
|
-
) {
|
|
86
|
-
return (
|
|
87
|
-
<ListView.Root
|
|
88
|
-
ref={forwardedRef}
|
|
89
|
-
scrollable
|
|
90
|
-
keyExtractor={(item) => item.id}
|
|
91
|
-
data={items}
|
|
92
|
-
virtualized={listSize}
|
|
93
|
-
pressEventName="onPointerDown"
|
|
94
|
-
sectionHeaderVariant="label"
|
|
95
|
-
renderItem={(item, i) => {
|
|
96
|
-
if (item.type === "sectionHeader") {
|
|
97
|
-
return (
|
|
98
|
-
<ListView.Row key={item.id} isSectionHeader>
|
|
99
|
-
{item.name}
|
|
100
|
-
</ListView.Row>
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const tokens = fuzzyTokenize({
|
|
105
|
-
item: item.name,
|
|
106
|
-
itemScore: item,
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
return (
|
|
110
|
-
<ListView.Row
|
|
111
|
-
key={item.id}
|
|
112
|
-
selected={i === selectedIndex}
|
|
113
|
-
onPress={() => onSelectItem(item)}
|
|
114
|
-
onHoverChange={(hovered) => {
|
|
115
|
-
if (hovered) {
|
|
116
|
-
onHoverIndex(i);
|
|
117
|
-
}
|
|
118
|
-
}}
|
|
119
|
-
>
|
|
120
|
-
{tokens.map((token, j) => (
|
|
121
|
-
<span
|
|
122
|
-
key={j}
|
|
123
|
-
className={`${token.type === "match" ? "font-bold" : "font-normal"} whitespace-pre`}
|
|
124
|
-
>
|
|
125
|
-
{token.text}
|
|
126
|
-
</span>
|
|
127
|
-
))}
|
|
128
|
-
{item.icon && (
|
|
129
|
-
<>
|
|
130
|
-
<Spacer.Horizontal />
|
|
131
|
-
{item.icon}
|
|
132
|
-
</>
|
|
133
|
-
)}
|
|
134
|
-
</ListView.Row>
|
|
135
|
-
);
|
|
136
|
-
}}
|
|
137
|
-
/>
|
|
138
|
-
);
|
|
139
|
-
})
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
export type InputFieldWithCompletionsProps = {
|
|
28
|
+
export type BaseComboboxProps = {
|
|
143
29
|
id?: string;
|
|
144
30
|
loading?: boolean;
|
|
145
|
-
initialValue?: string;
|
|
146
31
|
placeholder?: string;
|
|
147
|
-
items:
|
|
148
|
-
onChange?: (value: string) => void;
|
|
149
|
-
onHoverItem?: (item: CompletionItem | undefined) => void;
|
|
150
|
-
onSelectItem?: (item: CompletionItem) => void;
|
|
32
|
+
items: ComboboxItem[];
|
|
151
33
|
onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
|
|
152
|
-
onBlur?: (didSubmit: boolean, value: string) => void;
|
|
153
34
|
onDeleteWhenEmpty?: () => void;
|
|
154
35
|
size?: InputFieldSize;
|
|
155
36
|
style?: React.CSSProperties;
|
|
156
37
|
className?: string;
|
|
157
38
|
label?: React.ReactNode;
|
|
158
39
|
children?: React.ReactNode;
|
|
159
|
-
hideChildrenWhenFocused?: boolean;
|
|
160
40
|
hideMenuWhenEmptyValue?: boolean;
|
|
41
|
+
readOnly?: boolean;
|
|
42
|
+
tabBehavior?: "autocomplete" | "select";
|
|
43
|
+
openMenuBehavior?: "showAllItems" | "showFilteredItems";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type StringModeOnlyProps = {
|
|
47
|
+
mode: "string";
|
|
48
|
+
initialValue?: string;
|
|
49
|
+
onChange?: (value: string) => void;
|
|
50
|
+
onSelectItem?: (value: string) => void;
|
|
51
|
+
onBlur?: (didSubmit: boolean, value: string) => void;
|
|
52
|
+
onHoverItem?: (value: string | undefined) => void;
|
|
53
|
+
allowArbitraryValues?: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type StringModeProps = BaseComboboxProps & StringModeOnlyProps;
|
|
57
|
+
|
|
58
|
+
export type OptionModeOnlyProps = {
|
|
59
|
+
mode?: "option";
|
|
60
|
+
initialValue?: ComboboxOption;
|
|
61
|
+
onChange?: (value: ComboboxOption) => void;
|
|
62
|
+
onSelectItem?: (value: ComboboxOption) => void;
|
|
63
|
+
onBlur?: (didSubmit: boolean, value: ComboboxOption) => void;
|
|
64
|
+
onHoverItem?: (value: ComboboxOption | undefined) => void;
|
|
65
|
+
allowArbitraryValues?: false;
|
|
161
66
|
};
|
|
162
67
|
|
|
163
|
-
export
|
|
68
|
+
export type OptionModeProps = BaseComboboxProps & OptionModeOnlyProps;
|
|
69
|
+
|
|
70
|
+
export type ComboboxProps = StringModeProps | OptionModeProps;
|
|
71
|
+
|
|
72
|
+
export interface ComboboxRef {
|
|
164
73
|
focus(): void;
|
|
165
|
-
setValue(value: string): void;
|
|
74
|
+
setValue(value: string | ComboboxOption): void;
|
|
166
75
|
selectAllInputText(): void;
|
|
167
76
|
}
|
|
168
77
|
|
|
@@ -171,101 +80,83 @@ export const Combobox = memo(
|
|
|
171
80
|
{
|
|
172
81
|
id,
|
|
173
82
|
loading,
|
|
174
|
-
initialValue = "",
|
|
175
83
|
placeholder,
|
|
176
84
|
items,
|
|
177
85
|
size,
|
|
178
|
-
onChange,
|
|
179
|
-
onSelectItem,
|
|
180
|
-
onHoverItem,
|
|
181
|
-
onFocus,
|
|
182
|
-
onBlur,
|
|
183
|
-
onDeleteWhenEmpty,
|
|
184
86
|
style,
|
|
185
87
|
className,
|
|
186
88
|
label,
|
|
187
89
|
children,
|
|
188
|
-
hideChildrenWhenFocused = false,
|
|
189
90
|
hideMenuWhenEmptyValue = false,
|
|
190
|
-
|
|
191
|
-
|
|
91
|
+
readOnly = false,
|
|
92
|
+
tabBehavior = "select",
|
|
93
|
+
openMenuBehavior = "showFilteredItems",
|
|
94
|
+
onFocus,
|
|
95
|
+
onDeleteWhenEmpty,
|
|
96
|
+
...rest
|
|
97
|
+
}: ComboboxProps,
|
|
98
|
+
forwardedRef: ForwardedRef<ComboboxRef>
|
|
192
99
|
) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
onHoverItem?.(nextItem);
|
|
226
|
-
} else {
|
|
227
|
-
onHoverItem?.(undefined);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
100
|
+
// We make a separate props object for the mode-specific props for memoizing
|
|
101
|
+
// since we put the props object into hook deps
|
|
102
|
+
const props = useMemo((): StringModeOnlyProps | OptionModeOnlyProps => {
|
|
103
|
+
if (rest.mode === "string") {
|
|
104
|
+
return {
|
|
105
|
+
mode: rest.mode,
|
|
106
|
+
initialValue: rest.initialValue,
|
|
107
|
+
onChange: rest.onChange,
|
|
108
|
+
onSelectItem: rest.onSelectItem,
|
|
109
|
+
onHoverItem: rest.onHoverItem,
|
|
110
|
+
onBlur: rest.onBlur,
|
|
111
|
+
allowArbitraryValues: rest.allowArbitraryValues,
|
|
112
|
+
} satisfies StringModeOnlyProps;
|
|
113
|
+
} else {
|
|
114
|
+
return {
|
|
115
|
+
mode: rest.mode,
|
|
116
|
+
initialValue: rest.initialValue,
|
|
117
|
+
onChange: rest.onChange,
|
|
118
|
+
onSelectItem: rest.onSelectItem,
|
|
119
|
+
onHoverItem: rest.onHoverItem,
|
|
120
|
+
onBlur: rest.onBlur,
|
|
121
|
+
} satisfies OptionModeOnlyProps;
|
|
122
|
+
}
|
|
123
|
+
}, [
|
|
124
|
+
rest.mode,
|
|
125
|
+
rest.initialValue,
|
|
126
|
+
rest.onChange,
|
|
127
|
+
rest.onSelectItem,
|
|
128
|
+
rest.onHoverItem,
|
|
129
|
+
rest.onBlur,
|
|
130
|
+
rest.allowArbitraryValues,
|
|
131
|
+
]);
|
|
230
132
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
133
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
134
|
+
const initialValueString =
|
|
135
|
+
typeof props.initialValue === "string"
|
|
136
|
+
? props.initialValue
|
|
137
|
+
: props.initialValue?.name ?? "";
|
|
138
|
+
const [comboboxState] = useState(
|
|
139
|
+
() =>
|
|
140
|
+
new ComboboxState(
|
|
141
|
+
items,
|
|
142
|
+
initialValueString,
|
|
143
|
+
props.mode === "string" && (props.allowArbitraryValues ?? false)
|
|
144
|
+
)
|
|
237
145
|
);
|
|
146
|
+
const state = useComboboxState(comboboxState);
|
|
147
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
148
|
+
const listRef = useRef<ListView.VirtualizedList>(null);
|
|
149
|
+
const [shouldBlur, setShouldBlur] = useState(false);
|
|
238
150
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
initialValueRef.current = initialValue;
|
|
244
|
-
updateState({ filter: initialValue });
|
|
245
|
-
}, [initialValue, updateState]);
|
|
246
|
-
|
|
247
|
-
const { filter, selectedIndex } = state;
|
|
248
|
-
|
|
249
|
-
const listRef = React.useRef<ListView.VirtualizedList>(null);
|
|
250
|
-
|
|
251
|
-
const filteredItems = useMemo(
|
|
252
|
-
() => filterWithGroupedSections(items, filter),
|
|
253
|
-
[items, filter]
|
|
254
|
-
);
|
|
151
|
+
// Keep state in sync with items prop
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
comboboxState.setItems(items);
|
|
154
|
+
}, [items, comboboxState]);
|
|
255
155
|
|
|
256
|
-
const
|
|
257
|
-
filteredItems,
|
|
258
|
-
(item) => item.type !== "sectionHeader"
|
|
259
|
-
);
|
|
156
|
+
const { filter, selectedIndex, filteredItems } = state;
|
|
260
157
|
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
normalItems.length,
|
|
264
|
-
sectionHeaderItems.length,
|
|
265
|
-
"label"
|
|
266
|
-
),
|
|
267
|
-
ListView.rowHeight * 10.5
|
|
268
|
-
);
|
|
158
|
+
const shouldShowMenu =
|
|
159
|
+
isFocused && !(hideMenuWhenEmptyValue && filter === "");
|
|
269
160
|
|
|
270
161
|
useEffect(() => {
|
|
271
162
|
if (listRef.current) {
|
|
@@ -273,99 +164,167 @@ export const Combobox = memo(
|
|
|
273
164
|
}
|
|
274
165
|
}, [selectedIndex]);
|
|
275
166
|
|
|
276
|
-
// Keep track of the last submitted value so we can detect whether a blur
|
|
277
|
-
// event was caused by selecting an item or not
|
|
278
|
-
const lastSubmittedValueRef = useRef<
|
|
279
|
-
{ filter: string; itemName: string } | undefined
|
|
280
|
-
>(undefined);
|
|
281
|
-
|
|
282
|
-
const selectItem = useCallback(
|
|
283
|
-
(item: CompletionListItem) => {
|
|
284
|
-
if (item.type === "sectionHeader") return;
|
|
285
|
-
|
|
286
|
-
lastSubmittedValueRef.current = {
|
|
287
|
-
filter,
|
|
288
|
-
itemName: item.name,
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
onSelectItem?.(item);
|
|
292
|
-
onHoverItem?.(undefined);
|
|
293
|
-
|
|
294
|
-
if (ref && typeof ref === "object") {
|
|
295
|
-
ref.current?.blur();
|
|
296
|
-
}
|
|
297
|
-
},
|
|
298
|
-
[filter, onSelectItem, onHoverItem, ref]
|
|
299
|
-
);
|
|
300
|
-
|
|
301
|
-
const handleChange = useCallback(
|
|
302
|
-
(value: string) => updateState({ selectedIndex: 0, filter: value }),
|
|
303
|
-
[updateState]
|
|
304
|
-
);
|
|
305
|
-
|
|
306
167
|
const handleBlur = useCallback(() => {
|
|
168
|
+
ref.current?.blur();
|
|
169
|
+
comboboxState.reset(initialValueString);
|
|
170
|
+
if (props.mode === "string") {
|
|
171
|
+
props.onBlur?.(
|
|
172
|
+
state.filter === state.lastSubmittedValue.filter,
|
|
173
|
+
filter
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
const selectedItem = comboboxState.getSelectedItem();
|
|
177
|
+
if (selectedItem && selectedItem.type !== "sectionHeader") {
|
|
178
|
+
props.onBlur?.(
|
|
179
|
+
state.filter === state.lastSubmittedValue.filter,
|
|
180
|
+
selectedItem
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
307
184
|
setIsFocused(false);
|
|
308
|
-
|
|
309
|
-
updateState({ selectedIndex: 0, filter: initialValue }, "resetHover");
|
|
310
|
-
onBlur?.(didSubmit, filter);
|
|
311
|
-
}, [filter, initialValue, onBlur, updateState]);
|
|
185
|
+
}, [filter, initialValueString, props, state, comboboxState]);
|
|
312
186
|
|
|
313
187
|
const handleFocus: FocusEventHandler<HTMLInputElement> = useCallback(
|
|
314
188
|
(event) => {
|
|
315
189
|
setIsFocused(true);
|
|
316
|
-
|
|
190
|
+
comboboxState.reset(
|
|
191
|
+
openMenuBehavior === "showAllItems" ? "" : initialValueString
|
|
192
|
+
);
|
|
193
|
+
if (ref.current) {
|
|
194
|
+
const length = ref.current.value.length;
|
|
195
|
+
ref.current.setSelectionRange(length, length);
|
|
196
|
+
}
|
|
317
197
|
onFocus?.(event);
|
|
318
198
|
},
|
|
319
|
-
[
|
|
199
|
+
[initialValueString, onFocus, openMenuBehavior, comboboxState]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const handleUpdateSelection = useCallback(
|
|
203
|
+
(item: InternalComboboxItem) => {
|
|
204
|
+
if (item.type === "sectionHeader") return;
|
|
205
|
+
|
|
206
|
+
const selectedItem = comboboxState.selectCurrentItem(item);
|
|
207
|
+
if (!selectedItem || selectedItem.type === "sectionHeader") return;
|
|
208
|
+
|
|
209
|
+
if (props.mode === "string") {
|
|
210
|
+
props.onSelectItem?.(selectedItem.name);
|
|
211
|
+
props.onHoverItem?.(undefined);
|
|
212
|
+
} else {
|
|
213
|
+
props.onSelectItem?.(selectedItem);
|
|
214
|
+
props.onHoverItem?.(undefined);
|
|
215
|
+
}
|
|
216
|
+
setShouldBlur(true);
|
|
217
|
+
},
|
|
218
|
+
[props, comboboxState]
|
|
320
219
|
);
|
|
321
220
|
|
|
322
221
|
const handleIndexChange = useCallback(
|
|
323
|
-
(index: number) =>
|
|
324
|
-
|
|
222
|
+
(index: number) => {
|
|
223
|
+
comboboxState.setSelectedIndex(index);
|
|
224
|
+
const item = comboboxState.getSelectedItem();
|
|
225
|
+
if (item && item.type !== "sectionHeader") {
|
|
226
|
+
if (props.mode === "string") {
|
|
227
|
+
props.onHoverItem?.(item.name);
|
|
228
|
+
} else {
|
|
229
|
+
props.onHoverItem?.(item);
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
props.onHoverItem?.(undefined);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
[props, comboboxState]
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const handleChange = useCallback(
|
|
239
|
+
(value: string) => {
|
|
240
|
+
if (readOnly) return;
|
|
241
|
+
comboboxState.setFilter(value);
|
|
242
|
+
if (props.mode === "string") {
|
|
243
|
+
props.onChange?.(value);
|
|
244
|
+
} else {
|
|
245
|
+
const selectedItem = comboboxState.getSelectedItem();
|
|
246
|
+
if (selectedItem && selectedItem.type !== "sectionHeader") {
|
|
247
|
+
props.onChange?.(selectedItem);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
[readOnly, props, comboboxState]
|
|
325
252
|
);
|
|
326
253
|
|
|
327
254
|
const handleKeyDown = useCallback(
|
|
328
255
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
329
256
|
let handled = false;
|
|
257
|
+
const item = comboboxState.getSelectedItem();
|
|
330
258
|
|
|
331
259
|
switch (event.key) {
|
|
332
260
|
case "ArrowDown":
|
|
333
261
|
case "ArrowUp":
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
filteredItems,
|
|
337
|
-
selectedIndex,
|
|
338
|
-
event.key === "ArrowDown" ? "next" : "previous",
|
|
339
|
-
(item) => item.type === "sectionHeader"
|
|
340
|
-
)
|
|
262
|
+
comboboxState.moveSelection(
|
|
263
|
+
event.key === "ArrowDown" ? "down" : "up"
|
|
341
264
|
);
|
|
342
|
-
|
|
343
265
|
handled = true;
|
|
344
266
|
break;
|
|
345
|
-
case "Enter":
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
267
|
+
case "Enter":
|
|
268
|
+
case "Return": {
|
|
269
|
+
if (shouldShowMenu) {
|
|
270
|
+
if (item) {
|
|
271
|
+
handleUpdateSelection(item);
|
|
272
|
+
} else if (
|
|
273
|
+
props.mode === "string" &&
|
|
274
|
+
props.allowArbitraryValues
|
|
275
|
+
) {
|
|
276
|
+
// If no item is selected but arbitrary filters are allowed in string mode,
|
|
277
|
+
// submit the current filter value
|
|
278
|
+
comboboxState.submitArbitraryFilter(filter);
|
|
279
|
+
handleBlur();
|
|
280
|
+
} else {
|
|
281
|
+
comboboxState.restoreLastSubmittedValue();
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
ref.current?.focus();
|
|
349
285
|
}
|
|
350
|
-
|
|
351
|
-
handled = true;
|
|
352
286
|
break;
|
|
353
287
|
}
|
|
354
288
|
case "Tab": {
|
|
355
|
-
// Update the filter to the selected item
|
|
356
|
-
const item = filteredItems[selectedIndex];
|
|
357
289
|
if (item) {
|
|
358
|
-
|
|
290
|
+
if (event.shiftKey) {
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (tabBehavior === "select") {
|
|
295
|
+
handleUpdateSelection(item);
|
|
296
|
+
handled = true;
|
|
297
|
+
} else {
|
|
298
|
+
// In autocomplete mode:
|
|
299
|
+
// 1. Always set the filter to the selected item
|
|
300
|
+
// 2. If the filter matches the item name, submit it
|
|
301
|
+
event.preventDefault();
|
|
302
|
+
event.stopPropagation();
|
|
303
|
+
if (item.name === filter) {
|
|
304
|
+
handleUpdateSelection(item);
|
|
305
|
+
} else {
|
|
306
|
+
comboboxState.setFilter(item.name);
|
|
307
|
+
ref.current?.focus();
|
|
308
|
+
}
|
|
309
|
+
handled = true;
|
|
310
|
+
}
|
|
311
|
+
} else if (
|
|
312
|
+
props.mode === "string" &&
|
|
313
|
+
props.allowArbitraryValues &&
|
|
314
|
+
filter !== ""
|
|
315
|
+
) {
|
|
316
|
+
// Allow submitting arbitrary filter on Tab only in string mode
|
|
317
|
+
comboboxState.submitArbitraryFilter(filter);
|
|
318
|
+
handleBlur();
|
|
319
|
+
handled = true;
|
|
359
320
|
}
|
|
360
|
-
|
|
361
|
-
handled = true;
|
|
362
321
|
break;
|
|
363
322
|
}
|
|
364
323
|
case "Escape":
|
|
365
|
-
if (ref
|
|
366
|
-
ref.current
|
|
324
|
+
if (ref.current) {
|
|
325
|
+
ref.current.blur();
|
|
326
|
+
comboboxState.restoreLastSubmittedValue();
|
|
367
327
|
}
|
|
368
|
-
|
|
369
328
|
handled = true;
|
|
370
329
|
break;
|
|
371
330
|
case "Backspace":
|
|
@@ -373,7 +332,6 @@ export const Combobox = memo(
|
|
|
373
332
|
if (filter === "") {
|
|
374
333
|
onDeleteWhenEmpty?.();
|
|
375
334
|
}
|
|
376
|
-
|
|
377
335
|
break;
|
|
378
336
|
}
|
|
379
337
|
}
|
|
@@ -384,61 +342,112 @@ export const Combobox = memo(
|
|
|
384
342
|
}
|
|
385
343
|
},
|
|
386
344
|
[
|
|
387
|
-
handleIndexChange,
|
|
388
|
-
filteredItems,
|
|
389
|
-
selectedIndex,
|
|
390
|
-
ref,
|
|
391
|
-
selectItem,
|
|
392
|
-
handleChange,
|
|
393
345
|
filter,
|
|
346
|
+
handleUpdateSelection,
|
|
347
|
+
handleBlur,
|
|
394
348
|
onDeleteWhenEmpty,
|
|
349
|
+
shouldShowMenu,
|
|
350
|
+
comboboxState,
|
|
351
|
+
tabBehavior,
|
|
352
|
+
props,
|
|
395
353
|
]
|
|
396
354
|
);
|
|
397
355
|
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
const typeaheadValue = filteredItems[selectedIndex]?.name;
|
|
356
|
+
const typeaheadValue = comboboxState.getTypeaheadValue();
|
|
401
357
|
|
|
402
358
|
useImperativeHandle(forwardedRef, () => ({
|
|
403
359
|
focus: () => {
|
|
404
360
|
ref.current?.focus();
|
|
361
|
+
if (ref.current) {
|
|
362
|
+
const length = ref.current.value.length;
|
|
363
|
+
ref.current.setSelectionRange(length, length);
|
|
364
|
+
}
|
|
405
365
|
},
|
|
406
|
-
setValue: (value: string) => {
|
|
407
|
-
|
|
366
|
+
setValue: (value: string | ComboboxOption) => {
|
|
367
|
+
if (typeof value === "string") {
|
|
368
|
+
comboboxState.setFilter(value);
|
|
369
|
+
} else {
|
|
370
|
+
comboboxState.setFilter(value.name);
|
|
371
|
+
}
|
|
408
372
|
},
|
|
409
373
|
selectAllInputText: () => {
|
|
410
374
|
ref.current?.setSelectionRange(0, ref.current.value.length);
|
|
411
375
|
},
|
|
412
376
|
}));
|
|
413
377
|
|
|
378
|
+
const handleChevronClick = useCallback(
|
|
379
|
+
(event: React.MouseEvent) => {
|
|
380
|
+
event.stopPropagation();
|
|
381
|
+
|
|
382
|
+
if (shouldShowMenu) {
|
|
383
|
+
handleBlur();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Use openMenuBehavior consistently for both focus and chevron click
|
|
388
|
+
comboboxState.reset(
|
|
389
|
+
openMenuBehavior === "showAllItems" ? "" : initialValueString
|
|
390
|
+
);
|
|
391
|
+
ref.current?.focus();
|
|
392
|
+
},
|
|
393
|
+
[
|
|
394
|
+
shouldShowMenu,
|
|
395
|
+
openMenuBehavior,
|
|
396
|
+
initialValueString,
|
|
397
|
+
handleBlur,
|
|
398
|
+
comboboxState,
|
|
399
|
+
]
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (!shouldBlur) return;
|
|
404
|
+
ref.current?.focus();
|
|
405
|
+
handleBlur();
|
|
406
|
+
setShouldBlur(false);
|
|
407
|
+
}, [shouldBlur, handleBlur]);
|
|
408
|
+
|
|
414
409
|
return (
|
|
415
410
|
<InputField.Root
|
|
416
411
|
id={id}
|
|
417
412
|
size={size}
|
|
418
|
-
|
|
413
|
+
sideOffset={6}
|
|
419
414
|
renderPopoverContent={({ width }) => {
|
|
415
|
+
const height = Math.min(
|
|
416
|
+
ListView.calculateHeight(
|
|
417
|
+
filteredItems.filter((item) => item.type !== "sectionHeader")
|
|
418
|
+
.length,
|
|
419
|
+
filteredItems.filter((item) => item.type === "sectionHeader")
|
|
420
|
+
.length,
|
|
421
|
+
"label"
|
|
422
|
+
),
|
|
423
|
+
ListView.rowHeight * 10.5
|
|
424
|
+
);
|
|
425
|
+
|
|
420
426
|
const listSize = { width, height };
|
|
421
427
|
|
|
422
428
|
return (
|
|
423
429
|
<div
|
|
424
|
-
className={
|
|
425
|
-
|
|
426
|
-
flex:
|
|
427
|
-
}
|
|
430
|
+
className={cx(
|
|
431
|
+
"flex-col",
|
|
432
|
+
shouldShowMenu && !readOnly ? "flex" : "hidden"
|
|
433
|
+
)}
|
|
428
434
|
>
|
|
429
|
-
{filteredItems.length > 0 ? (
|
|
430
|
-
<
|
|
435
|
+
{filteredItems.length > 0 && !loading ? (
|
|
436
|
+
<ComboboxMenu
|
|
431
437
|
ref={listRef}
|
|
432
438
|
items={filteredItems}
|
|
433
439
|
selectedIndex={selectedIndex}
|
|
434
|
-
onSelectItem={
|
|
440
|
+
onSelectItem={handleUpdateSelection}
|
|
435
441
|
onHoverIndex={handleIndexChange}
|
|
436
442
|
listSize={listSize}
|
|
443
|
+
style={{
|
|
444
|
+
flex: `0 0 ${height}px`,
|
|
445
|
+
}}
|
|
437
446
|
/>
|
|
438
447
|
) : (
|
|
439
|
-
<div className="flex flex-col h-[
|
|
448
|
+
<div className="flex flex-col h-[50px] p-5 items-center justify-center">
|
|
440
449
|
<Small className="text-text-disabled">
|
|
441
|
-
{loading ? "Loading" : "No results"}
|
|
450
|
+
{loading ? "Loading..." : "No results"}
|
|
442
451
|
</Small>
|
|
443
452
|
</div>
|
|
444
453
|
)}
|
|
@@ -454,7 +463,7 @@ export const Combobox = memo(
|
|
|
454
463
|
onChange={handleChange}
|
|
455
464
|
onBlur={handleBlur}
|
|
456
465
|
onFocusCapture={handleFocus}
|
|
457
|
-
onKeyDown={handleKeyDown}
|
|
466
|
+
onKeyDown={!readOnly ? handleKeyDown : undefined}
|
|
458
467
|
style={style}
|
|
459
468
|
className={className}
|
|
460
469
|
autoCapitalize="off"
|
|
@@ -464,56 +473,37 @@ export const Combobox = memo(
|
|
|
464
473
|
aria-autocomplete="list"
|
|
465
474
|
aria-haspopup="listbox"
|
|
466
475
|
aria-owns="component-listbox"
|
|
467
|
-
aria-expanded={
|
|
476
|
+
aria-expanded={shouldShowMenu}
|
|
468
477
|
aria-controls="component-listbox"
|
|
478
|
+
{...(readOnly ? { readOnly: true } : {})}
|
|
469
479
|
/>
|
|
470
480
|
{filter &&
|
|
471
481
|
typeaheadValue &&
|
|
472
482
|
typeaheadValue.toLowerCase().startsWith(filter.toLowerCase()) && (
|
|
473
|
-
<InputField.Typeahead
|
|
483
|
+
<InputField.Typeahead
|
|
484
|
+
value={typeaheadValue}
|
|
485
|
+
prefix={filter}
|
|
486
|
+
isFocused={isFocused}
|
|
487
|
+
/>
|
|
474
488
|
)}
|
|
475
|
-
{
|
|
476
|
-
{loading &&
|
|
489
|
+
{children}
|
|
490
|
+
{loading && (
|
|
477
491
|
<InputField.Label>
|
|
478
492
|
<ActivityIndicator />
|
|
479
493
|
</InputField.Label>
|
|
480
494
|
)}
|
|
481
|
-
|
|
495
|
+
<InputField.Label>{label}</InputField.Label>
|
|
496
|
+
{!readOnly && (
|
|
497
|
+
<InputField.Button variant="floating" onClick={handleChevronClick}>
|
|
498
|
+
<DropdownChevronIcon
|
|
499
|
+
className={cx(
|
|
500
|
+
"transition-transform duration-100",
|
|
501
|
+
shouldShowMenu ? "rotate-180" : "rotate-0"
|
|
502
|
+
)}
|
|
503
|
+
/>
|
|
504
|
+
</InputField.Button>
|
|
505
|
+
)}
|
|
482
506
|
</InputField.Root>
|
|
483
507
|
);
|
|
484
508
|
})
|
|
485
509
|
);
|
|
486
|
-
|
|
487
|
-
function getNextIndex<T>(
|
|
488
|
-
items: T[],
|
|
489
|
-
currentIndex: number,
|
|
490
|
-
direction: "next" | "previous",
|
|
491
|
-
isDisabled: (item: T) => boolean
|
|
492
|
-
): number {
|
|
493
|
-
// Make sure the current index is within bounds
|
|
494
|
-
currentIndex =
|
|
495
|
-
currentIndex < 0
|
|
496
|
-
? 0
|
|
497
|
-
: currentIndex >= items.length
|
|
498
|
-
? items.length - 1
|
|
499
|
-
: currentIndex;
|
|
500
|
-
|
|
501
|
-
// If there are no valid items in the array, return -1
|
|
502
|
-
if (items.every(isDisabled)) return -1;
|
|
503
|
-
|
|
504
|
-
let nextIndex = currentIndex;
|
|
505
|
-
|
|
506
|
-
do {
|
|
507
|
-
// Move to the next or previous index, wrapping around if necessary
|
|
508
|
-
if (direction === "next") {
|
|
509
|
-
nextIndex = (nextIndex + 1) % items.length;
|
|
510
|
-
} else {
|
|
511
|
-
nextIndex = (nextIndex - 1 + items.length) % items.length;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// If we've looped all the way around without finding a valid item, return the current index
|
|
515
|
-
if (nextIndex === currentIndex) return currentIndex;
|
|
516
|
-
} while (isDisabled(items[nextIndex]));
|
|
517
|
-
|
|
518
|
-
return nextIndex;
|
|
519
|
-
}
|