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.
- package/README.md +31 -49
- package/demo/package-lock.json +5 -5
- package/demo/package.json +3 -2
- package/demo/src/App.tsx +56 -43
- package/demo/src/rac.css +6 -25
- package/dist/index.cjs.js +6 -6
- package/dist/index.css +1 -1
- package/dist/index.es.js +911 -769
- package/index.d.ts +10 -1
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/optgroup.jsx +36 -0
- package/src/option.jsx +29 -13
- package/src/options.jsx +11 -11
- package/src/select.css +39 -2
- package/src/select.jsx +203 -263
- package/src/selectContext.js +1 -1
- package/src/selectJSX.jsx +148 -0
- package/src/slideDown.jsx +36 -0
- package/src/useSelect.jsx +83 -118
- package/src/useSelectLogic.jsx +184 -139
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {memo} from 'react'
|
|
2
|
+
import {SelectContext} from './selectContext'
|
|
3
|
+
import Options from './options'
|
|
4
|
+
import SlideLeft from './slideLeft'
|
|
5
|
+
|
|
6
|
+
const SelectJSX = memo(({
|
|
7
|
+
selectRef,
|
|
8
|
+
selectId,
|
|
9
|
+
|
|
10
|
+
renderOptions,
|
|
11
|
+
selected,
|
|
12
|
+
title,
|
|
13
|
+
visibility,
|
|
14
|
+
active,
|
|
15
|
+
hasOptions,
|
|
16
|
+
hasActualValue,
|
|
17
|
+
|
|
18
|
+
disabled,
|
|
19
|
+
loading,
|
|
20
|
+
error,
|
|
21
|
+
|
|
22
|
+
registerOption,
|
|
23
|
+
unregisterOption,
|
|
24
|
+
handleBlur,
|
|
25
|
+
handleFocus,
|
|
26
|
+
handleToggle,
|
|
27
|
+
handleKeyDown,
|
|
28
|
+
handleListScroll,
|
|
29
|
+
setAnimationFinished,
|
|
30
|
+
clear,
|
|
31
|
+
|
|
32
|
+
children,
|
|
33
|
+
renderedDropdown,
|
|
34
|
+
placeholder,
|
|
35
|
+
className,
|
|
36
|
+
style,
|
|
37
|
+
duration,
|
|
38
|
+
easing,
|
|
39
|
+
offset,
|
|
40
|
+
animateOpacity,
|
|
41
|
+
unmount,
|
|
42
|
+
ArrowIcon,
|
|
43
|
+
ClearIcon,
|
|
44
|
+
renderIcon,
|
|
45
|
+
hasMore,
|
|
46
|
+
loadButton
|
|
47
|
+
}) => {
|
|
48
|
+
return (
|
|
49
|
+
<SelectContext.Provider
|
|
50
|
+
value={{registerOption, unregisterOption}}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
{renderedDropdown}
|
|
54
|
+
<div
|
|
55
|
+
ref={selectRef}
|
|
56
|
+
style={{'--rac-duration': `${duration}ms`, ...style}}
|
|
57
|
+
className={
|
|
58
|
+
`rac-select
|
|
59
|
+
${className}
|
|
60
|
+
${(!hasOptions || disabled) ? 'rac-disabled-style' : ''}
|
|
61
|
+
${loading ? 'rac-loading-style' : ''}
|
|
62
|
+
${error ? 'rac-error-style' : ''}`}
|
|
63
|
+
tabIndex={active ? 0 : -1}
|
|
64
|
+
role='combobox'
|
|
65
|
+
aria-haspopup='listbox'
|
|
66
|
+
aria-expanded={visibility}
|
|
67
|
+
aria-controls={`${selectId}-listbox`}
|
|
68
|
+
aria-label={placeholder}
|
|
69
|
+
aria-disabled={disabled || !hasOptions}
|
|
70
|
+
{...(active && {
|
|
71
|
+
onBlur: handleBlur,
|
|
72
|
+
onFocus: handleFocus,
|
|
73
|
+
onClick: handleToggle,
|
|
74
|
+
onKeyDown: handleKeyDown
|
|
75
|
+
})}
|
|
76
|
+
>
|
|
77
|
+
<div className={`rac-select-title ${(!error && !loading && selected?.type === 'boolean') ? (selected.raw ? 'rac-true-option' : 'rac-false-option') : ''}`}>
|
|
78
|
+
<span className='rac-title-text' key={title}>{title}</span>
|
|
79
|
+
<SlideLeft visibility={loading && !error} duration={duration}>
|
|
80
|
+
<span className='rac-loading-dots'><i/><i/><i/></span>
|
|
81
|
+
</SlideLeft>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div className='rac-select-buttons'>
|
|
85
|
+
<SlideLeft
|
|
86
|
+
visibility={hasActualValue && hasOptions && !disabled && !loading && !error}
|
|
87
|
+
duration={duration}
|
|
88
|
+
style={{display: 'grid'}}
|
|
89
|
+
>
|
|
90
|
+
{renderIcon(ClearIcon, {
|
|
91
|
+
className: 'rac-select-cancel',
|
|
92
|
+
onMouseDown: e => {
|
|
93
|
+
e.preventDefault()
|
|
94
|
+
e.stopPropagation()
|
|
95
|
+
},
|
|
96
|
+
onClick: clear
|
|
97
|
+
})}
|
|
98
|
+
</SlideLeft>
|
|
99
|
+
<SlideLeft
|
|
100
|
+
visibility={active}
|
|
101
|
+
duration={duration}
|
|
102
|
+
style={{display: 'grid'}}
|
|
103
|
+
>
|
|
104
|
+
<span
|
|
105
|
+
className={`rac-select-arrow-wrapper ${visibility ? '--open' : ''}`}
|
|
106
|
+
>
|
|
107
|
+
{renderIcon(ArrowIcon, {
|
|
108
|
+
className: 'rac-select-arrow-wrapper'
|
|
109
|
+
})}
|
|
110
|
+
</span>
|
|
111
|
+
</SlideLeft>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<Options
|
|
115
|
+
visibility={visibility}
|
|
116
|
+
selectRef={selectRef}
|
|
117
|
+
onAnimationDone={() => setAnimationFinished(true)}
|
|
118
|
+
unmount={unmount}
|
|
119
|
+
duration={duration}
|
|
120
|
+
easing={easing}
|
|
121
|
+
offset={offset}
|
|
122
|
+
animateOpacity={animateOpacity}
|
|
123
|
+
>
|
|
124
|
+
<div
|
|
125
|
+
onScroll={handleListScroll}
|
|
126
|
+
tabIndex='-1'
|
|
127
|
+
className='rac-select-list'
|
|
128
|
+
role='listbox'
|
|
129
|
+
aria-label='Options'
|
|
130
|
+
>
|
|
131
|
+
{renderOptions}
|
|
132
|
+
{!loadButton && hasMore && (
|
|
133
|
+
<div
|
|
134
|
+
className='rac-select-option rac-disabled-option rac-loading-option'
|
|
135
|
+
onClick={e => e.stopPropagation()}
|
|
136
|
+
>
|
|
137
|
+
<span className='rac-loading-option-title'>Loading</span>
|
|
138
|
+
<span className='rac-loading-dots'><i/><i/><i/></span>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</Options>
|
|
143
|
+
</div>
|
|
144
|
+
</SelectContext.Provider>
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
export default SelectJSX
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {CSSTransition} from 'react-transition-group'
|
|
2
|
+
import {useRef} from 'react'
|
|
3
|
+
|
|
4
|
+
function SlideDown({visibility, children, duration = 300}) {
|
|
5
|
+
const nodeRef = useRef(null)
|
|
6
|
+
|
|
7
|
+
return(
|
|
8
|
+
<CSSTransition
|
|
9
|
+
in={visibility}
|
|
10
|
+
timeout={300}
|
|
11
|
+
classNames='slideDown'
|
|
12
|
+
unmountOnExit
|
|
13
|
+
nodeRef={nodeRef}
|
|
14
|
+
onEnter={() => (nodeRef.current.style.height = '0px')}
|
|
15
|
+
onEntering={() => (nodeRef.current.style.height = nodeRef.current.scrollHeight + 'px')}
|
|
16
|
+
onEntered={() => (nodeRef.current.style.height = 'auto')}
|
|
17
|
+
onExit={() => (nodeRef.current.style.height = nodeRef.current.scrollHeight + 'px')}
|
|
18
|
+
onExiting={() => (nodeRef.current.style.height = '0px')}
|
|
19
|
+
>
|
|
20
|
+
<div
|
|
21
|
+
ref={nodeRef}
|
|
22
|
+
style={{
|
|
23
|
+
overflow: 'hidden',
|
|
24
|
+
transition: `height ${duration}ms ease`,
|
|
25
|
+
paddingLeft: '1em'
|
|
26
|
+
}}
|
|
27
|
+
className='slideDown-enter-done'
|
|
28
|
+
tabIndex={-1}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</div>
|
|
32
|
+
</CSSTransition>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default SlideDown
|
package/src/useSelect.jsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {useState, useRef, useCallback,
|
|
1
|
+
import {useState, useRef, useCallback, useEffect, useMemo} from 'react'
|
|
2
2
|
|
|
3
3
|
function useSelect({
|
|
4
4
|
disabled,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
options,
|
|
5
|
+
open,
|
|
6
|
+
setOpen,
|
|
7
|
+
options = [],
|
|
8
8
|
selectOption,
|
|
9
9
|
selected,
|
|
10
10
|
hasMore,
|
|
@@ -17,136 +17,118 @@ function useSelect({
|
|
|
17
17
|
}) {
|
|
18
18
|
const justFocused = useRef(false)
|
|
19
19
|
const lastWindowFocusTime = useRef(0)
|
|
20
|
-
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
21
|
-
|
|
22
20
|
const loadingTriggered = useRef(false)
|
|
21
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// loading state synchronization
|
|
26
24
|
useEffect(() => {
|
|
27
|
-
if
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
// flag is reset if value of the loadButton or hasMore props has changed
|
|
26
|
+
loadingTriggered.current = false
|
|
27
|
+
|
|
28
|
+
if (loadButton) {
|
|
29
|
+
setLoadingTitle(loadButtonText)
|
|
31
30
|
}
|
|
32
|
-
}, [options])
|
|
31
|
+
}, [options.length, hasMore, loadButton, loadButtonText, setLoadingTitle])
|
|
33
32
|
|
|
33
|
+
// safely call loadMore prop
|
|
34
34
|
const safeLoadMore = useCallback(() => {
|
|
35
35
|
if (!hasMore || loadingTriggered.current) return
|
|
36
|
-
|
|
37
36
|
loadingTriggered.current = true
|
|
38
37
|
loadMore()
|
|
39
38
|
}, [hasMore, loadMore])
|
|
40
39
|
|
|
40
|
+
// calling a function when scrolling almost to the end;
|
|
41
|
+
// loadOffset is a prop indicating how many pixels before end loadMore will be called
|
|
41
42
|
const handleListScroll = useCallback((e) => {
|
|
42
43
|
if (loadButton || !hasMore || loadingTriggered.current) return
|
|
43
44
|
|
|
44
45
|
const {scrollTop, scrollHeight, clientHeight} = e.currentTarget
|
|
45
|
-
|
|
46
|
-
const threshold = loadOffset
|
|
47
|
-
|
|
48
|
-
if (scrollHeight - scrollTop <= clientHeight + threshold) {
|
|
46
|
+
if (scrollHeight - scrollTop <= clientHeight + loadOffset) {
|
|
49
47
|
safeLoadMore()
|
|
50
48
|
}
|
|
51
|
-
}, [loadButton, hasMore, safeLoadMore])
|
|
49
|
+
}, [loadButton, hasMore, loadOffset, safeLoadMore])
|
|
52
50
|
|
|
51
|
+
// call a function when scrolling through options using keys;
|
|
52
|
+
// loadAhead prop how many options before the end it will be called
|
|
53
53
|
useEffect(() => {
|
|
54
|
-
if (loadButton)
|
|
55
|
-
|
|
56
|
-
if (isOpen && hasMore && highlightedIndex >= 0) {
|
|
57
|
-
const threshold = loadAhead
|
|
58
|
-
if (highlightedIndex >= options.length - threshold) {
|
|
59
|
-
safeLoadMore()
|
|
60
|
-
}
|
|
54
|
+
if (!loadButton && open && hasMore && highlightedIndex >= options.length - loadAhead) {
|
|
55
|
+
safeLoadMore()
|
|
61
56
|
}
|
|
62
|
-
}, [
|
|
57
|
+
}, [highlightedIndex, open, hasMore, options.length, loadAhead, loadButton, safeLoadMore])
|
|
63
58
|
|
|
59
|
+
// force refocus blocking if the user exits the browser or the page
|
|
64
60
|
useEffect(() => {
|
|
65
|
-
const handleWindowFocus = () => {
|
|
66
|
-
lastWindowFocusTime.current = Date.now()
|
|
67
|
-
}
|
|
61
|
+
const handleWindowFocus = () => {lastWindowFocusTime.current = Date.now()}
|
|
68
62
|
window.addEventListener('focus', handleWindowFocus)
|
|
69
63
|
return () => window.removeEventListener('focus', handleWindowFocus)
|
|
70
64
|
}, [])
|
|
71
65
|
|
|
66
|
+
// set highlighting to the first available option by default unless otherwise selected
|
|
72
67
|
useEffect(() => {
|
|
73
|
-
if (
|
|
74
|
-
if (highlightedIndex >= 0 && highlightedIndex < options.length) {
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let index = -1
|
|
79
|
-
if (selected) {
|
|
80
|
-
const selectedIndex = options.findIndex(o => o.id === selected.id && !o.disabled)
|
|
81
|
-
if (selectedIndex >= 0) index = selectedIndex
|
|
82
|
-
}
|
|
83
|
-
if (index === -1) {
|
|
84
|
-
index = options.findIndex(o => !o.disabled)
|
|
85
|
-
}
|
|
86
|
-
setHighlightedIndex(index)
|
|
87
|
-
} else {
|
|
68
|
+
if (!open) {
|
|
88
69
|
setHighlightedIndex(-1)
|
|
70
|
+
return
|
|
89
71
|
}
|
|
90
|
-
}, [isOpen, options])
|
|
91
72
|
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
setIsOpen(false)
|
|
95
|
-
}, [setIsOpen])
|
|
73
|
+
// blocking the reset of an index if it is already within the array (exmpl after loading)
|
|
74
|
+
if (highlightedIndex >= 0 && highlightedIndex < options.length) return
|
|
96
75
|
|
|
97
|
-
|
|
98
|
-
if (
|
|
76
|
+
let index = -1
|
|
77
|
+
if (selected) {
|
|
78
|
+
index = options.findIndex(o => o.id === selected.id && !o.disabled)
|
|
79
|
+
}
|
|
99
80
|
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
const timeSinceWindowFocus = Date.now() - lastWindowFocusTime.current
|
|
103
|
-
if (timeSinceWindowFocus < 100) return
|
|
104
|
-
|
|
105
|
-
if (!isOpen) {
|
|
106
|
-
setIsOpen(true)
|
|
107
|
-
justFocused.current = true
|
|
108
|
-
setTimeout(() => {
|
|
109
|
-
justFocused.current = false
|
|
110
|
-
}, 200)
|
|
81
|
+
if (index === -1) {
|
|
82
|
+
index = options.findIndex(o => !o.disabled)
|
|
111
83
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const handleToggle = useCallback((e) => {
|
|
115
|
-
if (disabled) return
|
|
116
|
-
if (e.target.closest && e.target.closest('.rac-select-cancel')) return
|
|
117
|
-
if (justFocused.current) return
|
|
118
|
-
|
|
119
|
-
setIsOpen(!isOpen)
|
|
120
|
-
}, [disabled, isOpen, setIsOpen])
|
|
84
|
+
setHighlightedIndex(index)
|
|
85
|
+
}, [open, options, selected])
|
|
121
86
|
|
|
122
|
-
|
|
87
|
+
// find the next available option to switch to using the keyboard
|
|
88
|
+
const getNextIndex = useCallback((current, direction) => {
|
|
123
89
|
const isNavigable = (opt) => opt && !opt.disabled && !opt.loading
|
|
124
|
-
|
|
125
|
-
if (
|
|
90
|
+
const len = options.length
|
|
91
|
+
if (len === 0) return -1
|
|
126
92
|
|
|
127
93
|
let next = current
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
if (
|
|
135
|
-
next
|
|
94
|
+
// я не шарю нихуя в математике
|
|
95
|
+
for (let i = 0; i < len; i++) {
|
|
96
|
+
next = (next + direction + len) % len
|
|
97
|
+
|
|
98
|
+
// if autoloading is active but loadButton is inactive, then infinite scrolling is blocked
|
|
99
|
+
if (!loadButton && hasMore) {
|
|
100
|
+
if (direction > 0 && next === 0) return current
|
|
101
|
+
if (direction < 0 && next === len - 1) return current
|
|
136
102
|
}
|
|
137
103
|
|
|
138
|
-
if (next
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
104
|
+
if (isNavigable(options[next])) return next
|
|
105
|
+
}
|
|
106
|
+
return current
|
|
107
|
+
}, [options, hasMore, loadButton])
|
|
142
108
|
|
|
143
|
-
|
|
144
|
-
|
|
109
|
+
// closing the selector if focus is lost
|
|
110
|
+
const handleBlur = useCallback((e) => {
|
|
111
|
+
if (!e.currentTarget.contains(e.relatedTarget)) setOpen(false)
|
|
112
|
+
}, [setOpen])
|
|
145
113
|
|
|
146
|
-
|
|
147
|
-
|
|
114
|
+
// opening the selector when receiving focus
|
|
115
|
+
const handleFocus = useCallback(() => {
|
|
116
|
+
if (disabled || document.hidden || (Date.now() - lastWindowFocusTime.current < 100)) return
|
|
117
|
+
|
|
118
|
+
if (!open) {
|
|
119
|
+
setOpen(true)
|
|
120
|
+
justFocused.current = true
|
|
121
|
+
setTimeout(() => {justFocused.current = false}, 200)
|
|
122
|
+
}
|
|
123
|
+
}, [disabled, open, setOpen])
|
|
148
124
|
|
|
125
|
+
// processing toggle click on select
|
|
126
|
+
const handleToggle = useCallback((e) => {
|
|
127
|
+
if (disabled || e.target.closest('.rac-select-cancel') || justFocused.current) return
|
|
128
|
+
setOpen(!open)
|
|
129
|
+
}, [disabled, open, setOpen])
|
|
149
130
|
|
|
131
|
+
// hotkey processing
|
|
150
132
|
const handleKeyDown = useCallback((e) => {
|
|
151
133
|
if (disabled) return
|
|
152
134
|
|
|
@@ -154,51 +136,34 @@ function useSelect({
|
|
|
154
136
|
case 'Enter':
|
|
155
137
|
case ' ':
|
|
156
138
|
e.preventDefault()
|
|
157
|
-
if (
|
|
139
|
+
if (open) {
|
|
158
140
|
if (highlightedIndex !== -1 && options[highlightedIndex]) {
|
|
159
141
|
selectOption(options[highlightedIndex], e)
|
|
160
142
|
}
|
|
161
|
-
} else
|
|
162
|
-
setIsOpen(true)
|
|
163
|
-
}
|
|
143
|
+
} else setOpen(true)
|
|
164
144
|
break
|
|
165
145
|
case 'Escape':
|
|
166
146
|
e.preventDefault()
|
|
167
|
-
|
|
147
|
+
setOpen(false)
|
|
168
148
|
break
|
|
169
149
|
case 'ArrowDown':
|
|
170
150
|
e.preventDefault()
|
|
171
|
-
|
|
172
|
-
setIsOpen(true)
|
|
173
|
-
} else {
|
|
174
|
-
setHighlightedIndex(prev => getNextIndex(prev, 1))
|
|
175
|
-
}
|
|
151
|
+
open ? setHighlightedIndex(prev => getNextIndex(prev, 1)) : setOpen(true)
|
|
176
152
|
break
|
|
177
153
|
case 'ArrowUp':
|
|
178
154
|
e.preventDefault()
|
|
179
|
-
|
|
180
|
-
setIsOpen(true)
|
|
181
|
-
} else {
|
|
182
|
-
setHighlightedIndex(prev => getNextIndex(prev, -1))
|
|
183
|
-
}
|
|
155
|
+
open ? setHighlightedIndex(prev => getNextIndex(prev, -1)) : setOpen(true)
|
|
184
156
|
break
|
|
185
157
|
case 'Tab':
|
|
186
|
-
if (
|
|
187
|
-
break
|
|
188
|
-
default:
|
|
158
|
+
if (open) setOpen(false)
|
|
189
159
|
break
|
|
190
160
|
}
|
|
191
|
-
}, [disabled,
|
|
161
|
+
}, [disabled, open, setOpen, highlightedIndex, options, selectOption, getNextIndex])
|
|
192
162
|
|
|
193
163
|
return useMemo(() => ({
|
|
194
|
-
handleBlur,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
handleKeyDown,
|
|
198
|
-
highlightedIndex,
|
|
199
|
-
setHighlightedIndex,
|
|
200
|
-
handleListScroll
|
|
201
|
-
}), [handleBlur, handleFocus, handleToggle, handleKeyDown, highlightedIndex, setHighlightedIndex])
|
|
164
|
+
handleBlur, handleFocus, handleToggle, handleKeyDown,
|
|
165
|
+
highlightedIndex, setHighlightedIndex, handleListScroll
|
|
166
|
+
}), [handleBlur, handleFocus, handleToggle, handleKeyDown, highlightedIndex, handleListScroll])
|
|
202
167
|
}
|
|
203
168
|
|
|
204
169
|
export default useSelect
|