react-animated-select 0.2.9 → 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.
package/src/select.jsx CHANGED
@@ -1,34 +1,44 @@
1
1
  import './select.css'
2
-
3
2
  import {XMarkIcon, ArrowUpIcon} from './icons'
4
3
  import {forwardRef, useImperativeHandle, useRef, useMemo, useState, useEffect, useCallback, useId, isValidElement, cloneElement} from 'react'
4
+ import {makeId} from './makeId'
5
5
 
6
- import {SelectContext} from './selectContext'
6
+ import SelectJSX from './selectJSX'
7
7
  import useSelect from './useSelect'
8
- import {useSelectLogic} from './useSelectLogic'
9
- import {makeId} from './makeId'
10
- import Options from './options'
8
+ import useSelectLogic from './useSelectLogic'
9
+ import SlideDown from './slideDown'
11
10
  import SlideLeft from './slideLeft'
12
11
 
12
+ // universal icon display
13
13
  const renderIcon = (Icon, defaultProps) => {
14
14
  if (!Icon) return null
15
-
16
- if (typeof Icon === 'string') {
17
- return <img src={Icon} {...defaultProps} alt='' />
18
- }
19
-
20
- if (isValidElement(Icon)) {
21
- return cloneElement(Icon, defaultProps)
22
- }
23
-
15
+ if (typeof Icon === 'string') return <img src={Icon} {...defaultProps} alt=''/>
16
+ if (isValidElement(Icon)) return cloneElement(Icon, defaultProps)
24
17
  if (typeof Icon === 'function' || (typeof Icon === 'object' && Icon.$$typeof)) {
25
18
  const IconComponent = Icon
26
- return <IconComponent {...defaultProps} />
19
+ return <IconComponent {...defaultProps}/>
27
20
  }
28
-
29
21
  return null
30
22
  }
31
23
 
24
+ // adding classes to style options according to their state
25
+ const getOptionClassName = (element, index, highlightedIndex, selectedId, loadingTitle, loadMoreText, invalidOption) => {
26
+ if (element.groupHeader) {
27
+ return 'rac-select-option rac-group-option'
28
+ }
29
+
30
+ return [
31
+ 'rac-select-option',
32
+ element.className,
33
+ selectedId === element.id && 'rac-selected',
34
+ index === highlightedIndex && 'rac-highlighted',
35
+ (element.disabled || element.loading) && 'rac-disabled-option',
36
+ (element.invalid || element.name === invalidOption) && 'rac-invalid-option',
37
+ (element.loadMore && loadingTitle === loadMoreText) && 'rac-loading-option',
38
+ typeof element.raw === 'boolean' && (element.raw ? 'rac-true-option' : 'rac-false-option')
39
+ ].filter(Boolean).join(' ')
40
+ }
41
+
32
42
  const Select = forwardRef(({
33
43
  unmount,
34
44
  children,
@@ -44,294 +54,255 @@ const Select = forwardRef(({
44
54
  className = '',
45
55
  ArrowIcon = ArrowUpIcon,
46
56
  ClearIcon = XMarkIcon,
57
+ hasMore = false,
58
+ loadMore = () => {console.warn('loadMore not implemented')},
59
+ loadButton = false,
60
+ loadButtonText = 'Load more',
61
+ loadMoreText = 'Loading',
62
+ loadOffset = 100,
63
+ loadAhead = 3,
64
+ childrenFirst = false,
47
65
  ...props
48
66
  }, ref) => {
49
67
 
50
68
  const reactId = useId()
51
-
52
69
  const selectId = useMemo(() => reactId.replace(/:/g, ''), [reactId])
53
-
54
70
  const [jsxOptions, setJsxOptions] = useState([])
71
+ const [internalVisibility, setInternalVisibility] = useState(false)
72
+ const [loadingTitle, setLoadingTitle] = useState(loadButton ? loadButtonText : loadMoreText)
73
+ const [animationFinished, setAnimationFinished] = useState(false)
74
+ const selectRef = useRef(null)
75
+
55
76
 
56
77
  const registerOption = useCallback((opt) => {
57
- setJsxOptions(prev => [...prev, opt])
78
+ setJsxOptions(prev => {
79
+ const index = prev.findIndex(o => o.id === opt.id)
80
+ if (index !== -1) {
81
+ const existing = prev[index]
82
+ if (
83
+ existing.label === opt.label &&
84
+ existing.value === opt.value &&
85
+ existing.disabled === opt.disabled &&
86
+ existing.group === opt.group
87
+ ) {
88
+ return prev
89
+ }
90
+ const next = [...prev]
91
+ next[index] = opt
92
+ return next
93
+ }
94
+ return [...prev, opt]
95
+ })
58
96
  }, [])
59
97
 
60
98
  const unregisterOption = useCallback((id) => {
61
- setJsxOptions(prev => prev.filter(o => o.id !== id))
99
+ setJsxOptions(prev => {
100
+ const filtered = prev.filter(o => o.id !== id)
101
+ return filtered.length === prev.length ? prev : filtered
102
+ })
62
103
  }, [])
63
104
 
64
- // ref is needed to pass dimensions for the animation hook
65
- const selectRef = useRef(null)
66
-
67
- useEffect(() => {
68
- if (!ref) return
69
- if (typeof ref === 'function') {
70
- ref(selectRef.current)
71
- } else {
72
- ref.current = selectRef.current
73
- }
74
- }, [ref])
75
-
76
- useImperativeHandle(ref, () => selectRef.current)
77
-
78
- // open/closed status select
79
- const [internalVisibility, setInternalVisibility] = useState(false)
80
-
81
- const visibility = useMemo(() => {
82
- if (alwaysOpen) return true
83
- if (ownBehavior) return !!externalVisibility
84
-
85
- return internalVisibility
86
- }, [alwaysOpen, ownBehavior, externalVisibility, internalVisibility])
87
-
105
+ // select visibility control
106
+ const visibility = alwaysOpen ? true : (ownBehavior ? !!externalVisibility : internalVisibility)
107
+
88
108
  const setVisibility = useCallback((newState) => {
89
- if (alwaysOpen) return
90
- if (ownBehavior) return
91
-
92
- setInternalVisibility(prev => {
93
- const next = typeof newState === 'function' ? newState(prev) : newState
94
- return next
95
- })
109
+ if (alwaysOpen || ownBehavior) return
110
+ setInternalVisibility(newState)
96
111
  }, [alwaysOpen, ownBehavior])
97
112
 
98
- const {normalizedOptions, selected, selectOption, clear, hasOptions, active, selectedValue, disabled, loading, error, placeholder, invalidOption, options, value, defaultValue, isControlled, emptyText, disabledText, loadingText, errorText} = useSelectLogic({...props, visibility, setVisibility, jsxOptions})
99
-
100
- // event handler functions for interacting with the select
101
- const {handleBlur, handleFocus, handleToggle, handleKeyDown, highlightedIndex, setHighlightedIndex} = useSelect({
102
- disabled,
103
- isOpen: visibility,
104
- setIsOpen: setVisibility,
105
- options: normalizedOptions,
106
- selectOption: selectOption,
107
- selected: selected
113
+ const logic = useSelectLogic({
114
+ ...props, visibility, setVisibility, jsxOptions, hasMore,
115
+ loadButton, loadingTitle, loadMore, loadMoreText, setLoadingTitle, childrenFirst
108
116
  })
109
117
 
110
- const [animationFinished, setAnimationFinished] = useState(false)
118
+ const {normalizedOptions, selected, selectOption, clear, hasOptions, active, selectedValue, disabled, loading, error, placeholder, invalidOption, emptyText, disabledText, loadingText, errorText, expandedGroups} = logic
119
+
120
+ const behavior = useSelect({setLoadingTitle, loadButton, loadButtonText, hasMore, loadMore, disabled, open: visibility, setOpen: setVisibility, options: normalizedOptions, selectOption, selected, loadOffset, loadAhead})
121
+
122
+ const {handleListScroll, handleBlur, handleFocus, handleToggle, handleKeyDown, highlightedIndex, setHighlightedIndex} = behavior
123
+
124
+ useImperativeHandle(ref, () => selectRef.current)
111
125
 
112
126
  useEffect(() => {
113
- if (!visibility) {
114
- setAnimationFinished(false)
115
- }
127
+ if (!visibility) setAnimationFinished(false)
116
128
  }, [visibility])
117
129
 
118
130
  useEffect(() => {
119
- if (error || disabled || loading || !hasOptions) {
120
- setVisibility(false)
121
- }
122
- }, [error, disabled, loading, hasOptions])
131
+ if (error || disabled || loading || !hasOptions) setVisibility(false)
132
+ }, [error, disabled, loading, hasOptions, setVisibility])
123
133
 
124
134
  useEffect(() => {
125
135
  if (visibility && animationFinished && highlightedIndex !== -1) {
126
-
127
136
  const option = normalizedOptions[highlightedIndex]
128
137
  if (option) {
129
- const elementId = `opt-${selectId}-${makeId(option.id)}`
130
- const domElement = document.getElementById(elementId)
131
-
132
- if (domElement) {
133
- domElement.scrollIntoView({block: 'nearest'})
134
- }
138
+ const domElement = document.getElementById(`opt-${selectId}-${makeId(option.id)}`)
139
+ domElement?.scrollIntoView({ block: 'nearest' })
135
140
  }
136
141
  }
137
142
  }, [highlightedIndex, visibility, animationFinished, normalizedOptions, selectId])
138
143
 
139
- const hasActualValue =
144
+ const hasActualValue = useMemo(() => (
140
145
  selectedValue !== undefined &&
141
146
  selectedValue !== null &&
142
147
  !(Array.isArray(selectedValue) && selectedValue.length === 0) &&
143
148
  !(typeof selectedValue === 'object' && Object.keys(selectedValue).length === 0)
149
+ ), [selectedValue])
144
150
 
145
- // displaying title according to state of select
146
151
  const title = useMemo(() => {
147
152
  if (error) return errorText
148
153
  if (loading) return loadingText
149
154
  if (disabled) return disabledText
150
-
151
155
  if (selected) return selected.jsx ?? selected.name
152
156
 
153
157
  if (hasActualValue) {
154
158
  const recovered = normalizedOptions.find(o => o.raw === selectedValue)
155
159
  if (recovered) return recovered.name
156
-
157
- if (typeof selectedValue === 'object' && selectedValue !== null) {
158
- return selectedValue.name ?? selectedValue.label ?? 'Selected Object'
159
- }
160
- return String(selectedValue)
160
+ return (typeof selectedValue === 'object' && selectedValue !== null)
161
+ ? (selectedValue.name ?? selectedValue.label ?? 'Selected Object')
162
+ : String(selectedValue)
161
163
  }
164
+ return hasOptions ? placeholder : emptyText
165
+ }, [disabled, loading, error, hasOptions, selected, selectedValue, placeholder, errorText, loadingText, disabledText, emptyText, hasActualValue, normalizedOptions])
162
166
 
163
- if (!hasOptions) return emptyText
164
-
165
- return placeholder
166
- }, [disabled, loading, error, hasOptions, selected, selectedValue, placeholder, errorText, loadingText, disabledText, emptyText])
167
-
168
- const listboxId = `${selectId}-listbox`
167
+ const renderOptions = useMemo(() => {
168
+ const nodes = []
169
+ let currentGroupChildren = []
170
+ let currentGroupName = null
169
171
 
170
- // option list rendering
171
- const renderOptions = useMemo(() => normalizedOptions?.map((element, index) => {
172
- const optionId = `opt-${selectId}-${makeId(element.id)}`
173
-
174
- let optionClass = 'rac-select-option'
175
- if (element.className) optionClass += ` ${element.className}`
176
-
177
- if (selected?.id === element.id) optionClass += ' rac-selected'
178
-
179
- if (index === highlightedIndex) optionClass += ' rac-highlighted'
180
-
181
- if (element.disabled) optionClass += ' rac-disabled-option'
182
-
183
- if (element.isInvalid) optionClass += ' rac-invalid-option'
184
-
185
- if (typeof element.raw === 'boolean') {
186
- optionClass += element.raw ? ' rac-true-option' : ' rac-false-option'
187
- }
172
+ const groupCounts = normalizedOptions.reduce((acc, opt) => {
173
+ if (opt.group) {
174
+ acc[opt.group] = (acc[opt.group] || 0) + 1
175
+ }
176
+ return acc
177
+ }, {})
188
178
 
189
- if (element.name == invalidOption) {
190
- optionClass += ' rac-invalid-option'
179
+ const flushGroup = (name) => {
180
+ if (name === null || currentGroupChildren.length === 0) return
181
+
182
+ nodes.push(
183
+ <SlideDown
184
+ key={`slide-${name}`}
185
+ visibility={expandedGroups.has(name)}
186
+ >
187
+ {currentGroupChildren}
188
+ </SlideDown>
189
+ )
190
+ currentGroupChildren = []
191
191
  }
192
192
 
193
- return (
193
+ const createOptionNode = (element, index) => (
194
194
  <div
195
- className={optionClass}
196
- onClick={(e) => selectOption(element, e)}
197
- onMouseEnter={() => !element.disabled && setHighlightedIndex(index)}
198
195
  key={element.id}
199
- id={optionId}
196
+ id={`opt-${selectId}-${makeId(element.id)}`}
200
197
  role='option'
201
198
  aria-selected={selected?.id === element.id}
202
- aria-disabled={element.disabled}
199
+ aria-disabled={element.disabled || element.loading}
200
+ className={getOptionClassName(element, index, highlightedIndex, selected?.id, loadingTitle, loadMoreText, invalidOption)}
201
+ onClick={(e) => !element.loading && selectOption(element, e)}
202
+ onMouseEnter={() => (!element.disabled && !element.loading) && setHighlightedIndex(index)}
203
203
  >
204
204
  {element.jsx ?? element.name}
205
+ {element.loading && <span className='rac-loading-dots'><i/><i/><i/></span>}
205
206
  </div>
206
207
  )
207
- }), [normalizedOptions, selectOption, selectId, selected, highlightedIndex])
208
208
 
209
- useEffect(() => {
210
- if (process.env.NODE_ENV !== 'production') {
211
-
212
- const receivedType = typeof options
213
- if (options && typeof options !== 'object') {
214
- console.error(
215
- `%c[Select Library]:%c Invalid prop %coptions%c.\n` +
216
- `Expected %cArray%c or %cObject%c, but received %c${receivedType}%c.\n`,
217
- 'color: #ff4d4f; font-weight: bold;', 'color: default;',
218
- 'color: #1890ff; font-weight: bold;', 'color: default;',
219
- 'color: #52c41a; font-weight: bold;', 'color: default;',
220
- 'color: #52c41a; font-weight: bold;', 'color: default;',
221
- 'color: #ff4d4f; font-weight: bold;', 'color: default;'
222
- )
223
- }
209
+ normalizedOptions.forEach((element, index) => {
210
+ const isHeader = element.groupHeader
211
+ const belongsToGroup = !!element.group
224
212
 
225
- if (isControlled && defaultValue !== undefined) {
226
- console.warn(
227
- `%c[Select Library]:%c .\n` +
228
- ``,
229
- 'color: #faad14; font-weight: bold;', 'color: default;'
230
- )
213
+ if (isHeader || (!belongsToGroup && currentGroupName !== null)) {
214
+ flushGroup(currentGroupName)
215
+ if (!isHeader) currentGroupName = null
231
216
  }
232
- }
233
- }, [options, value, defaultValue, isControlled])
234
-
235
- return(
236
- <SelectContext.Provider
237
- value={{registerOption, unregisterOption}}
238
- >
239
- {children}
240
- {renderedDropdown}
241
- <div
242
- style={{
243
- '--rac-duration': `${duration}ms`,
244
- ...style
245
- }}
246
- className={`rac-select
247
- ${className}
248
- ${(!hasOptions || disabled) ? 'rac-disabled-style' : ''}
249
- ${loading ? 'rac-loading-style' : ''}
250
- ${error ? 'rac-error-style' : ''}`
251
- }
252
- tabIndex={active ? 0 : -1}
253
- ref={selectRef}
254
- role='combobox'
255
- aria-haspopup='listbox'
256
- aria-expanded={visibility}
257
- aria-controls={listboxId}
258
- aria-label={placeholder}
259
- aria-disabled={disabled || !hasOptions}
260
- {...(active && {
261
- onBlur: handleBlur,
262
- onFocus: handleFocus,
263
- onClick: handleToggle,
264
- onKeyDown: handleKeyDown
265
- })}
266
- >
267
- <div
268
- className={`rac-select-title ${(!error && !loading && selected?.type == 'boolean')
269
- ? selected.raw ? 'rac-true-option' : 'rac-false-option'
270
- : ''
271
- }`}
272
- >
273
- <span
274
- className='rac-title-text'
275
- key={title}
276
- >
277
- {title}
278
- </span>
279
- <SlideLeft
280
- visibility={loading && !error}
281
- duration={duration}
282
- >
283
- <span className='rac-loading-dots'>
284
- <i/><i/><i/>
285
- </span>
286
- </SlideLeft>
287
- </div>
288
- <div
289
- className='rac-select-buttons'
290
- >
291
- <SlideLeft
292
- visibility={hasActualValue && hasOptions && !disabled && !loading && !error}
293
- duration={duration}
294
- style={{display: 'grid'}}
295
- >
296
- {renderIcon(ClearIcon, {
297
- className: 'rac-select-cancel',
298
- onClick: (e) => clear(e)
299
- })}
300
- </SlideLeft>
301
- <SlideLeft
302
- visibility={active}
303
- duration={duration}
304
- style={{display: 'grid'}}
217
+
218
+ if (isHeader) {
219
+ currentGroupName = element.name
220
+ const open = expandedGroups.has(element.name)
221
+ const hasChildren = groupCounts[element.name] > 0
222
+
223
+ nodes.push(
224
+ <div
225
+ key={element.id}
226
+ className='rac-group-header'
227
+ onClick={(e) => selectOption(element, e)}
305
228
  >
306
- <span
307
- className={`rac-select-arrow-wrapper ${visibility ? '--open' : ''}`}
229
+ <span className='rac-group-title-text'>{element.name}</span>
230
+ <SlideLeft
231
+ visibility={hasChildren}
232
+ duration={duration}
233
+ style={{display: 'grid'}}
308
234
  >
309
- {renderIcon(ArrowIcon, {
310
- className: 'rac-select-arrow-wrapper'
311
- })}
312
- </span>
313
- </SlideLeft>
314
- </div>
315
- <Options
316
- visibility={visibility}
317
- selectRef={selectRef}
318
- onAnimationDone={() => setAnimationFinished(true)}
319
- unmount={unmount}
320
- duration={duration}
321
- easing={easing}
322
- offset={offset}
323
- animateOpacity={animateOpacity}
324
- >
325
- <div
326
- className='rac-select-list'
327
- role='listbox'
328
- aria-label='Options'
329
- >
330
- {renderOptions}
235
+ <span className={`rac-group-arrow-wrapper ${open ? '--open' : ''}`}>
236
+ {renderIcon(ArrowIcon, {className: 'rac-select-arrow-wrapper'})}
237
+ </span>
238
+ </SlideLeft>
331
239
  </div>
332
- </Options>
333
- </div>
334
- </SelectContext.Provider>
240
+ )
241
+ } else if (belongsToGroup) {
242
+ currentGroupChildren.push(createOptionNode(element, index))
243
+ } else {
244
+ nodes.push(createOptionNode(element, index))
245
+ }
246
+ })
247
+
248
+ flushGroup(currentGroupName)
249
+
250
+ return nodes
251
+ }, [
252
+ normalizedOptions, selectOption, selectId, selected, highlightedIndex,
253
+ loadingTitle, loadMoreText, invalidOption, setHighlightedIndex,
254
+ expandedGroups, ArrowIcon
255
+ ])
256
+
257
+ return (
258
+ <SelectJSX
259
+ selectRef={selectRef}
260
+ selectId={selectId}
261
+
262
+ renderIcon={renderIcon}
263
+ normalizedOptions={normalizedOptions}
264
+ renderOptions={renderOptions}
265
+ selected={selected}
266
+ title={title}
267
+ visibility={visibility}
268
+ active={active}
269
+ hasOptions={hasOptions}
270
+ hasActualValue={hasActualValue}
271
+ highlightedIndex={highlightedIndex}
272
+ animationFinished={animationFinished}
273
+
274
+ disabled={disabled}
275
+ loading={loading}
276
+ error={error}
277
+
278
+ setVisibility={setVisibility}
279
+ setHighlightedIndex={setHighlightedIndex}
280
+ setAnimationFinished={setAnimationFinished}
281
+ handleBlur={handleBlur}
282
+ handleFocus={handleFocus}
283
+ handleToggle={handleToggle}
284
+ handleKeyDown={handleKeyDown}
285
+ handleListScroll={handleListScroll}
286
+ selectOption={selectOption}
287
+ clear={clear}
288
+ registerOption={registerOption}
289
+ unregisterOption={unregisterOption}
290
+
291
+ children={children}
292
+ renderedDropdown={renderedDropdown}
293
+ placeholder={placeholder}
294
+ className={className}
295
+ style={style}
296
+ duration={duration}
297
+ easing={easing}
298
+ offset={offset}
299
+ animateOpacity={animateOpacity}
300
+ unmount={unmount}
301
+ ArrowIcon={ArrowIcon}
302
+ ClearIcon={ClearIcon}
303
+ hasMore={hasMore}
304
+ loadButton={loadButton}
305
+ />
335
306
  )
336
307
  })
337
308
 
@@ -1,3 +1,3 @@
1
1
  import {createContext} from 'react'
2
2
 
3
- export const SelectContext = createContext(null)
3
+ export const SelectContext = createContext(null)