@stack-spot/citric-react 0.37.0 → 0.37.1-beta.1

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/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@stack-spot/citric-react",
3
- "version": "0.37.0",
3
+ "version": "0.37.1-beta.1",
4
4
  "author": "StackSpot",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
7
7
  "exports": {
8
8
  ".": "./dist/index.js",
9
- "./package.json": "./package.json",
10
9
  "./theme.css": "./dist/theme.css",
11
10
  "./citric.css": "./dist/citric.css"
12
11
  },
@@ -8,6 +8,7 @@ import { withRef } from '../../utils/react'
8
8
  import { Checkbox } from '../Checkbox'
9
9
  import { CheckboxGroup } from '../CheckboxGroup'
10
10
  import { CitricComponent } from '../CitricComponent'
11
+ import { IconButton } from '../IconBox'
11
12
  import { Input } from '../Input'
12
13
  import { Row } from '../layout'
13
14
  import { ProgressCircular } from '../ProgressCircular'
@@ -49,6 +50,28 @@ export interface BaseMultiSelectProps<T> extends
49
50
  * @default false
50
51
  */
51
52
  showSelectAll?: boolean,
53
+ /**
54
+ * Whether to render selected values as removable chips/tags.
55
+ *
56
+ * @default false
57
+ */
58
+ showAsChips?: boolean,
59
+ /**
60
+ * Whether to allow adding custom values that don't exist in options.
61
+ * When enabled, typing in the search and pressing Enter will add the value.
62
+ * The value will be added as a string to the list.
63
+ *
64
+ * @default false
65
+ */
66
+ allowCustomOptions?: boolean,
67
+ /**
68
+ * Function to create a new option from a string input.
69
+ * Required when `allowCustomOptions` is true.
70
+ *
71
+ * @param input the string input from the user
72
+ * @returns the new option of type T
73
+ */
74
+ createOption?: (inputValue: string) => T,
52
75
  }
53
76
 
54
77
  export type MultiSelectProps<T> = Omit<React.JSX.IntrinsicElements['div'], 'ref' | 'onChange' | 'onFocus' | 'onBlur'> &
@@ -94,6 +117,9 @@ export const MultiSelect = withRef(
94
117
  showArrow,
95
118
  placeholder,
96
119
  showSelectAll,
120
+ showAsChips = false,
121
+ allowCustomOptions = false,
122
+ createOption,
97
123
  ...props
98
124
  }: MultiSelectProps<T>,
99
125
  ) {
@@ -102,12 +128,24 @@ export const MultiSelect = withRef(
102
128
  const element = ref ?? _element
103
129
  const [open, setOpen] = useState(false)
104
130
  const [focused, setFocused] = useState(false)
131
+
132
+ // Merge options with selected values that are not in the original options
133
+ const mergedOptions = useMemo(() => {
134
+ const optionKeys = new Set(options.map(renderKey))
135
+ const extraValues = value.filter(v => !optionKeys.has(renderKey(v)))
136
+ return [...options, ...extraValues]
137
+ }, [options, value, renderKey])
138
+
105
139
  const controls = useCheckboxGroupControls({
106
- options,
140
+ options: mergedOptions,
107
141
  renderKey,
108
142
  initialValue: value,
109
143
  onChange,
110
- applyFilter: (filter, option) => renderLabel(option).toLocaleLowerCase().includes(filter.toLocaleLowerCase()),
144
+ applyFilter: (filter, option) => {
145
+ const label = renderLabel(option)
146
+ if (!label) return false
147
+ return label.toLocaleLowerCase().includes(filter.toLocaleLowerCase())
148
+ },
111
149
  })
112
150
 
113
151
  useOpenPanelEffect({ open, setOpen, setSearch: controls.setFilter, element, searchable })
@@ -118,9 +156,81 @@ export const MultiSelect = withRef(
118
156
  if (value !== controls.value) controls.setValue(value)
119
157
  }, [value.map(renderKey).join(',')])
120
158
 
159
+ const handleRemoveChip = (option: T) => {
160
+ const newValue = value.filter(v => renderKey(v) !== renderKey(option))
161
+ controls.setValue(newValue)
162
+ }
163
+
164
+ const handleAddCustomValue = (e: React.KeyboardEvent<HTMLInputElement>) => {
165
+ if (!allowCustomOptions || !createOption || !controls.filter) return
166
+ const filterValue = String(controls.filter).trim()
167
+ if (e.key === 'Enter' && filterValue && filterValue.length > 0) {
168
+ e.preventDefault()
169
+ const newOption = createOption(filterValue)
170
+ const exists = value.some(v => {
171
+ const key1 = renderKey(v)
172
+ const key2 = renderKey(newOption)
173
+ if (typeof key1 === 'string' && typeof key2 === 'string') {
174
+ return key1.toLowerCase() === key2.toLowerCase()
175
+ }
176
+ return key1 === key2
177
+ })
178
+ if (!exists) {
179
+ controls.setValue([...value, newOption])
180
+ }
181
+ controls.setFilter('' as any)
182
+ }
183
+ }
184
+
185
+ // Check if the current filter does not match any existing option
186
+ const hasTemporaryOption = useMemo(() => {
187
+ if (!allowCustomOptions || !controls.filter) return false
188
+ const filterValue = String(controls.filter).trim()
189
+ if (!filterValue || filterValue.length === 0) return false
190
+
191
+ const matchesExisting = mergedOptions.some(option => {
192
+ const label = renderLabel(option)
193
+ if (!label) return false
194
+ return label.toLowerCase() === filterValue.toLowerCase()
195
+ })
196
+
197
+ return !matchesExisting
198
+ }, [allowCustomOptions, controls.filter, mergedOptions, renderLabel])
199
+
121
200
  const header = useMemo(() => {
122
201
  if (value.length === 0) return <span className="placeholder header-text">{placeholder}</span>
123
202
  const reversed = [...value].reverse()
203
+
204
+ if (showAsChips) {
205
+ return (
206
+ <Row className="header-chips" gap="4px">
207
+ {reversed.map(option => (
208
+ <span
209
+ key={renderKey(option)}
210
+ data-citric="badge"
211
+ className="chip"
212
+ >
213
+ <span>{renderLabel(option)}</span>
214
+ {!disabled && (
215
+ <IconButton
216
+ icon="Times"
217
+ type="button"
218
+ className="remove-button"
219
+ size="xs"
220
+ disabled={disabled}
221
+ onClick={(e) => {
222
+ e.stopPropagation()
223
+ handleRemoveChip(option)
224
+ }}
225
+ aria-label={`${t.remove} ${renderLabel(option)}`}
226
+ />
227
+ )}
228
+ </span>
229
+ ))}
230
+ </Row>
231
+ )
232
+ }
233
+
124
234
  return (
125
235
  (renderHeader?.(reversed)
126
236
  ?? (renderOption
@@ -128,7 +238,7 @@ export const MultiSelect = withRef(
128
238
  : <span className="header-text">{reversed.map(renderLabel).join(', ')}</span>
129
239
  )
130
240
  ) || <span></span>
131
- )}, [value, placeholder])
241
+ )}, [value, placeholder, showAsChips, disabled])
132
242
 
133
243
  return (
134
244
  <CitricComponent
@@ -141,6 +251,7 @@ export const MultiSelect = withRef(
141
251
  open && 'open',
142
252
  focused && 'focused',
143
253
  disabled && 'disabled',
254
+ showAsChips && 'with-chips',
144
255
  ])}
145
256
  ref={element}
146
257
  aria-busy={loading}
@@ -164,7 +275,14 @@ export const MultiSelect = withRef(
164
275
  {searchable && <div className="search-bar">
165
276
  <div data-citric="field-group" className="auto">
166
277
  <i data-citric="icon-box" className="citric-icon outline Search"></i>
167
- <Input type="search" value={controls.filter} onChange={controls.setFilter} aria-label={t.searchAccessibility} />
278
+ <Input
279
+ type="search"
280
+ value={controls.filter}
281
+ onChange={controls.setFilter}
282
+ onKeyDown={handleAddCustomValue}
283
+ aria-label={t.searchAccessibility}
284
+ placeholder={allowCustomOptions ? t.searchOrAddPlaceholder : undefined}
285
+ />
168
286
  </div>
169
287
  </div>}
170
288
  {showSelectAll && (
@@ -195,6 +313,11 @@ export const MultiSelect = withRef(
195
313
  </CitricComponent>
196
314
  )}
197
315
  />
316
+ {hasTemporaryOption && (
317
+ <div className="temporary-option" style={{ fontStyle: 'italic', padding: '8px 16px', opacity: 0.7 }}>
318
+ {String(controls.filter).trim()} ({t.pressEnterToAdd})
319
+ </div>
320
+ )}
198
321
  </div>
199
322
  </CitricComponent>
200
323
  )
@@ -207,11 +330,17 @@ const dictionary = {
207
330
  searchAccessibility: 'Filter the options',
208
331
  removeSelection: 'Remove selection',
209
332
  selectAll: 'Select all',
333
+ remove: 'Remove',
334
+ searchOrAddPlaceholder: 'Search or press Enter to add',
335
+ pressEnterToAdd: 'press Enter to add',
210
336
  },
211
337
  pt: {
212
338
  accessibilityHelp: 'Pressione a seta para baixo para selecionar múltiplas opções',
213
339
  searchAccessibility: 'Filtre as opções',
214
340
  removeSelection: 'Remover seleção',
215
341
  selectAll: 'Selecionar todos',
342
+ remove: 'Remover',
343
+ searchOrAddPlaceholder: 'Busque ou pressione Enter para adicionar',
344
+ pressEnterToAdd: 'pressione Enter para adicionar',
216
345
  },
217
346
  }