@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.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +10 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +217 -121
  5. package/dist/index.d.ts +217 -121
  6. package/dist/index.js +7435 -6796
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +5257 -4660
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +4 -5
  11. package/src/__tests__/combobox.test.ts +578 -0
  12. package/src/components/ActivityIndicator.tsx +4 -4
  13. package/src/components/AnimatePresence.tsx +5 -5
  14. package/src/components/Avatar.tsx +5 -2
  15. package/src/components/BaseToolbar.tsx +61 -0
  16. package/src/components/Button.tsx +1 -1
  17. package/src/components/Checkbox.tsx +6 -5
  18. package/src/components/Combobox.tsx +220 -324
  19. package/src/components/ComboboxMenu.tsx +88 -0
  20. package/src/components/Dialog.tsx +10 -6
  21. package/src/components/DimensionInput.tsx +4 -4
  22. package/src/components/FillPreviewBackground.tsx +51 -44
  23. package/src/components/FloatingWindow.tsx +5 -2
  24. package/src/components/GradientPicker.tsx +14 -14
  25. package/src/components/IconButton.tsx +6 -12
  26. package/src/components/Icons.tsx +3 -3
  27. package/src/components/InputField.tsx +145 -160
  28. package/src/components/Label.tsx +4 -4
  29. package/src/components/LabeledElementView.tsx +35 -41
  30. package/src/components/ListView.tsx +49 -39
  31. package/src/components/Message.tsx +75 -0
  32. package/src/components/Progress.tsx +2 -2
  33. package/src/components/ScrollArea.tsx +7 -5
  34. package/src/components/SegmentedControl.tsx +171 -0
  35. package/src/components/SelectMenu.tsx +61 -10
  36. package/src/components/Slider.tsx +5 -2
  37. package/src/components/Sortable.tsx +17 -27
  38. package/src/components/Spacer.tsx +28 -9
  39. package/src/components/Switch.tsx +8 -8
  40. package/src/components/TextArea.tsx +23 -13
  41. package/src/components/Toolbar.tsx +134 -0
  42. package/src/components/Tooltip.tsx +10 -7
  43. package/src/components/internal/Menu.tsx +5 -4
  44. package/src/contexts/DesignSystemConfiguration.tsx +15 -24
  45. package/src/contexts/DialogContext.tsx +137 -49
  46. package/src/contexts/ImageDataContext.tsx +6 -12
  47. package/src/index.css +1 -3
  48. package/src/index.tsx +12 -3
  49. package/src/utils/combobox.ts +369 -0
  50. package/tailwind.config.ts +1 -2
  51. package/src/components/RadioGroup.tsx +0 -100
  52. package/src/utils/completions.ts +0 -21
@@ -1,5 +1,4 @@
1
- import { Size } from "@noya-app/noya-geometry";
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
- CompletionItem,
18
- CompletionListItem,
19
- CompletionSectionHeader,
20
- } from "../utils/completions";
21
- import { fuzzyFilter, fuzzyTokenize } from "../utils/fuzzyScorer";
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 { IVirtualizedList, ListView } from "./ListView";
25
- import { Spacer } from "./Spacer";
25
+ import { ListView } from "./ListView";
26
26
  import { Small } from "./Text";
27
27
 
28
- function filterWithGroupedSections(
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: (CompletionItem | CompletionSectionHeader)[];
33
+ items: ComboboxItem[];
148
34
  onChange?: (value: string) => void;
149
- onHoverItem?: (item: CompletionItem | undefined) => void;
150
- onSelectItem?: (item: CompletionItem) => void;
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 InputFieldWithCompletionsRef {
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
- }: InputFieldWithCompletionsProps,
191
- forwardedRef: ForwardedRef<InputFieldWithCompletionsRef>
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
- const [isFocused, setIsFocused] = useState(false);
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
- const initialValueRef = useRef(initialValue);
240
-
241
- useLayoutEffect(() => {
242
- if (initialValueRef.current === initialValue) return;
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 [normalItems, sectionHeaderItems] = partition(
257
- filteredItems,
258
- (item) => item.type !== "sectionHeader"
259
- );
99
+ const { filter, selectedIndex, filteredItems } = state;
260
100
 
261
- const height = Math.min(
262
- ListView.calculateHeight(
263
- normalItems.length,
264
- sectionHeaderItems.length,
265
- "label"
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
- const didSubmit = lastSubmittedValueRef.current?.filter === filter;
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
- updateState({ selectedIndex: 0, filter: initialValue });
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, updateState]
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) => updateState({ selectedIndex: index }),
324
- [updateState]
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
- handleIndexChange(
335
- getNextIndex(
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
- const item = filteredItems[selectedIndex];
347
- if (item) {
348
- selectItem(item);
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
- handleChange(item.name);
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 && typeof ref === "object") {
366
- ref.current?.blur();
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 display = !filter && hideMenuWhenEmptyValue ? "none" : "flex";
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 = filteredItems[selectedIndex]?.name;
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
- handleChange(value);
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
- labelPosition="end"
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={`flex flex-col ${!filter && hideMenuWhenEmptyValue ? "none" : "flex"}`}
425
- style={{
426
- flex: `0 0 ${height}px`,
427
- }}
336
+ className={cx(
337
+ "flex-col",
338
+ shouldShowMenu && !readOnly ? "flex" : "hidden"
339
+ )}
428
340
  >
429
- {filteredItems.length > 0 ? (
430
- <CompletionMenu
341
+ {filteredItems.length > 0 && !loading ? (
342
+ <ComboboxMenu
431
343
  ref={listRef}
432
344
  items={filteredItems}
433
345
  selectedIndex={selectedIndex}
434
- onSelectItem={selectItem}
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-[100px] p-5 items-center justify-center">
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={isFocused && display !== "none"}
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 value={typeaheadValue} prefix={filter} />
389
+ <InputField.Typeahead
390
+ value={typeaheadValue}
391
+ prefix={filter}
392
+ isFocused={isFocused}
393
+ />
474
394
  )}
475
- {(!isFocused || !hideChildrenWhenFocused) && children}
476
- {loading && isFocused && (
395
+ {children}
396
+ {loading && (
477
397
  <InputField.Label>
478
398
  <ActivityIndicator />
479
399
  </InputField.Label>
480
400
  )}
481
- {label && <InputField.Label>{label}</InputField.Label>}
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
- }