react-animated-select 0.3.1 → 0.3.6

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.
@@ -1,5 +1,11 @@
1
1
  import {useState, useMemo, useCallback, useId, useEffect} from 'react'
2
2
 
3
+ // keys that cannot be taken as a name if labelKeys are unavailable
4
+ const systemKeys = ['group', 'disabled', 'options', 'items', 'children']
5
+
6
+ // main keys in order of priority that can be taken for the name
7
+ const labelKeys = ['name', 'label', 'id', 'value']
8
+
3
9
  function useSelectLogic({
4
10
  options = [],
5
11
  jsxOptions = [],
@@ -28,184 +34,218 @@ function useSelectLogic({
28
34
  }) {
29
35
  const stableId = useId()
30
36
  const isControlled = value !== undefined
31
-
32
37
  const [selectedId, setSelectedId] = useState(null)
33
38
 
34
- const isOptionObject = (obj) => {
35
- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false
36
- return ('id' in obj || 'value' in obj || 'name' in obj || 'label' in obj || 'disabled' in obj)
37
- }
39
+ // getting option name
40
+ const getLabelFromObject = useCallback((obj, fallback = false) => {
41
+ //
42
+ const foundKey = labelKeys.find(k => obj[k] !== undefined && obj[k] !== null && obj[k] !== '')
43
+ if (foundKey) return String(obj[foundKey])
38
44
 
39
- const normalizedOptions = useMemo(() => {
40
- const flat = []
41
- const push = (key, val, originalItem) => {
42
- let computedUserId = originalItem?.id ?? originalItem?.value ?? val ?? key
43
-
44
- if (typeof val === 'function') {
45
- flat.push({
46
- key: `invalid-${flat.length}`,
47
- value: val,
48
- userId: null,
49
- disabled: true,
50
- invalid: true,
51
- label: invalidOption,
52
- original: originalItem
53
- })
54
- return
45
+ const fallbackEntry = Object.entries(obj).find(([k, v]) =>
46
+ !systemKeys.includes(k) && v != null && v !== ''
47
+ )
48
+
49
+ if (fallbackEntry) return String(fallbackEntry[1])
50
+ return fallback
51
+ }, [])
52
+
53
+ const createNormalizedOption = useCallback((rawItem, index, type = 'normal', injectedGroup = null, injectedDisabled = false) => {
54
+ let label = ''
55
+ let itemValue = rawItem
56
+ let isDisabled = injectedDisabled
57
+ let userId = null
58
+ let group = injectedGroup
59
+
60
+ if (rawItem == null || rawItem === '') {
61
+ return {
62
+ id: `${stableId}-${type}-${index}`,
63
+ userId: null,
64
+ name: emptyOption,
65
+ raw: null,
66
+ disabled: true,
67
+ type: 'normal',
68
+ group: group
55
69
  }
70
+ }
56
71
 
57
- if (val === '') {
58
- flat.push({
59
- key: `empty-str-${flat.length}`,
60
- value: '',
61
- userId: null,
62
- disabled: true,
63
- label: emptyOption,
64
- original: originalItem
65
- })
66
- return
72
+ if (typeof rawItem === 'function') {
73
+ return {
74
+ id: `${stableId}-inv-${index}`,
75
+ userId: null,
76
+ name: invalidOption,
77
+ raw: rawItem,
78
+ disabled: true,
79
+ invalid: true,
80
+ type: 'normal',
81
+ group: group
67
82
  }
83
+ }
68
84
 
69
- if (val === null || val === undefined) {
70
- flat.push({
71
- key: `empty-${flat.length}`,
72
- value: null,
73
- userId: null,
74
- disabled: true,
75
- label: emptyOption,
76
- original: originalItem
77
- })
85
+ if (typeof rawItem === 'object' && !Array.isArray(rawItem)) {
86
+ if (!group) group = rawItem.group || null
87
+ isDisabled = isDisabled || !!rawItem.disabled
88
+
89
+ userId = rawItem.id ?? rawItem.value ?? rawItem.name ?? rawItem.label
90
+ itemValue = rawItem.value !== undefined ? rawItem.value : (rawItem.id !== undefined ? rawItem.id : rawItem)
91
+
92
+ label = getLabelFromObject(rawItem, isDisabled ? disabledOption : emptyOption)
93
+ if (label === emptyOption && !isDisabled) isDisabled = true
94
+ } else {
95
+ label = String(rawItem)
96
+ userId = rawItem
97
+ itemValue = rawItem
98
+ }
99
+
100
+ return {
101
+ id: `${stableId}-${type}-${index}`,
102
+ userId: userId,
103
+ name: label,
104
+ raw: itemValue,
105
+ original: rawItem,
106
+ disabled: isDisabled,
107
+ type: typeof itemValue === 'boolean' ? 'boolean' : 'normal',
108
+ group: group
109
+ }
110
+ }, [stableId, emptyOption, invalidOption, disabledOption, getLabelFromObject])
111
+
112
+ const [expandedGroups, setExpandedGroups] = useState(new Set())
113
+
114
+ const toggleGroup = useCallback((groupName) => {
115
+ setExpandedGroups(prev => {
116
+ const next = new Set(prev)
117
+ if (next.has(groupName)) next.delete(groupName)
118
+ else next.add(groupName)
119
+ return next
120
+ })
121
+ }, [])
122
+
123
+ const normalizedOptions = useMemo(() => {
124
+ const combined = []
125
+
126
+ const processItem = (opt, uniqueIdx, parentGroup = null, parentDisabled = false) => {
127
+ if (opt && typeof opt === 'object' && !Array.isArray(opt) && 'options' in opt) {
128
+ const groupName = getLabelFromObject(opt, 'Empty group', true)
129
+ const isGroupDisabled = parentDisabled || !!opt.disabled
130
+ const innerData = opt.options
131
+
132
+ if (Array.isArray(innerData)) {
133
+ innerData.forEach((child, i) => processItem(child, `${uniqueIdx}-${i}`, groupName, isGroupDisabled))
134
+ } else if (innerData && typeof innerData === 'object') {
135
+ Object.entries(innerData).forEach(([k, v], i) => processItem(v, `${uniqueIdx}-${i}`, groupName, isGroupDisabled))
136
+ } else {
137
+ processItem(innerData, `${uniqueIdx}-0`, groupName, isGroupDisabled)
138
+ }
78
139
  return
79
140
  }
80
141
 
81
- if (typeof val === 'number' || typeof val === 'boolean') {
82
- flat.push({
83
- key: `${typeof val}-${val}-${flat.length}`,
84
- value: val,
85
- userId: computedUserId,
86
- label: String(val),
87
- original: originalItem
142
+ const isMapObj = opt && typeof opt === 'object' && !Array.isArray(opt) &&
143
+ !labelKeys.some(k => k in opt) && !('group' in opt)
144
+
145
+ if (isMapObj) {
146
+ Object.entries(opt).forEach(([k, v], j) => {
147
+ combined.push(createNormalizedOption(v, `${uniqueIdx}-${j}`, 'normal', parentGroup, parentDisabled))
88
148
  })
89
149
  return
90
- } else {
91
- flat.push({
92
- key: key ?? `opt-${flat.length}`,
93
- value: val,
94
- userId: computedUserId,
95
- label: String(val ?? key),
96
- original: originalItem
97
- })
98
150
  }
151
+
152
+ combined.push(createNormalizedOption(opt, uniqueIdx, 'normal', parentGroup, parentDisabled))
99
153
  }
100
154
 
101
155
  if (Array.isArray(options)) {
102
- options.forEach((item, index) => {
103
- if (item && typeof item === 'object' && Object.keys(item).length === 1 && item.disabled === true) {
104
- flat.push({key: `dis-${index}`, value: null, userId: null, disabled: true, label: disabledOption, original: item})
105
- } else if (isOptionObject(item)) {
106
- const stableUserId = item.id ?? (typeof item.value !== 'object' ? item.value : (item.label ?? item.name ?? item.value))
107
-
108
- let rawLabel = item.name || item.label || item.id || item.value
109
-
110
- if (rawLabel === null || rawLabel === undefined || rawLabel === '') {
111
- const fallbackEntry = Object.entries(item).find(([k, v]) =>
112
- k !== 'disabled' && v !== null && v !== undefined && v !== ''
113
- )
114
- if (fallbackEntry) {
115
- rawLabel = fallbackEntry[1]
116
- }
117
- }
118
-
119
- const hasNoContent = rawLabel === null || rawLabel === undefined || rawLabel === ''
120
- const finalLabel = hasNoContent ? emptyOption : String(rawLabel)
121
-
122
- flat.push({
123
- key: item.id ?? item.value ?? item.name ?? `opt-${index}`,
124
- value: item.value !== undefined ? item.value : (item.id !== undefined ? item.id : item),
125
- userId: stableUserId,
126
- disabled: hasNoContent || !!item.disabled,
127
- label: finalLabel,
128
- original: item
129
- })
130
- } else if (item && typeof item === 'object' && !Array.isArray(item)) {
131
- Object.entries(item).forEach(([k, v]) => push(k, v, v))
132
- } else {
133
- push(item, item, item)
134
- }
135
- })
136
- } else if (typeof options === 'object' && options !== null) {
137
- Object.entries(options).forEach(([k, v]) => push(k, v, v))
156
+ options.forEach((opt, i) => processItem(opt, i))
138
157
  }
139
158
 
140
- const propOpts = flat.map((item, i) => ({
141
- id: `${stableId}-opt-${i}`,
142
- userId: item.userId,
143
- name: String(item.label),
144
- raw: item.value,
145
- original: item.original,
146
- disabled: item.disabled,
147
- invalid: item.invalid,
148
- type: typeof item.value === 'boolean' ? 'boolean' : 'normal'
149
- }))
150
-
151
- const jsxOpts = jsxOptions.map((opt, index) => {
152
- const hasNoValue = opt.value === null || opt.value === undefined
153
- const hasNoLabel = opt.label === null || opt.label === undefined || opt.label === ''
154
-
155
- const isActuallyEmpty = hasNoValue && hasNoLabel
156
-
159
+ const jsxMapped = jsxOptions.map(opt => {
160
+ if (opt.isGroupMarker) return { ...opt, type: 'group-marker' }
161
+ const isActuallyEmpty = (opt.value == null || opt.value === '') && !opt.label
157
162
  return {
158
163
  ...opt,
159
- id: `jsx-${stableId.replace(/:/g, '')}-${opt.id}-${index}`,
164
+ id: `jsx-${opt.id}`,
160
165
  userId: opt.id,
161
166
  raw: opt.value,
162
167
  original: opt.value,
163
168
  name: isActuallyEmpty ? emptyOption : opt.label,
164
- disabled: opt.disabled || isActuallyEmpty,
165
- type: typeof opt.value === 'boolean' ? 'boolean' : 'normal'
169
+ disabled: !!opt.disabled || isActuallyEmpty,
170
+ type: typeof opt.value === 'boolean' ? 'boolean' : 'normal',
171
+ group: opt.group || null
166
172
  }
167
173
  })
168
174
 
169
- const combined = childrenFirst ? [...jsxOpts, ...propOpts] : [...propOpts, ...jsxOpts]
175
+ const baseList = childrenFirst ? [...jsxMapped, ...combined] : [...combined, ...jsxMapped]
170
176
 
171
- if (hasMore && loadButton) {
172
- const isLoading = loadingTitle === loadMoreText
177
+ const finalFlattened = []
178
+ const groupsMap = new Map()
179
+ const order = []
173
180
 
174
- combined.push({
181
+ baseList.forEach(opt => {
182
+ if (!opt.group) {
183
+ order.push({type: 'item', data: opt})
184
+ } else {
185
+ if (!groupsMap.has(opt.group)) {
186
+ groupsMap.set(opt.group, [])
187
+ order.push({type: 'group', name: opt.group})
188
+ }
189
+ if (!opt.isGroupMarker) {
190
+ const visible = expandedGroups.has(opt.group)
191
+ groupsMap.get(opt.group).push({...opt, hidden: !visible})
192
+ }
193
+ }
194
+ })
195
+
196
+ order.forEach((entry, idx) => {
197
+ if (entry.type === 'item') {
198
+ finalFlattened.push(entry.data)
199
+ } else {
200
+ const expanded = expandedGroups.has(entry.name)
201
+ finalFlattened.push({
202
+ id: `group-header-${entry.name}-${idx}`,
203
+ name: entry.name,
204
+ disabled: false,
205
+ groupHeader: true,
206
+ expanded,
207
+ type: 'group'
208
+ })
209
+
210
+ const items = groupsMap.get(entry.name)
211
+ finalFlattened.push(...items)
212
+
213
+ }
214
+ })
215
+
216
+ if (hasMore && loadButton) {
217
+ finalFlattened.push({
175
218
  id: 'special-load-more-id',
176
219
  name: loadingTitle,
177
220
  loadMore: true,
178
- loading: isLoading,
221
+ loading: loadingTitle === loadMoreText,
179
222
  type: 'special'
180
223
  })
181
224
  }
182
225
 
183
- return combined
184
- }, [options, jsxOptions, stableId, emptyOption, disabledOption, hasMore, loadButton, loadingTitle, loadMoreText])
226
+ return finalFlattened
227
+ }, [options, jsxOptions, stableId, createNormalizedOption, childrenFirst, hasMore, loadButton, loadingTitle, loadMoreText, emptyText, emptyOption, getLabelFromObject])
185
228
 
186
229
  const findIdByValue = useCallback((val) => {
187
- if (val === undefined || val === null) return null
188
-
189
- const refMatch = normalizedOptions.find(o => o.original === val || o.raw === val)
190
- if (refMatch) return refMatch.id
230
+ if (val == null) return null
231
+ const match = normalizedOptions.find(o => o.original === val || o.raw === val || o.userId === val)
232
+ if (match) return match.id
191
233
 
192
234
  if (typeof val === 'object') {
193
235
  try {
194
236
  const str = JSON.stringify(val)
195
- const structMatch = normalizedOptions.find(o =>
237
+ return normalizedOptions.find(o =>
196
238
  o.original && typeof o.original === 'object' && JSON.stringify(o.original) === str
197
- )
198
- if (structMatch) return structMatch.id
199
- } catch {}
239
+ )?.id ?? null
240
+ } catch { return null }
200
241
  }
201
-
202
- return normalizedOptions.find(o => o.userId === val)?.id ?? null
242
+ return null
203
243
  }, [normalizedOptions])
204
244
 
205
245
  useEffect(() => {
206
246
  const effectiveValue = isControlled ? value : defaultValue
207
-
208
247
  const currentSelected = normalizedOptions.find(o => o.id === selectedId)
248
+
209
249
  const isStillValid = currentSelected && (
210
250
  currentSelected.original === effectiveValue ||
211
251
  currentSelected.raw === effectiveValue ||
@@ -215,29 +255,33 @@ function useSelectLogic({
215
255
  if (!isStillValid) {
216
256
  setSelectedId(findIdByValue(effectiveValue))
217
257
  }
218
- }, [value, defaultValue, isControlled, normalizedOptions, findIdByValue])
258
+ }, [value, defaultValue, isControlled, normalizedOptions, findIdByValue, selectedId])
219
259
 
220
- const selected = useMemo(() => {
221
- return normalizedOptions.find(o => o.id === selectedId) ?? null
222
- }, [selectedId, normalizedOptions])
260
+ const selected = useMemo(() =>
261
+ normalizedOptions.find(o => o.id === selectedId) ?? null,
262
+ [selectedId, normalizedOptions])
223
263
 
224
264
  const selectOption = useCallback((option, e) => {
225
- if (option.disabled || option.loadMore) {
265
+ if (option.groupHeader) {
226
266
  e?.stopPropagation()
227
267
  e?.preventDefault()
268
+ toggleGroup(option.name)
269
+ return
270
+ }
228
271
 
229
- if (loadingTitle !== loadMoreText) {
272
+ if (option.disabled || option.loadMore) {
273
+ e?.stopPropagation()
274
+ e?.preventDefault()
275
+ if (option.loadMore) {
230
276
  setLoadingTitle(loadMoreText)
231
277
  loadMore()
232
278
  }
233
-
234
279
  return
235
280
  }
236
-
237
281
  setSelectedId(option.id)
238
282
  onChange?.(option.original, option.userId)
239
283
  setVisibility(false)
240
- }, [onChange, setVisibility])
284
+ }, [onChange, setVisibility, loadMore, loadMoreText, setLoadingTitle])
241
285
 
242
286
  const clear = useCallback(() => {
243
287
  setSelectedId(null)
@@ -250,7 +294,8 @@ function useSelectLogic({
250
294
  active: !error && !loading && !disabled && normalizedOptions.length > 0,
251
295
  selectedValue: value ?? defaultValue,
252
296
  placeholder, emptyText, disabledText, loadingText, errorText,
253
- disabledOption, emptyOption, invalidOption, disabled, loading, error
297
+ disabledOption, emptyOption, invalidOption, disabled, loading, error,
298
+ expandedGroups, toggleGroup, visibleOptions: normalizedOptions.filter(o => !o.hidden)
254
299
  }
255
300
  }
256
301