@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.
|
|
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) =>
|
|
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
|
|
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
|
}
|