@stack-spot/citric-react 0.42.0-beta.0 → 0.43.0

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 (209) hide show
  1. package/CHANGELOG.md +13 -13
  2. package/dist/citric.css +2926 -2920
  3. package/dist/components/Accordion.d.ts +1 -1
  4. package/dist/components/Accordion.js +1 -1
  5. package/dist/components/Alert.d.ts +1 -1
  6. package/dist/components/Alert.js +1 -1
  7. package/dist/components/AsyncContent.d.ts +1 -1
  8. package/dist/components/AsyncContent.js +1 -1
  9. package/dist/components/Autocomplete.d.ts +370 -0
  10. package/dist/components/Autocomplete.d.ts.map +1 -0
  11. package/dist/components/{Autocomplete/Autocomplete.js → Autocomplete.js} +163 -98
  12. package/dist/components/Autocomplete.js.map +1 -0
  13. package/dist/components/Avatar.d.ts +1 -1
  14. package/dist/components/Avatar.js +1 -1
  15. package/dist/components/AvatarGroup.d.ts +1 -1
  16. package/dist/components/AvatarGroup.js +1 -1
  17. package/dist/components/Badge.d.ts +1 -1
  18. package/dist/components/Badge.js +1 -1
  19. package/dist/components/Blockquote.d.ts +1 -1
  20. package/dist/components/Blockquote.js +1 -1
  21. package/dist/components/Breadcrumb.d.ts +1 -1
  22. package/dist/components/Breadcrumb.js +1 -1
  23. package/dist/components/Button.d.ts +1 -1
  24. package/dist/components/Button.js +1 -1
  25. package/dist/components/ButtonLink.d.ts +1 -1
  26. package/dist/components/ButtonLink.js +1 -1
  27. package/dist/components/Card.d.ts +1 -1
  28. package/dist/components/Card.js +1 -1
  29. package/dist/components/Checkbox.d.ts +1 -1
  30. package/dist/components/Checkbox.d.ts.map +1 -1
  31. package/dist/components/Checkbox.js +2 -2
  32. package/dist/components/Checkbox.js.map +1 -1
  33. package/dist/components/CheckboxGroup.d.ts +1 -1
  34. package/dist/components/CheckboxGroup.js +1 -1
  35. package/dist/components/Circle.d.ts +1 -1
  36. package/dist/components/Circle.js +1 -1
  37. package/dist/components/Divider.d.ts +1 -1
  38. package/dist/components/Divider.js +1 -1
  39. package/dist/components/ErrorBoundary.d.ts +1 -1
  40. package/dist/components/ErrorBoundary.js +1 -1
  41. package/dist/components/ErrorMessage.d.ts +1 -1
  42. package/dist/components/ErrorMessage.js +1 -1
  43. package/dist/components/FallbackBoundary.d.ts +1 -1
  44. package/dist/components/FallbackBoundary.js +1 -1
  45. package/dist/components/Favorite.d.ts +1 -1
  46. package/dist/components/Favorite.js +1 -1
  47. package/dist/components/FieldGroup.d.ts +1 -1
  48. package/dist/components/FieldGroup.js +1 -1
  49. package/dist/components/Form.d.ts +2 -2
  50. package/dist/components/Form.js +1 -1
  51. package/dist/components/FormGroup.d.ts +1 -1
  52. package/dist/components/FormGroup.js +1 -1
  53. package/dist/components/Icon.d.ts +1 -1
  54. package/dist/components/Icon.js +1 -1
  55. package/dist/components/IconBox.d.ts +3 -3
  56. package/dist/components/IconBox.js +1 -1
  57. package/dist/components/ImageBox.d.ts +3 -3
  58. package/dist/components/ImageBox.js +1 -1
  59. package/dist/components/ImageWithFallback.d.ts +1 -1
  60. package/dist/components/ImageWithFallback.js +1 -1
  61. package/dist/components/Input.d.ts +1 -1
  62. package/dist/components/Input.js +1 -1
  63. package/dist/components/Link.d.ts +1 -1
  64. package/dist/components/Link.js +1 -1
  65. package/dist/components/LoadingPanel.d.ts +1 -1
  66. package/dist/components/LoadingPanel.js +1 -1
  67. package/dist/components/MenuOverlay/Menu.d.ts +1 -1
  68. package/dist/components/MenuOverlay/Menu.js +1 -1
  69. package/dist/components/MenuOverlay/index.d.ts +1 -1
  70. package/dist/components/MenuOverlay/index.js +1 -1
  71. package/dist/components/Overlay/index.d.ts +1 -1
  72. package/dist/components/Overlay/index.js +1 -1
  73. package/dist/components/Pagination.d.ts +1 -1
  74. package/dist/components/Pagination.js +1 -1
  75. package/dist/components/ProgressBar.d.ts +1 -1
  76. package/dist/components/ProgressBar.js +1 -1
  77. package/dist/components/ProgressCircular.d.ts +1 -1
  78. package/dist/components/ProgressCircular.js +1 -1
  79. package/dist/components/RadioGroup.d.ts +1 -1
  80. package/dist/components/RadioGroup.js +1 -1
  81. package/dist/components/Rating.d.ts +1 -1
  82. package/dist/components/Rating.js +1 -1
  83. package/dist/components/Select/MultiSelect.d.ts +1 -1
  84. package/dist/components/Select/MultiSelect.js +1 -1
  85. package/dist/components/Select/RichSelect.d.ts +1 -1
  86. package/dist/components/Select/RichSelect.js +1 -1
  87. package/dist/components/Select/SimpleSelect.d.ts +1 -1
  88. package/dist/components/Select/SimpleSelect.js +1 -1
  89. package/dist/components/Select/index.d.ts +1 -1
  90. package/dist/components/Select/index.js +1 -1
  91. package/dist/components/SelectBox.d.ts +9 -1
  92. package/dist/components/SelectBox.d.ts.map +1 -1
  93. package/dist/components/SelectBox.js +6 -5
  94. package/dist/components/SelectBox.js.map +1 -1
  95. package/dist/components/Skeleton.d.ts +1 -1
  96. package/dist/components/Skeleton.js +1 -1
  97. package/dist/components/Slider.d.ts +1 -1
  98. package/dist/components/Slider.js +1 -1
  99. package/dist/components/SmartTable.d.ts +1 -1
  100. package/dist/components/SmartTable.js +1 -1
  101. package/dist/components/Stepper.d.ts +1 -1
  102. package/dist/components/Stepper.js +1 -1
  103. package/dist/components/Table.d.ts +3 -3
  104. package/dist/components/Table.js +1 -1
  105. package/dist/components/Tabs/index.d.ts +1 -1
  106. package/dist/components/Tabs/index.js +1 -1
  107. package/dist/components/Textarea.d.ts +1 -1
  108. package/dist/components/Textarea.js +1 -1
  109. package/dist/components/Tooltip.d.ts +1 -1
  110. package/dist/components/Tooltip.js +1 -1
  111. package/dist/context/CitricProvider.d.ts +1 -1
  112. package/dist/context/CitricProvider.js +1 -1
  113. package/dist/index.d.ts +2 -1
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +2 -1
  116. package/dist/index.js.map +1 -1
  117. package/dist/overlay.js +1 -1
  118. package/dist/theme.css +415 -415
  119. package/dist/utils/css.js +1 -1
  120. package/dist/utils/css.js.map +1 -1
  121. package/package.json +1 -1
  122. package/scripts/build-css.ts +49 -49
  123. package/src/components/Accordion.tsx +130 -130
  124. package/src/components/Alert.tsx +24 -24
  125. package/src/components/AsyncContent.tsx +75 -75
  126. package/src/components/{Autocomplete/Autocomplete.tsx → Autocomplete.tsx} +403 -159
  127. package/src/components/Avatar.tsx +45 -45
  128. package/src/components/AvatarGroup.tsx +49 -49
  129. package/src/components/Badge.tsx +47 -47
  130. package/src/components/Blockquote.tsx +18 -18
  131. package/src/components/Breadcrumb.tsx +33 -33
  132. package/src/components/Button.tsx +105 -105
  133. package/src/components/ButtonLink.tsx +45 -45
  134. package/src/components/Card.tsx +68 -68
  135. package/src/components/Checkbox.tsx +51 -52
  136. package/src/components/CheckboxGroup.tsx +153 -153
  137. package/src/components/Circle.tsx +43 -43
  138. package/src/components/CitricComponent.ts +47 -47
  139. package/src/components/Divider.tsx +24 -24
  140. package/src/components/ErrorBoundary.tsx +75 -75
  141. package/src/components/ErrorMessage.tsx +11 -11
  142. package/src/components/FallbackBoundary.tsx +40 -40
  143. package/src/components/Favorite.tsx +57 -57
  144. package/src/components/FieldGroup.tsx +46 -46
  145. package/src/components/Form.tsx +36 -36
  146. package/src/components/FormGroup.tsx +57 -57
  147. package/src/components/Icon.tsx +35 -35
  148. package/src/components/IconBox.tsx +134 -134
  149. package/src/components/ImageBox.tsx +125 -125
  150. package/src/components/ImageWithFallback.tsx +65 -65
  151. package/src/components/Input.tsx +49 -49
  152. package/src/components/Link.tsx +55 -55
  153. package/src/components/LoadingPanel.tsx +12 -12
  154. package/src/components/MenuOverlay/Menu.tsx +158 -158
  155. package/src/components/MenuOverlay/context.ts +20 -20
  156. package/src/components/MenuOverlay/index.tsx +55 -55
  157. package/src/components/MenuOverlay/keyboard.ts +60 -60
  158. package/src/components/MenuOverlay/types.ts +171 -171
  159. package/src/components/Overlay/context.ts +10 -10
  160. package/src/components/Overlay/index.tsx +182 -182
  161. package/src/components/Overlay/types.ts +75 -75
  162. package/src/components/Pagination.tsx +133 -133
  163. package/src/components/ProgressBar.tsx +45 -45
  164. package/src/components/ProgressCircular.tsx +45 -45
  165. package/src/components/RadioGroup.tsx +147 -147
  166. package/src/components/Rating.tsx +98 -98
  167. package/src/components/Select/MultiSelect.tsx +217 -217
  168. package/src/components/Select/RichSelect.tsx +128 -128
  169. package/src/components/Select/SimpleSelect.tsx +73 -73
  170. package/src/components/Select/hooks.ts +133 -133
  171. package/src/components/Select/index.tsx +35 -35
  172. package/src/components/Select/types.ts +134 -134
  173. package/src/components/SelectBox.tsx +181 -167
  174. package/src/components/Skeleton.tsx +53 -53
  175. package/src/components/Slider.tsx +89 -89
  176. package/src/components/SmartTable.tsx +227 -227
  177. package/src/components/Stepper.tsx +163 -163
  178. package/src/components/Table.tsx +234 -234
  179. package/src/components/Tabs/TabController.ts +54 -54
  180. package/src/components/Tabs/index.tsx +106 -106
  181. package/src/components/Tabs/types.ts +67 -67
  182. package/src/components/Tabs/utils.ts +6 -6
  183. package/src/components/Text.ts +111 -111
  184. package/src/components/Textarea.tsx +27 -27
  185. package/src/components/Tooltip.tsx +83 -83
  186. package/src/components/layout.tsx +101 -101
  187. package/src/context/CitricContext.tsx +4 -4
  188. package/src/context/CitricProvider.tsx +14 -14
  189. package/src/context/hooks.ts +6 -6
  190. package/src/index.ts +59 -59
  191. package/src/overlay.ts +348 -348
  192. package/src/types.ts +235 -235
  193. package/src/utils/ValueController.ts +28 -28
  194. package/src/utils/acessibility.ts +92 -92
  195. package/src/utils/checkbox.ts +121 -121
  196. package/src/utils/css.ts +119 -119
  197. package/src/utils/options.ts +9 -9
  198. package/src/utils/radio.ts +93 -93
  199. package/src/utils/react.ts +6 -6
  200. package/src/utils/time.ts +5 -5
  201. package/tsconfig.json +10 -10
  202. package/dist/components/Autocomplete/Autocomplete.d.ts +0 -211
  203. package/dist/components/Autocomplete/Autocomplete.d.ts.map +0 -1
  204. package/dist/components/Autocomplete/Autocomplete.js.map +0 -1
  205. package/dist/components/Autocomplete/index.d.ts +0 -3
  206. package/dist/components/Autocomplete/index.d.ts.map +0 -1
  207. package/dist/components/Autocomplete/index.js +0 -2
  208. package/dist/components/Autocomplete/index.js.map +0 -1
  209. package/src/components/Autocomplete/index.ts +0 -3
@@ -1,16 +1,36 @@
1
1
  import { ColorPaletteName, ColorSchemeName, listToClass } from '@stack-spot/portal-theme'
2
2
  import { useTranslate } from '@stack-spot/portal-translate'
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
- import { applyCSSVariable } from '../../utils/css'
5
- import { defaultRenderKey, defaultRenderLabel } from '../../utils/options'
6
- import { withRef } from '../../utils/react'
7
- import { Badge } from '../Badge'
8
- import { Checkbox } from '../Checkbox'
9
- import { CitricComponent } from '../CitricComponent'
10
- import { IconButton } from '../IconBox'
11
- import { ProgressCircular } from '../ProgressCircular'
12
- import { useDisabledEffect, useFocusEffect } from '../Select/hooks'
13
- import { Row } from '../layout'
4
+ import { applyCSSVariable } from '../utils/css'
5
+ import { defaultRenderKey, defaultRenderLabel } from '../utils/options'
6
+ import { withRef } from '../utils/react'
7
+ import { Badge } from './Badge'
8
+ import { Checkbox } from './Checkbox'
9
+ import { CitricComponent } from './CitricComponent'
10
+ import { IconButton } from './IconBox'
11
+ import { ProgressCircular } from './ProgressCircular'
12
+ import { useDisabledEffect, useFocusEffect } from './Select/hooks'
13
+ import { Row } from './layout'
14
+
15
+ export interface CustomSelectedTagsConfig {
16
+ /**
17
+ * Color scheme for the tags (badges).
18
+ */
19
+ colorScheme?: ColorSchemeName,
20
+ /**
21
+ * Color palette for the tags (badges).
22
+ */
23
+ colorPalette?: ColorPaletteName,
24
+ /**
25
+ * Appearance of the tags (badges).
26
+ * @default 'circle'
27
+ */
28
+ appearance?: 'square' | 'circle',
29
+ /**
30
+ * Maximum number of tags to show before displaying "+N more".
31
+ */
32
+ maxItems?: number,
33
+ }
14
34
 
15
35
  export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
16
36
  /**
@@ -19,14 +39,37 @@ export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
19
39
  options: T[],
20
40
  /**
21
41
  * The current value(s) selected.
22
- * - Single selection: T | null
42
+ * - Single selection: T | undefined
23
43
  * - Multiple selection: T[]
24
44
  */
25
- value: Multiple extends true ? T[] : (T | null),
45
+ value: Multiple extends true ? T[] : (T | undefined),
26
46
  /**
27
- * Callback fired when the value changes.
47
+ * Callback fired when the value changes (user selects/removes an option).
48
+ * This is the main callback for getting the final selected value(s).
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * // Single selection
53
+ * <Autocomplete
54
+ * value={user}
55
+ * onChange={(selectedUser) => {
56
+ * console.log('Selected:', selectedUser)
57
+ * setUser(selectedUser)
58
+ * }}
59
+ * />
60
+ *
61
+ * // Multiple selection
62
+ * <Autocomplete
63
+ * multiple
64
+ * value={selectedUsers}
65
+ * onChange={(users) => {
66
+ * console.log('Selected users:', users)
67
+ * setSelectedUsers(users)
68
+ * }}
69
+ * />
70
+ * ```
28
71
  */
29
- onChange: Multiple extends true ? (value: T[]) => void : (value: T | null) => void,
72
+ onChange: Multiple extends true ? (value: T[]) => void : (value: T | undefined) => void,
30
73
  /**
31
74
  * If true, enables multiple selection mode.
32
75
  * @default false
@@ -50,20 +93,70 @@ export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
50
93
  onCreate?: (inputValue: string) => void,
51
94
  /**
52
95
  * Function to create a new option object from the input value.
53
- * Required when creatable is true without onCreate and working with objects.
96
+ * Used when creatable is true and onCreate is NOT defined.
97
+ * Allows automatic option creation on Enter key.
98
+ *
99
+ * Note: This prop has no effect when onCreate is defined, as onCreate takes precedence.
100
+ *
101
+ * @param inputValue - The text typed by the user
102
+ * @returns A new option object
54
103
  *
55
104
  * @example
56
105
  * ```tsx
57
- * getOptionFromInput={(inputValue) => ({ id: Date.now(), name: inputValue })}
106
+ * // Auto-create tags on Enter
107
+ * <Autocomplete
108
+ * multiple
109
+ * creatable
110
+ * freeSolo
111
+ * getOptionFromInput={(text) => ({
112
+ * id: Date.now(),
113
+ * name: text,
114
+ * isCustom: true
115
+ * })}
116
+ * />
58
117
  * ```
59
118
  */
60
119
  getOptionFromInput?: (inputValue: string) => T,
61
120
  /**
62
- * The input value (controlled).
121
+ * The input value (controlled mode).
122
+ * Use this when you need full control over the input text.
123
+ * Usually used with onInputChange for controlled components.
124
+ *
125
+ * @example
126
+ * ```tsx
127
+ * const [inputValue, setInputValue] = useState('')
128
+ *
129
+ * <Autocomplete
130
+ * inputValue={inputValue}
131
+ * onInputChange={setInputValue}
132
+ * options={options}
133
+ * />
134
+ * ```
63
135
  */
64
136
  inputValue?: string,
65
137
  /**
66
- * Callback fired when the input value changes.
138
+ * Callback fired when the input text changes (user types).
139
+ * Use this to control the input value or perform side effects like API calls.
140
+ * Different from onChange which fires when an option is selected.
141
+ *
142
+ * @param value - The current text in the input field
143
+ *
144
+ * @example
145
+ * ```tsx
146
+ * // Debounced API search
147
+ * <Autocomplete
148
+ * onInputChange={(text) => {
149
+ * console.log('User typed:', text)
150
+ * debouncedSearch(text)
151
+ * }}
152
+ * />
153
+ *
154
+ * // Controlled input
155
+ * <Autocomplete
156
+ * inputValue={inputValue}
157
+ * onInputChange={(text) => setInputValue(text.toUpperCase())}
158
+ * />
159
+ * ```
67
160
  */
68
161
  onInputChange?: (value: string) => void,
69
162
  /**
@@ -74,18 +167,76 @@ export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
74
167
  /**
75
168
  * A function to generate a unique key for each option.
76
169
  */
77
- renderKey?: (option: T) => string | number,
170
+ renderKey?: (option: T) => string | number | undefined,
78
171
  /**
79
172
  * A function to render an option in the dropdown.
80
173
  */
81
174
  renderOption?: (option: T) => React.ReactNode,
82
175
  /**
83
- * A function to render a tag in multiple mode.
176
+ * Custom function to render the selected values display area in multiple mode.
177
+ * When defined, gives you full control over how selected values are displayed.
178
+ * The customSelectedTags prop has no effect when this is defined.
179
+ *
180
+ * @param values - Array of selected options
181
+ * @param onRemove - Function to call when user wants to remove an option
182
+ * @returns React element to display selected values
183
+ *
184
+ * @example
185
+ * ```tsx
186
+ * <Autocomplete
187
+ * multiple
188
+ * renderSelected={(values, onRemove) => (
189
+ * <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
190
+ * {values.map(user => (
191
+ * <div key={user.id} style={{ background: '#e3f2fd', padding: '4px 8px', borderRadius: '12px' }}>
192
+ * <Avatar src={user.avatar} size="xs" />
193
+ * {user.name}
194
+ * <button onClick={() => onRemove(user)}>×</button>
195
+ * </div>
196
+ * ))}
197
+ * </div>
198
+ * )}
199
+ * />
200
+ * ```
201
+ */
202
+ renderSelected?: (values: T[], onRemove: (option: T) => void) => React.ReactElement,
203
+ /**
204
+ * Configuration for the default selected tags appearance in multiple mode.
205
+ * Has no effect when renderSelected is defined.
206
+ *
207
+ * @example
208
+ * ```tsx
209
+ * <Autocomplete
210
+ * multiple
211
+ * customSelectedTags={{
212
+ * colorScheme: 'primary',
213
+ * appearance: 'square',
214
+ * maxItems: 3
215
+ * }}
216
+ * />
217
+ * ```
84
218
  */
85
- renderTag?: (option: T, onRemove: () => void) => React.ReactNode,
219
+ customSelectedTags?: CustomSelectedTagsConfig,
86
220
  /**
87
221
  * Custom filter function for options.
88
- * @default filters by label includes input (case insensitive)
222
+ * When not set, the filter will use the text returned by renderLabel (case-insensitive includes).
223
+ *
224
+ * @param options - The full list of options
225
+ * @param inputValue - The current input text
226
+ * @returns Filtered array of options
227
+ *
228
+ * @example
229
+ * ```tsx
230
+ * // Search by name OR email
231
+ * <Autocomplete
232
+ * filterOptions={(options, input) =>
233
+ * options.filter(user =>
234
+ * user.name.toLowerCase().includes(input.toLowerCase()) ||
235
+ * user.email.toLowerCase().includes(input.toLowerCase())
236
+ * )
237
+ * }
238
+ * />
239
+ * ```
89
240
  */
90
241
  filterOptions?: (options: T[], inputValue: string) => T[],
91
242
  /**
@@ -106,11 +257,6 @@ export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
106
257
  * Maximum height for the dropdown panel in pixels.
107
258
  */
108
259
  maxHeight?: number,
109
- /**
110
- * Maximum number of tags to show before truncating.
111
- * Only applies when multiple is true.
112
- */
113
- maxTagsToShow?: number,
114
260
  /**
115
261
  * If true, automatically highlights the first option.
116
262
  * @default false
@@ -119,7 +265,30 @@ export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
119
265
  /**
120
266
  * If true, clears the input value when an option is selected.
121
267
  * Only applies when multiple is true.
122
- * @default true (for multiple)
268
+ * When false, the input keeps the text after selection, useful for adding multiple similar items quickly.
269
+ *
270
+ * Note: Adding multiple tags with similar prefixes when clearOnSelect={false}, you can select "React",
271
+ * then easily select "React Native"
272
+ * without retyping "React" from scratch
273
+ * @default true (for multiple mode)
274
+ *
275
+ * @example
276
+ * ```tsx
277
+ * // Clear input after each selection (default)
278
+ * <Autocomplete multiple clearOnSelect />
279
+ *
280
+ * // Keep input text after selection
281
+ * const [tags, setTags] = useState<Tag[]>([])
282
+ *
283
+ * <Autocomplete
284
+ * multiple
285
+ * clearOnSelect={false}
286
+ * value={tags}
287
+ * onChange={setTags}
288
+ * options={availableTags}
289
+ * renderLabel={tag => tag.name}
290
+ * />
291
+ * ```
123
292
  */
124
293
  clearOnSelect?: boolean,
125
294
  /**
@@ -127,10 +296,6 @@ export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
127
296
  * @default true
128
297
  */
129
298
  openOnFocus?: boolean,
130
- /**
131
- * Callback fired when an option is selected (before onChange).
132
- */
133
- onSelect?: (option: T | null) => void,
134
299
  /**
135
300
  * Text to display when no options are available.
136
301
  */
@@ -159,24 +324,19 @@ export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
159
324
  */
160
325
  scrollEndMargin?: number,
161
326
  /**
162
- * Color scheme for the tags (badges) in multiple mode.
163
- * @example 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'
327
+ * Color scheme for the autocomplete component.
328
+ * Applies the theme's color scheme to the component root.
164
329
  */
165
- tagColorScheme?: ColorSchemeName,
330
+ colorScheme?: ColorSchemeName,
166
331
  /**
167
- * Color palette for the tags (badges) in multiple mode.
168
- * @example 'blue' | 'green' | 'red' | 'yellow' | 'purple'
169
- */
170
- tagColorPalette?: ColorPaletteName,
171
- /**
172
- * Appearance of the tags (badges) in multiple mode.
173
- * @default 'circle'
332
+ * The id attribute for the input element.
333
+ * Useful for associating with a label element.
174
334
  */
175
- tagAppearance?: 'square' | 'circle',
335
+ id?: string,
176
336
  }
177
337
 
178
338
  export type AutocompleteProps<T, Multiple extends boolean = false> =
179
- Omit<React.JSX.IntrinsicElements['div'], 'ref' | 'onChange' | 'onSelect'> &
339
+ Omit<React.JSX.IntrinsicElements['div'], 'ref' | 'onChange'> &
180
340
  BaseAutocompleteProps<T, Multiple>
181
341
 
182
342
  /**
@@ -239,24 +399,22 @@ export const Autocomplete = withRef(
239
399
  renderLabel = defaultRenderLabel,
240
400
  renderKey = defaultRenderKey as (option: T) => string | number,
241
401
  renderOption,
242
- renderTag,
402
+ renderSelected,
403
+ customSelectedTags,
243
404
  filterOptions,
244
405
  loading = false,
245
406
  disabled = false,
246
407
  placeholder,
247
408
  maxHeight,
248
- maxTagsToShow,
249
409
  autoHighlight = false,
250
410
  clearOnSelect = multiple,
251
411
  openOnFocus = true,
252
- onSelect,
253
412
  noOptionsText,
254
413
  loadingText,
255
414
  onScrollEnd,
256
415
  scrollEndMargin = 200,
257
- tagColorScheme,
258
- tagColorPalette,
259
- tagAppearance = 'circle',
416
+ colorScheme,
417
+ id,
260
418
  style,
261
419
  className,
262
420
  ...props
@@ -265,6 +423,7 @@ export const Autocomplete = withRef(
265
423
  const _element = useRef<HTMLDivElement | null>(null)
266
424
  const inputRef = useRef<HTMLInputElement | null>(null)
267
425
  const dropdownRef = useRef<HTMLDivElement | null>(null)
426
+ const isNavigatingWithKeyboard = useRef(false)
268
427
  const element = (ref as React.RefObject<HTMLDivElement>) ?? _element
269
428
 
270
429
  const [open, setOpen] = useState(false)
@@ -306,13 +465,18 @@ export const Autocomplete = withRef(
306
465
  const defaultFilter = useCallback((opts: T[], input: string) => {
307
466
  if (!input) return opts
308
467
  return opts.filter(option =>
309
- renderLabel(option).toLowerCase().includes(input.toLowerCase()),
468
+ renderLabel(option)?.toLowerCase()?.includes(input?.toLowerCase()),
310
469
  )
311
470
  }, [renderLabel])
312
471
 
313
472
  const filter = filterOptions ?? defaultFilter
314
473
 
315
- const filteredOptions = useMemo(() => filter(options, inputValue), [options, inputValue, filter])
474
+ const filteredOptions = useMemo(() => {
475
+ if (!multiple && value && renderLabel(value as T) === inputValue) {
476
+ return options
477
+ }
478
+ return filter(options, inputValue)
479
+ }, [options, inputValue, filter, multiple, value, renderLabel])
316
480
 
317
481
  const showCreateOption = useMemo(() => {
318
482
  if (!creatable || !onCreate || !inputValue.trim()) return false
@@ -343,8 +507,6 @@ export const Autocomplete = withRef(
343
507
  }, [value, multiple, renderKey])
344
508
 
345
509
  const handleSelect = useCallback((option: T) => {
346
- if (onSelect) onSelect(option)
347
-
348
510
  if (multiple) {
349
511
  const currentValue = value as T[]
350
512
  const isAlreadySelected = currentValue.some(v => renderKey(v) === renderKey(option))
@@ -364,7 +526,7 @@ export const Autocomplete = withRef(
364
526
  setInputValue(renderLabel(option))
365
527
  setOpen(false)
366
528
  }
367
- }, [multiple, value, onChange, renderKey, renderLabel, clearOnSelect, setInputValue, onSelect])
529
+ }, [multiple, value, onChange, renderKey, clearOnSelect, setInputValue, renderLabel])
368
530
 
369
531
  const handleRemoveTag = useCallback((optionToRemove: T) => {
370
532
  if (!multiple) return
@@ -384,6 +546,10 @@ export const Autocomplete = withRef(
384
546
  setFocused(true)
385
547
  if (openOnFocus) {
386
548
  setOpen(true)
549
+
550
+ if (autoHighlight && filteredOptions.length > 0) {
551
+ setHighlightedIndex(0)
552
+ }
387
553
  }
388
554
  }
389
555
 
@@ -411,17 +577,114 @@ export const Autocomplete = withRef(
411
577
  handleSelect(exactMatch)
412
578
  }
413
579
  }
580
+ } else if (!multiple && inputValue) {
581
+ const exactMatch = options.find(o =>
582
+ renderLabel(o).toLowerCase() === inputValue.toLowerCase(),
583
+ )
584
+
585
+ if (exactMatch) {
586
+ handleSelect(exactMatch)
587
+ } else {
588
+ if (value) {
589
+ setInputValue(renderLabel(value as T))
590
+ } else {
591
+ setInputValue('')
592
+ }
593
+ }
414
594
  }
415
595
  }
416
596
 
597
+ const handleCreateNewOption = useCallback(() => {
598
+ if (!inputValue.trim()) return false
599
+
600
+ if (onCreate) {
601
+ handleCreate()
602
+ return true
603
+ }
604
+
605
+ if (freeSolo && getOptionFromInput) {
606
+ const newOption = getOptionFromInput(inputValue.trim())
607
+ if (multiple) {
608
+ const currentValue = value as T[]
609
+ const isDuplicate = currentValue.some(v => renderKey(v) === renderKey(newOption))
610
+ if (!isDuplicate) {
611
+ (onChange as (value: T[]) => void)([...currentValue, newOption])
612
+ }
613
+ setInputValue('')
614
+ } else {
615
+ (onChange as (value: T | null) => void)(newOption)
616
+ setInputValue(renderLabel(newOption))
617
+ setOpen(false)
618
+ }
619
+ return true
620
+ }
621
+
622
+ if (freeSolo) {
623
+ if (multiple) {
624
+ const currentValue = value as T[]
625
+ const inputAsOption = inputValue as unknown as T
626
+ const isDuplicate = currentValue.some(v => renderLabel(v).toLowerCase() === inputValue.toLowerCase())
627
+ if (!isDuplicate) {
628
+ (onChange as (value: T[]) => void)([...currentValue, inputAsOption])
629
+ }
630
+ setInputValue('')
631
+ } else {
632
+ (onChange as (value: T | null) => void)(inputValue as unknown as T)
633
+ setOpen(false)
634
+ }
635
+ return true
636
+ }
637
+
638
+ return false
639
+ }, [onCreate, handleCreate, freeSolo, getOptionFromInput, inputValue, multiple, value, renderKey, onChange, setInputValue, renderLabel])
640
+
641
+ const handleEnterKey = useCallback(() => {
642
+ if (open && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
643
+ handleSelect(filteredOptions[highlightedIndex])
644
+ return
645
+ }
646
+
647
+ if (!open && filteredOptions.length === 1) {
648
+ handleSelect(filteredOptions[0])
649
+ return
650
+ }
651
+
652
+ if (creatable && handleCreateNewOption()) {
653
+ return
654
+ }
655
+
656
+ if (freeSolo && inputValue && !multiple) {
657
+ const exactMatch = options.find(o =>
658
+ renderLabel(o).toLowerCase() === inputValue.toLowerCase(),
659
+ )
660
+ if (exactMatch) {
661
+ handleSelect(exactMatch)
662
+ }
663
+ }
664
+ }, [
665
+ open,
666
+ highlightedIndex,
667
+ filteredOptions,
668
+ handleSelect,
669
+ creatable,
670
+ handleCreateNewOption,
671
+ freeSolo,
672
+ inputValue,
673
+ multiple,
674
+ options,
675
+ renderLabel,
676
+ ])
677
+
417
678
  const handleKeyDown = (e: React.KeyboardEvent) => {
418
679
  if (disabled) return
419
680
 
420
681
  switch (e.key) {
421
682
  case 'ArrowDown':
422
683
  e.preventDefault()
684
+ isNavigatingWithKeyboard.current = true
423
685
  if (!open) {
424
686
  setOpen(true)
687
+ setHighlightedIndex(0)
425
688
  } else {
426
689
  setHighlightedIndex(prev =>
427
690
  prev < filteredOptions.length - 1 ? prev + 1 : prev,
@@ -431,6 +694,7 @@ export const Autocomplete = withRef(
431
694
 
432
695
  case 'ArrowUp':
433
696
  e.preventDefault()
697
+ isNavigatingWithKeyboard.current = true
434
698
  if (open) {
435
699
  setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0)
436
700
  }
@@ -438,47 +702,7 @@ export const Autocomplete = withRef(
438
702
 
439
703
  case 'Enter':
440
704
  e.preventDefault()
441
- if (open && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
442
- handleSelect(filteredOptions[highlightedIndex])
443
- } else if (creatable && inputValue.trim()) {
444
- if (onCreate) {
445
- handleCreate()
446
- } else if (freeSolo && getOptionFromInput) {
447
- const newOption = getOptionFromInput(inputValue.trim())
448
- if (multiple) {
449
- const currentValue = value as T[]
450
- const isDuplicate = currentValue.some(v => renderKey(v) === renderKey(newOption))
451
- if (!isDuplicate) {
452
- (onChange as (value: T[]) => void)([...currentValue, newOption])
453
- }
454
- setInputValue('')
455
- } else {
456
- (onChange as (value: T | null) => void)(newOption)
457
- setInputValue(renderLabel(newOption))
458
- setOpen(false)
459
- }
460
- } else if (freeSolo) {
461
- if (multiple) {
462
- const currentValue = value as T[]
463
- const inputAsOption = inputValue as unknown as T
464
- const isDuplicate = currentValue.some(v => renderLabel(v).toLowerCase() === inputValue.toLowerCase())
465
- if (!isDuplicate) {
466
- (onChange as (value: T[]) => void)([...currentValue, inputAsOption])
467
- }
468
- setInputValue('')
469
- } else {
470
- (onChange as (value: T | null) => void)(inputValue as unknown as T)
471
- setOpen(false)
472
- }
473
- }
474
- } else if (freeSolo && inputValue && !multiple) {
475
- const exactMatch = options.find(o =>
476
- renderLabel(o).toLowerCase() === inputValue.toLowerCase(),
477
- )
478
- if (exactMatch) {
479
- handleSelect(exactMatch)
480
- }
481
- }
705
+ handleEnterKey()
482
706
  break
483
707
 
484
708
  case 'Escape':
@@ -493,6 +717,8 @@ export const Autocomplete = withRef(
493
717
  if (multiple && !inputValue && (value as T[]).length > 0) {
494
718
  const lastTag = (value as T[])[(value as T[]).length - 1]
495
719
  handleRemoveTag(lastTag)
720
+ } else if (!multiple && !inputValue && value) {
721
+ (onChange as (value: T | null) => void)(null)
496
722
  }
497
723
  break
498
724
 
@@ -513,17 +739,14 @@ export const Autocomplete = withRef(
513
739
  }
514
740
  }
515
741
 
516
- useEffect(() => {
517
- if (!multiple && value && !focused) {
518
- setInternalInputValue(renderLabel(value as T))
519
- }
520
- }, [value, multiple, renderLabel, focused])
521
-
522
- useEffect(() => {
523
- if (autoHighlight && open && filteredOptions.length > 0) {
524
- setHighlightedIndex(0)
742
+ const handleFocusAndOpen = () => {
743
+ if (disabled) return
744
+ setFocused(true)
745
+ inputRef.current?.focus()
746
+ if (openOnFocus) {
747
+ setOpen(true)
525
748
  }
526
- }, [autoHighlight, open, filteredOptions.length])
749
+ }
527
750
 
528
751
  useEffect(() => {
529
752
  if (highlightedIndex < 0 || !open) return
@@ -574,50 +797,53 @@ export const Autocomplete = withRef(
574
797
  if (!multiple || (value as T[]).length === 0) return null
575
798
 
576
799
  const tags = value as T[]
577
- const visibleTags = maxTagsToShow && tags.length > maxTagsToShow
578
- ? tags.slice(0, maxTagsToShow)
800
+
801
+ if (renderSelected) {
802
+ return renderSelected(tags, handleRemoveTag)
803
+ }
804
+
805
+ const config = customSelectedTags || {}
806
+ const maxItems = config.maxItems
807
+ const visibleTags = maxItems && tags.length > maxItems
808
+ ? tags.slice(0, maxItems)
579
809
  : tags
580
- const remainingCount = maxTagsToShow && tags.length > maxTagsToShow
581
- ? tags.length - maxTagsToShow
810
+ const remainingCount = maxItems && tags.length > maxItems
811
+ ? tags.length - maxItems
582
812
  : 0
583
813
 
584
814
  return (
585
815
  <>
586
- {visibleTags.map(tag => {
587
- if (renderTag) {
588
- return renderTag(tag, () => handleRemoveTag(tag))
589
- }
590
- return (
591
- <Badge
592
- key={renderKey(tag)}
593
- colorScheme={tagColorScheme}
594
- colorPalette={tagColorPalette}
595
- appearance={tagAppearance}
596
- >
597
- {renderLabel(tag)}
598
- {!disabled && (
599
- <IconButton
600
- icon="Times"
601
- type="button"
602
- appearance="none"
603
- size="xs"
604
- style={{ color: 'inherit' }}
605
- onClick={(e) => {
606
- e.stopPropagation()
607
- if (!disabled) handleRemoveTag(tag)
608
- }}
609
- aria-label={`${t.removeTag} ${renderLabel(tag)}`}
610
- disabled={disabled}
611
- tabIndex={0}
612
- />)}
613
- </Badge>
614
- )
615
- })}
816
+ {visibleTags.map(tag => (
817
+ <Badge
818
+ key={renderKey(tag)}
819
+ colorScheme={config.colorScheme}
820
+ colorPalette={config.colorPalette}
821
+ appearance={config.appearance || 'circle'}
822
+ >
823
+ {renderLabel(tag)}
824
+ {!disabled && (
825
+ <IconButton
826
+ icon="Times"
827
+ type="button"
828
+ appearance="none"
829
+ size="xs"
830
+ style={{ color: 'inherit' }}
831
+ onClick={(e) => {
832
+ e.stopPropagation()
833
+ if (!disabled) handleRemoveTag(tag)
834
+ }}
835
+ aria-label={`${t.removeTag} ${renderLabel(tag)}`}
836
+ disabled={disabled}
837
+ tabIndex={0}
838
+ />
839
+ )}
840
+ </Badge>
841
+ ))}
616
842
  {remainingCount > 0 && (
617
843
  <Badge
618
- colorScheme={tagColorScheme}
619
- colorPalette={tagColorPalette}
620
- appearance={tagAppearance}
844
+ colorScheme={config.colorScheme}
845
+ colorPalette={config.colorPalette}
846
+ appearance={config.appearance || 'circle'}
621
847
  >
622
848
  +{remainingCount}
623
849
  </Badge>
@@ -635,7 +861,7 @@ export const Autocomplete = withRef(
635
861
  <CitricComponent
636
862
  tag="div"
637
863
  component="autocomplete"
638
- data-citric="autocomplete"
864
+ colorScheme={colorScheme}
639
865
  style={maxHeight ? applyCSSVariable(style, 'max-height', `${maxHeight}px`) : style}
640
866
  className={listToClass([
641
867
  className,
@@ -649,29 +875,24 @@ export const Autocomplete = withRef(
649
875
  {...props}
650
876
  >
651
877
  <header
652
- onClick={() => {
653
- if (disabled) return
654
- setFocused(true)
655
- setOpen(true)
656
- if (inputRef.current) {
657
- inputRef.current.focus()
658
- }
659
- }}
660
- onFocus={() => setFocused(true)}
661
878
  tabIndex={disabled ? undefined : 0}
879
+ onClick={handleFocusAndOpen}
880
+ onFocus={handleFocusAndOpen}
881
+ onKeyDown={handleKeyDown}
662
882
  >
663
883
  <Row gap="4px" className="input-container">
664
884
  {multiple && renderTags()}
665
885
  <input
666
886
  ref={inputRef}
887
+ id={id}
667
888
  type="text"
668
889
  value={inputValue}
669
890
  onChange={(e) => handleInputChange(e.target.value)}
670
891
  onFocus={handleFocus}
671
892
  onBlur={handleBlur}
672
- onKeyDown={handleKeyDown}
673
893
  disabled={disabled}
674
- placeholder={multiple && (value as T[]).length > 0 ? '' : placeholder}
894
+ placeholder={(multiple && (value as T[]).length > 0) ? '' : placeholder}
895
+ tabIndex={disabled ? undefined : 0}
675
896
  autoComplete="off"
676
897
  aria-autocomplete="list"
677
898
  aria-expanded={open}
@@ -688,11 +909,19 @@ export const Autocomplete = withRef(
688
909
  type="button"
689
910
  onClick={(e) => {
690
911
  e.stopPropagation()
912
+ e.preventDefault()
691
913
  handleClear()
692
914
  }}
915
+ onMouseDown={(e) => {
916
+ e.stopPropagation()
917
+ e.preventDefault()
918
+ }}
919
+ onFocus={(e) => {
920
+ e.stopPropagation()
921
+ }}
693
922
  disabled={disabled}
694
923
  aria-label={t.clear}
695
- tabIndex={1}
924
+ tabIndex={0}
696
925
  style={{ width: '12px', height: '12px' }}
697
926
  />
698
927
  )}
@@ -703,11 +932,19 @@ export const Autocomplete = withRef(
703
932
  type="button"
704
933
  onClick={(e) => {
705
934
  e.stopPropagation()
935
+ e.preventDefault()
706
936
  setOpen((prev) => !prev)
707
937
  }}
938
+ onMouseDown={(e) => {
939
+ e.stopPropagation()
940
+ e.preventDefault()
941
+ }}
942
+ onFocus={(e) => {
943
+ e.stopPropagation()
944
+ }}
708
945
  disabled={disabled}
709
946
  aria-label={open ? t.collapse : t.expand}
710
- tabIndex={1}
947
+ tabIndex={0}
711
948
  style={{ width: '12px', height: '12px' }}
712
949
  />
713
950
  </div>
@@ -719,11 +956,14 @@ export const Autocomplete = withRef(
719
956
  id="autocomplete-listbox"
720
957
  role="listbox"
721
958
  aria-hidden={!open}
959
+ onMouseMove={() => {
960
+ isNavigatingWithKeyboard.current = false
961
+ }}
722
962
  {...(open ? {} : { inert: 'true' })}
723
963
  >
724
964
  {loading && !filteredOptions.length ? (
725
965
  <div className="message">{loadingText || t.loading}</div>
726
- ) : filteredOptions.length === 0 && !showCreateOption ? (
966
+ ) : filteredOptions.length === 0 && !showCreateOption && !freeSolo ? (
727
967
  <div className="message">{noOptionsText || t.noOptions}</div>
728
968
  ) : (
729
969
  <div className="options">
@@ -756,7 +996,11 @@ export const Autocomplete = withRef(
756
996
  e.preventDefault()
757
997
  }}
758
998
  onClick={() => handleSelect(option)}
759
- onMouseEnter={() => setHighlightedIndex(index)}
999
+ onMouseEnter={() => {
1000
+ if (!isNavigatingWithKeyboard.current) {
1001
+ setHighlightedIndex(index)
1002
+ }
1003
+ }}
760
1004
  >
761
1005
  {multiple && <Checkbox value={isSelected(option)} readOnly />}
762
1006
  {renderOption ? renderOption(option) : renderLabel(option)}