@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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +16 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +268 -147
  5. package/dist/index.d.ts +268 -147
  6. package/dist/index.js +7618 -6908
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +6161 -5487
  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 +327 -337
  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 +59 -12
  41. package/src/components/Toolbar.tsx +129 -0
  42. package/src/components/Tooltip.tsx +10 -7
  43. package/src/components/WorkspaceLayout.tsx +145 -152
  44. package/src/components/internal/Menu.tsx +5 -4
  45. package/src/contexts/DesignSystemConfiguration.tsx +15 -24
  46. package/src/contexts/DialogContext.tsx +137 -49
  47. package/src/contexts/ImageDataContext.tsx +6 -12
  48. package/src/index.css +1 -3
  49. package/src/index.tsx +12 -3
  50. package/src/utils/combobox.ts +369 -0
  51. package/tailwind.config.ts +1 -2
  52. package/src/components/RadioGroup.tsx +0 -100
  53. 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,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
- 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 BaseComboboxProps = {
143
29
  id?: string;
144
30
  loading?: boolean;
145
- initialValue?: string;
146
31
  placeholder?: string;
147
- items: (CompletionItem | CompletionSectionHeader)[];
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 interface InputFieldWithCompletionsRef {
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
- }: InputFieldWithCompletionsProps,
191
- forwardedRef: ForwardedRef<InputFieldWithCompletionsRef>
91
+ readOnly = false,
92
+ tabBehavior = "select",
93
+ openMenuBehavior = "showFilteredItems",
94
+ onFocus,
95
+ onDeleteWhenEmpty,
96
+ ...rest
97
+ }: ComboboxProps,
98
+ forwardedRef: ForwardedRef<ComboboxRef>
192
99
  ) {
193
- 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
- }
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
- _setState(nextState);
232
- if (newState.filter !== undefined) {
233
- onChange?.(newState.filter);
234
- }
235
- },
236
- [items, onChange, onHoverItem, state]
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
- 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
- );
151
+ // Keep state in sync with items prop
152
+ useEffect(() => {
153
+ comboboxState.setItems(items);
154
+ }, [items, comboboxState]);
255
155
 
256
- const [normalItems, sectionHeaderItems] = partition(
257
- filteredItems,
258
- (item) => item.type !== "sectionHeader"
259
- );
156
+ const { filter, selectedIndex, filteredItems } = state;
260
157
 
261
- const height = Math.min(
262
- ListView.calculateHeight(
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
- const didSubmit = lastSubmittedValueRef.current?.filter === filter;
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
- updateState({ selectedIndex: 0, filter: initialValue });
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
- [initialValue, onFocus, updateState]
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) => updateState({ selectedIndex: index }),
324
- [updateState]
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
- handleIndexChange(
335
- getNextIndex(
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
- const item = filteredItems[selectedIndex];
347
- if (item) {
348
- selectItem(item);
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
- handleChange(item.name);
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 && typeof ref === "object") {
366
- ref.current?.blur();
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 display = !filter && hideMenuWhenEmptyValue ? "none" : "flex";
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
- handleChange(value);
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
- labelPosition="end"
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={`flex flex-col ${!filter && hideMenuWhenEmptyValue ? "none" : "flex"}`}
425
- style={{
426
- flex: `0 0 ${height}px`,
427
- }}
430
+ className={cx(
431
+ "flex-col",
432
+ shouldShowMenu && !readOnly ? "flex" : "hidden"
433
+ )}
428
434
  >
429
- {filteredItems.length > 0 ? (
430
- <CompletionMenu
435
+ {filteredItems.length > 0 && !loading ? (
436
+ <ComboboxMenu
431
437
  ref={listRef}
432
438
  items={filteredItems}
433
439
  selectedIndex={selectedIndex}
434
- onSelectItem={selectItem}
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-[100px] p-5 items-center justify-center">
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={isFocused && display !== "none"}
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 value={typeaheadValue} prefix={filter} />
483
+ <InputField.Typeahead
484
+ value={typeaheadValue}
485
+ prefix={filter}
486
+ isFocused={isFocused}
487
+ />
474
488
  )}
475
- {(!isFocused || !hideChildrenWhenFocused) && children}
476
- {loading && isFocused && (
489
+ {children}
490
+ {loading && (
477
491
  <InputField.Label>
478
492
  <ActivityIndicator />
479
493
  </InputField.Label>
480
494
  )}
481
- {label && <InputField.Label>{label}</InputField.Label>}
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
- }