@noya-app/noya-designsystem 0.1.41 → 0.1.42
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 +10 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +217 -121
- package/dist/index.d.ts +217 -121
- package/dist/index.js +7435 -6796
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5257 -4660
- 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 +220 -324
- 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 +23 -13
- package/src/components/Toolbar.tsx +134 -0
- package/src/components/Tooltip.tsx +10 -7
- 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,146 +7,33 @@ 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 ComboboxProps = {
|
|
143
29
|
id?: string;
|
|
144
30
|
loading?: boolean;
|
|
145
31
|
initialValue?: string;
|
|
146
32
|
placeholder?: string;
|
|
147
|
-
items:
|
|
33
|
+
items: ComboboxItem[];
|
|
148
34
|
onChange?: (value: string) => void;
|
|
149
|
-
onHoverItem?: (item:
|
|
150
|
-
onSelectItem?: (item:
|
|
35
|
+
onHoverItem?: (item: ComboboxOption | undefined) => void;
|
|
36
|
+
onSelectItem?: (item: ComboboxOption) => void;
|
|
151
37
|
onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
|
|
152
38
|
onBlur?: (didSubmit: boolean, value: string) => void;
|
|
153
39
|
onDeleteWhenEmpty?: () => void;
|
|
@@ -156,11 +42,14 @@ export type InputFieldWithCompletionsProps = {
|
|
|
156
42
|
className?: string;
|
|
157
43
|
label?: React.ReactNode;
|
|
158
44
|
children?: React.ReactNode;
|
|
159
|
-
hideChildrenWhenFocused?: boolean;
|
|
160
45
|
hideMenuWhenEmptyValue?: boolean;
|
|
46
|
+
allowArbitraryValues?: boolean;
|
|
47
|
+
readOnly?: boolean;
|
|
48
|
+
tabBehavior?: "autocomplete" | "select";
|
|
49
|
+
openMenuBehavior?: "showAllItems" | "showFilteredItems";
|
|
161
50
|
};
|
|
162
51
|
|
|
163
|
-
export interface
|
|
52
|
+
export interface ComboboxRef {
|
|
164
53
|
focus(): void;
|
|
165
54
|
setValue(value: string): void;
|
|
166
55
|
selectAllInputText(): void;
|
|
@@ -185,87 +74,35 @@ export const Combobox = memo(
|
|
|
185
74
|
className,
|
|
186
75
|
label,
|
|
187
76
|
children,
|
|
188
|
-
hideChildrenWhenFocused = false,
|
|
189
77
|
hideMenuWhenEmptyValue = false,
|
|
190
|
-
|
|
191
|
-
|
|
78
|
+
allowArbitraryValues = false,
|
|
79
|
+
readOnly = false,
|
|
80
|
+
tabBehavior = "select",
|
|
81
|
+
openMenuBehavior = "showFilteredItems",
|
|
82
|
+
}: ComboboxProps,
|
|
83
|
+
forwardedRef: ForwardedRef<ComboboxRef>
|
|
192
84
|
) {
|
|
193
85
|
const ref = useRef<HTMLInputElement>(null);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const [state, _setState] = useState({
|
|
197
|
-
filter: initialValue,
|
|
198
|
-
selectedIndex: 0,
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const updateState = useCallback(
|
|
202
|
-
(
|
|
203
|
-
newState: { filter?: string; selectedIndex?: number },
|
|
204
|
-
hoverItem?: "resetHover"
|
|
205
|
-
) => {
|
|
206
|
-
const nextState = { ...state, ...newState };
|
|
207
|
-
const nextItems = filterWithGroupedSections(items, nextState.filter);
|
|
208
|
-
|
|
209
|
-
// If we would be selecting a header, find the next valid index
|
|
210
|
-
if (nextItems[nextState.selectedIndex]?.type === "sectionHeader") {
|
|
211
|
-
nextState.selectedIndex = getNextIndex(
|
|
212
|
-
nextItems,
|
|
213
|
-
nextState.selectedIndex,
|
|
214
|
-
"next",
|
|
215
|
-
(item) => item.type === "sectionHeader"
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (hoverItem === "resetHover") {
|
|
220
|
-
onHoverItem?.(undefined);
|
|
221
|
-
} else {
|
|
222
|
-
const nextItem = nextItems[nextState.selectedIndex];
|
|
223
|
-
|
|
224
|
-
if (nextItem && nextItem.type !== "sectionHeader") {
|
|
225
|
-
onHoverItem?.(nextItem);
|
|
226
|
-
} else {
|
|
227
|
-
onHoverItem?.(undefined);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
_setState(nextState);
|
|
232
|
-
if (newState.filter !== undefined) {
|
|
233
|
-
onChange?.(newState.filter);
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
[items, onChange, onHoverItem, state]
|
|
86
|
+
const [comboboxState] = useState(
|
|
87
|
+
() => new ComboboxState(items, initialValue, allowArbitraryValues)
|
|
237
88
|
);
|
|
89
|
+
const state = useComboboxState(comboboxState);
|
|
90
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
91
|
+
const listRef = useRef<ListView.VirtualizedList>(null);
|
|
92
|
+
const [shouldBlur, setShouldBlur] = useState(false);
|
|
238
93
|
|
|
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
|
-
);
|
|
94
|
+
// Keep state in sync with items prop
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
comboboxState.setItems(items);
|
|
97
|
+
}, [items, comboboxState]);
|
|
255
98
|
|
|
256
|
-
const
|
|
257
|
-
filteredItems,
|
|
258
|
-
(item) => item.type !== "sectionHeader"
|
|
259
|
-
);
|
|
99
|
+
const { filter, selectedIndex, filteredItems } = state;
|
|
260
100
|
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
),
|
|
267
|
-
ListView.rowHeight * 10.5
|
|
268
|
-
);
|
|
101
|
+
const shouldShowMenu = useMemo(() => {
|
|
102
|
+
if (!isFocused) return false;
|
|
103
|
+
if (hideMenuWhenEmptyValue && filter === "") return false;
|
|
104
|
+
return true;
|
|
105
|
+
}, [hideMenuWhenEmptyValue, filter, isFocused]);
|
|
269
106
|
|
|
270
107
|
useEffect(() => {
|
|
271
108
|
if (listRef.current) {
|
|
@@ -273,99 +110,122 @@ export const Combobox = memo(
|
|
|
273
110
|
}
|
|
274
111
|
}, [selectedIndex]);
|
|
275
112
|
|
|
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
113
|
const handleBlur = useCallback(() => {
|
|
114
|
+
ref.current?.blur();
|
|
115
|
+
comboboxState.reset(initialValue);
|
|
116
|
+
onBlur?.(state.filter === state.lastSubmittedValue.filter, filter);
|
|
307
117
|
setIsFocused(false);
|
|
308
|
-
|
|
309
|
-
updateState({ selectedIndex: 0, filter: initialValue }, "resetHover");
|
|
310
|
-
onBlur?.(didSubmit, filter);
|
|
311
|
-
}, [filter, initialValue, onBlur, updateState]);
|
|
118
|
+
}, [filter, initialValue, onBlur, state, comboboxState]);
|
|
312
119
|
|
|
313
120
|
const handleFocus: FocusEventHandler<HTMLInputElement> = useCallback(
|
|
314
121
|
(event) => {
|
|
315
122
|
setIsFocused(true);
|
|
316
|
-
|
|
123
|
+
comboboxState.reset(
|
|
124
|
+
openMenuBehavior === "showAllItems" ? "" : initialValue
|
|
125
|
+
);
|
|
126
|
+
if (ref.current) {
|
|
127
|
+
const length = ref.current.value.length;
|
|
128
|
+
ref.current.setSelectionRange(length, length);
|
|
129
|
+
}
|
|
317
130
|
onFocus?.(event);
|
|
318
131
|
},
|
|
319
|
-
[initialValue, onFocus,
|
|
132
|
+
[initialValue, onFocus, openMenuBehavior, comboboxState]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const handleUpdateSelection = useCallback(
|
|
136
|
+
(item: InternalComboboxItem) => {
|
|
137
|
+
if (item.type === "sectionHeader") return;
|
|
138
|
+
|
|
139
|
+
const selectedItem = comboboxState.selectCurrentItem(item);
|
|
140
|
+
if (!selectedItem || selectedItem.type === "sectionHeader") return;
|
|
141
|
+
|
|
142
|
+
onSelectItem?.(selectedItem);
|
|
143
|
+
onHoverItem?.(undefined);
|
|
144
|
+
setShouldBlur(true);
|
|
145
|
+
},
|
|
146
|
+
[onSelectItem, onHoverItem, comboboxState]
|
|
320
147
|
);
|
|
321
148
|
|
|
322
149
|
const handleIndexChange = useCallback(
|
|
323
|
-
(index: number) =>
|
|
324
|
-
|
|
150
|
+
(index: number) => {
|
|
151
|
+
comboboxState.setSelectedIndex(index);
|
|
152
|
+
const item = comboboxState.getSelectedItem();
|
|
153
|
+
if (item && item.type !== "sectionHeader") {
|
|
154
|
+
onHoverItem?.(item);
|
|
155
|
+
} else {
|
|
156
|
+
onHoverItem?.(undefined);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
[onHoverItem, comboboxState]
|
|
325
160
|
);
|
|
326
161
|
|
|
327
162
|
const handleKeyDown = useCallback(
|
|
328
163
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
329
164
|
let handled = false;
|
|
165
|
+
const item = comboboxState.getSelectedItem();
|
|
330
166
|
|
|
331
167
|
switch (event.key) {
|
|
332
168
|
case "ArrowDown":
|
|
333
169
|
case "ArrowUp":
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
filteredItems,
|
|
337
|
-
selectedIndex,
|
|
338
|
-
event.key === "ArrowDown" ? "next" : "previous",
|
|
339
|
-
(item) => item.type === "sectionHeader"
|
|
340
|
-
)
|
|
170
|
+
comboboxState.moveSelection(
|
|
171
|
+
event.key === "ArrowDown" ? "down" : "up"
|
|
341
172
|
);
|
|
342
|
-
|
|
343
173
|
handled = true;
|
|
344
174
|
break;
|
|
345
|
-
case "Enter":
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
175
|
+
case "Enter":
|
|
176
|
+
case "Return": {
|
|
177
|
+
if (shouldShowMenu) {
|
|
178
|
+
if (item) {
|
|
179
|
+
handleUpdateSelection(item);
|
|
180
|
+
} else if (allowArbitraryValues) {
|
|
181
|
+
// If no item is selected but arbitrary filters are allowed,
|
|
182
|
+
// submit the current filter value
|
|
183
|
+
comboboxState.submitArbitraryFilter(filter);
|
|
184
|
+
handleBlur();
|
|
185
|
+
} else {
|
|
186
|
+
comboboxState.restoreLastSubmittedValue();
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
ref.current?.focus();
|
|
349
190
|
}
|
|
350
|
-
|
|
351
|
-
handled = true;
|
|
352
191
|
break;
|
|
353
192
|
}
|
|
354
193
|
case "Tab": {
|
|
355
|
-
// Update the filter to the selected item
|
|
356
|
-
const item = filteredItems[selectedIndex];
|
|
357
194
|
if (item) {
|
|
358
|
-
|
|
195
|
+
if (event.shiftKey) {
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (tabBehavior === "select") {
|
|
200
|
+
handleUpdateSelection(item);
|
|
201
|
+
handled = true;
|
|
202
|
+
} else {
|
|
203
|
+
// In autocomplete mode:
|
|
204
|
+
// 1. Always set the filter to the selected item
|
|
205
|
+
// 2. If the filter matches the item name, submit it
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
event.stopPropagation();
|
|
208
|
+
if (item.name === filter) {
|
|
209
|
+
handleUpdateSelection(item);
|
|
210
|
+
} else {
|
|
211
|
+
comboboxState.setFilter(item.name);
|
|
212
|
+
ref.current?.focus();
|
|
213
|
+
}
|
|
214
|
+
handled = true;
|
|
215
|
+
}
|
|
216
|
+
} else if (allowArbitraryValues && filter !== "") {
|
|
217
|
+
// Allow submitting arbitrary filter on Tab
|
|
218
|
+
comboboxState.submitArbitraryFilter(filter);
|
|
219
|
+
handleBlur();
|
|
220
|
+
handled = true;
|
|
359
221
|
}
|
|
360
|
-
|
|
361
|
-
handled = true;
|
|
362
222
|
break;
|
|
363
223
|
}
|
|
364
224
|
case "Escape":
|
|
365
|
-
if (ref
|
|
366
|
-
ref.current
|
|
225
|
+
if (ref.current) {
|
|
226
|
+
ref.current.blur();
|
|
227
|
+
comboboxState.restoreLastSubmittedValue();
|
|
367
228
|
}
|
|
368
|
-
|
|
369
229
|
handled = true;
|
|
370
230
|
break;
|
|
371
231
|
case "Backspace":
|
|
@@ -373,7 +233,6 @@ export const Combobox = memo(
|
|
|
373
233
|
if (filter === "") {
|
|
374
234
|
onDeleteWhenEmpty?.();
|
|
375
235
|
}
|
|
376
|
-
|
|
377
236
|
break;
|
|
378
237
|
}
|
|
379
238
|
}
|
|
@@ -384,61 +243,117 @@ export const Combobox = memo(
|
|
|
384
243
|
}
|
|
385
244
|
},
|
|
386
245
|
[
|
|
387
|
-
handleIndexChange,
|
|
388
|
-
filteredItems,
|
|
389
|
-
selectedIndex,
|
|
390
|
-
ref,
|
|
391
|
-
selectItem,
|
|
392
|
-
handleChange,
|
|
393
246
|
filter,
|
|
247
|
+
handleUpdateSelection,
|
|
248
|
+
handleBlur,
|
|
394
249
|
onDeleteWhenEmpty,
|
|
250
|
+
shouldShowMenu,
|
|
251
|
+
comboboxState,
|
|
252
|
+
tabBehavior,
|
|
253
|
+
allowArbitraryValues,
|
|
395
254
|
]
|
|
396
255
|
);
|
|
397
256
|
|
|
398
|
-
const
|
|
257
|
+
const handleChange = useCallback(
|
|
258
|
+
(value: string) => {
|
|
259
|
+
if (readOnly) return;
|
|
260
|
+
comboboxState.setFilter(value);
|
|
261
|
+
onChange?.(value);
|
|
262
|
+
},
|
|
263
|
+
[onChange, readOnly, comboboxState]
|
|
264
|
+
);
|
|
399
265
|
|
|
400
|
-
const typeaheadValue =
|
|
266
|
+
const typeaheadValue = comboboxState.getTypeaheadValue();
|
|
401
267
|
|
|
402
268
|
useImperativeHandle(forwardedRef, () => ({
|
|
403
269
|
focus: () => {
|
|
404
270
|
ref.current?.focus();
|
|
271
|
+
if (ref.current) {
|
|
272
|
+
const length = ref.current.value.length;
|
|
273
|
+
ref.current.setSelectionRange(length, length);
|
|
274
|
+
}
|
|
405
275
|
},
|
|
406
276
|
setValue: (value: string) => {
|
|
407
|
-
|
|
277
|
+
comboboxState.setFilter(value);
|
|
408
278
|
},
|
|
409
279
|
selectAllInputText: () => {
|
|
410
280
|
ref.current?.setSelectionRange(0, ref.current.value.length);
|
|
411
281
|
},
|
|
412
282
|
}));
|
|
413
283
|
|
|
284
|
+
const handleChevronClick = useCallback(
|
|
285
|
+
(event: React.MouseEvent) => {
|
|
286
|
+
event.stopPropagation();
|
|
287
|
+
|
|
288
|
+
if (shouldShowMenu) {
|
|
289
|
+
handleBlur();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Use openMenuBehavior consistently for both focus and chevron click
|
|
294
|
+
comboboxState.reset(
|
|
295
|
+
openMenuBehavior === "showAllItems" ? "" : initialValue
|
|
296
|
+
);
|
|
297
|
+
ref.current?.focus();
|
|
298
|
+
},
|
|
299
|
+
[
|
|
300
|
+
shouldShowMenu,
|
|
301
|
+
openMenuBehavior,
|
|
302
|
+
initialValue,
|
|
303
|
+
handleBlur,
|
|
304
|
+
comboboxState,
|
|
305
|
+
]
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (!shouldBlur) return;
|
|
310
|
+
ref.current?.focus();
|
|
311
|
+
handleBlur();
|
|
312
|
+
setShouldBlur(false);
|
|
313
|
+
}, [shouldBlur, handleBlur]);
|
|
314
|
+
|
|
414
315
|
return (
|
|
415
316
|
<InputField.Root
|
|
416
317
|
id={id}
|
|
417
318
|
size={size}
|
|
418
|
-
|
|
319
|
+
sideOffset={6}
|
|
419
320
|
renderPopoverContent={({ width }) => {
|
|
321
|
+
const height = Math.min(
|
|
322
|
+
ListView.calculateHeight(
|
|
323
|
+
filteredItems.filter((item) => item.type !== "sectionHeader")
|
|
324
|
+
.length,
|
|
325
|
+
filteredItems.filter((item) => item.type === "sectionHeader")
|
|
326
|
+
.length,
|
|
327
|
+
"label"
|
|
328
|
+
),
|
|
329
|
+
ListView.rowHeight * 10.5
|
|
330
|
+
);
|
|
331
|
+
|
|
420
332
|
const listSize = { width, height };
|
|
421
333
|
|
|
422
334
|
return (
|
|
423
335
|
<div
|
|
424
|
-
className={
|
|
425
|
-
|
|
426
|
-
flex:
|
|
427
|
-
}
|
|
336
|
+
className={cx(
|
|
337
|
+
"flex-col",
|
|
338
|
+
shouldShowMenu && !readOnly ? "flex" : "hidden"
|
|
339
|
+
)}
|
|
428
340
|
>
|
|
429
|
-
{filteredItems.length > 0 ? (
|
|
430
|
-
<
|
|
341
|
+
{filteredItems.length > 0 && !loading ? (
|
|
342
|
+
<ComboboxMenu
|
|
431
343
|
ref={listRef}
|
|
432
344
|
items={filteredItems}
|
|
433
345
|
selectedIndex={selectedIndex}
|
|
434
|
-
onSelectItem={
|
|
346
|
+
onSelectItem={handleUpdateSelection}
|
|
435
347
|
onHoverIndex={handleIndexChange}
|
|
436
348
|
listSize={listSize}
|
|
349
|
+
style={{
|
|
350
|
+
flex: `0 0 ${height}px`,
|
|
351
|
+
}}
|
|
437
352
|
/>
|
|
438
353
|
) : (
|
|
439
|
-
<div className="flex flex-col h-[
|
|
354
|
+
<div className="flex flex-col h-[50px] p-5 items-center justify-center">
|
|
440
355
|
<Small className="text-text-disabled">
|
|
441
|
-
{loading ? "Loading" : "No results"}
|
|
356
|
+
{loading ? "Loading..." : "No results"}
|
|
442
357
|
</Small>
|
|
443
358
|
</div>
|
|
444
359
|
)}
|
|
@@ -454,7 +369,7 @@ export const Combobox = memo(
|
|
|
454
369
|
onChange={handleChange}
|
|
455
370
|
onBlur={handleBlur}
|
|
456
371
|
onFocusCapture={handleFocus}
|
|
457
|
-
onKeyDown={handleKeyDown}
|
|
372
|
+
onKeyDown={!readOnly ? handleKeyDown : undefined}
|
|
458
373
|
style={style}
|
|
459
374
|
className={className}
|
|
460
375
|
autoCapitalize="off"
|
|
@@ -464,56 +379,37 @@ export const Combobox = memo(
|
|
|
464
379
|
aria-autocomplete="list"
|
|
465
380
|
aria-haspopup="listbox"
|
|
466
381
|
aria-owns="component-listbox"
|
|
467
|
-
aria-expanded={
|
|
382
|
+
aria-expanded={shouldShowMenu}
|
|
468
383
|
aria-controls="component-listbox"
|
|
384
|
+
{...(readOnly ? { readOnly: true } : {})}
|
|
469
385
|
/>
|
|
470
386
|
{filter &&
|
|
471
387
|
typeaheadValue &&
|
|
472
388
|
typeaheadValue.toLowerCase().startsWith(filter.toLowerCase()) && (
|
|
473
|
-
<InputField.Typeahead
|
|
389
|
+
<InputField.Typeahead
|
|
390
|
+
value={typeaheadValue}
|
|
391
|
+
prefix={filter}
|
|
392
|
+
isFocused={isFocused}
|
|
393
|
+
/>
|
|
474
394
|
)}
|
|
475
|
-
{
|
|
476
|
-
{loading &&
|
|
395
|
+
{children}
|
|
396
|
+
{loading && (
|
|
477
397
|
<InputField.Label>
|
|
478
398
|
<ActivityIndicator />
|
|
479
399
|
</InputField.Label>
|
|
480
400
|
)}
|
|
481
|
-
|
|
401
|
+
<InputField.Label>{label}</InputField.Label>
|
|
402
|
+
{!readOnly && (
|
|
403
|
+
<InputField.Button variant="floating" onClick={handleChevronClick}>
|
|
404
|
+
<DropdownChevronIcon
|
|
405
|
+
className={cx(
|
|
406
|
+
"transition-transform duration-100",
|
|
407
|
+
shouldShowMenu ? "rotate-180" : "rotate-0"
|
|
408
|
+
)}
|
|
409
|
+
/>
|
|
410
|
+
</InputField.Button>
|
|
411
|
+
)}
|
|
482
412
|
</InputField.Root>
|
|
483
413
|
);
|
|
484
414
|
})
|
|
485
415
|
);
|
|
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
|
-
}
|