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/README.md +53 -29
- package/demo/README.md +16 -0
- package/demo/eslint.config.js +29 -0
- package/demo/index.html +13 -0
- package/demo/package-lock.json +3436 -0
- package/demo/package.json +38 -0
- package/demo/public/vite.svg +1 -0
- package/demo/src/App.tsx +425 -0
- package/demo/src/main.jsx +9 -0
- package/demo/src/rac.css +735 -0
- package/demo/src/shake.js +11 -0
- package/demo/src/slideDown.jsx +35 -0
- package/demo/vite.config.js +7 -0
- package/dist/index.cjs.js +6 -9
- package/dist/index.css +1 -1
- package/dist/index.es.js +926 -687
- package/index.d.ts +20 -2
- package/package.json +3 -2
- package/src/index.js +1 -0
- package/src/optgroup.jsx +36 -0
- package/src/option.jsx +31 -11
- package/src/options.jsx +11 -11
- package/src/select.css +45 -1
- package/src/select.jsx +209 -238
- package/src/selectContext.js +1 -1
- package/src/selectJSX.jsx +148 -0
- package/src/slideDown.jsx +36 -0
- package/src/useSelect.jsx +113 -79
- package/src/useSelectLogic.jsx +215 -131
|
@@ -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,84 +1,134 @@
|
|
|
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
|
-
selected
|
|
9
|
+
selected,
|
|
10
|
+
hasMore,
|
|
11
|
+
loadMore,
|
|
12
|
+
loadButton,
|
|
13
|
+
loadButtonText,
|
|
14
|
+
setLoadingTitle,
|
|
15
|
+
loadOffset,
|
|
16
|
+
loadAhead
|
|
10
17
|
}) {
|
|
11
18
|
const justFocused = useRef(false)
|
|
12
19
|
const lastWindowFocusTime = useRef(0)
|
|
20
|
+
const loadingTriggered = useRef(false)
|
|
13
21
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
14
22
|
|
|
23
|
+
// loading state synchronization
|
|
15
24
|
useEffect(() => {
|
|
16
|
-
|
|
17
|
-
|
|
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)
|
|
30
|
+
}
|
|
31
|
+
}, [options.length, hasMore, loadButton, loadButtonText, setLoadingTitle])
|
|
32
|
+
|
|
33
|
+
// safely call loadMore prop
|
|
34
|
+
const safeLoadMore = useCallback(() => {
|
|
35
|
+
if (!hasMore || loadingTriggered.current) return
|
|
36
|
+
loadingTriggered.current = true
|
|
37
|
+
loadMore()
|
|
38
|
+
}, [hasMore, loadMore])
|
|
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
|
|
42
|
+
const handleListScroll = useCallback((e) => {
|
|
43
|
+
if (loadButton || !hasMore || loadingTriggered.current) return
|
|
44
|
+
|
|
45
|
+
const {scrollTop, scrollHeight, clientHeight} = e.currentTarget
|
|
46
|
+
if (scrollHeight - scrollTop <= clientHeight + loadOffset) {
|
|
47
|
+
safeLoadMore()
|
|
48
|
+
}
|
|
49
|
+
}, [loadButton, hasMore, loadOffset, safeLoadMore])
|
|
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
|
+
useEffect(() => {
|
|
54
|
+
if (!loadButton && open && hasMore && highlightedIndex >= options.length - loadAhead) {
|
|
55
|
+
safeLoadMore()
|
|
18
56
|
}
|
|
57
|
+
}, [highlightedIndex, open, hasMore, options.length, loadAhead, loadButton, safeLoadMore])
|
|
58
|
+
|
|
59
|
+
// force refocus blocking if the user exits the browser or the page
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const handleWindowFocus = () => {lastWindowFocusTime.current = Date.now()}
|
|
19
62
|
window.addEventListener('focus', handleWindowFocus)
|
|
20
63
|
return () => window.removeEventListener('focus', handleWindowFocus)
|
|
21
64
|
}, [])
|
|
22
65
|
|
|
66
|
+
// set highlighting to the first available option by default unless otherwise selected
|
|
23
67
|
useEffect(() => {
|
|
24
|
-
if (
|
|
25
|
-
let index = -1
|
|
26
|
-
if (selected) {
|
|
27
|
-
const selectedIndex = options.findIndex(o => o.id === selected.id && !o.disabled)
|
|
28
|
-
if (selectedIndex >= 0) index = selectedIndex
|
|
29
|
-
}
|
|
30
|
-
if (index === -1) {
|
|
31
|
-
index = options.findIndex(o => !o.disabled)
|
|
32
|
-
}
|
|
33
|
-
setHighlightedIndex(index)
|
|
34
|
-
} else {
|
|
68
|
+
if (!open) {
|
|
35
69
|
setHighlightedIndex(-1)
|
|
70
|
+
return
|
|
36
71
|
}
|
|
37
|
-
}, [isOpen, selected, options])
|
|
38
72
|
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
setIsOpen(false)
|
|
42
|
-
}, [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
|
|
43
75
|
|
|
44
|
-
|
|
45
|
-
if (
|
|
76
|
+
let index = -1
|
|
77
|
+
if (selected) {
|
|
78
|
+
index = options.findIndex(o => o.id === selected.id && !o.disabled)
|
|
79
|
+
}
|
|
46
80
|
|
|
47
|
-
if (
|
|
81
|
+
if (index === -1) {
|
|
82
|
+
index = options.findIndex(o => !o.disabled)
|
|
83
|
+
}
|
|
84
|
+
setHighlightedIndex(index)
|
|
85
|
+
}, [open, options, selected])
|
|
48
86
|
|
|
49
|
-
|
|
50
|
-
|
|
87
|
+
// find the next available option to switch to using the keyboard
|
|
88
|
+
const getNextIndex = useCallback((current, direction) => {
|
|
89
|
+
const isNavigable = (opt) => opt && !opt.disabled && !opt.loading
|
|
90
|
+
const len = options.length
|
|
91
|
+
if (len === 0) return -1
|
|
51
92
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
93
|
+
let next = current
|
|
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
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isNavigable(options[next])) return next
|
|
58
105
|
}
|
|
59
|
-
|
|
106
|
+
return current
|
|
107
|
+
}, [options, hasMore, loadButton])
|
|
60
108
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (e.
|
|
64
|
-
|
|
109
|
+
// closing the selector if focus is lost
|
|
110
|
+
const handleBlur = useCallback((e) => {
|
|
111
|
+
if (!e.currentTarget.contains(e.relatedTarget)) setOpen(false)
|
|
112
|
+
}, [setOpen])
|
|
65
113
|
|
|
66
|
-
|
|
67
|
-
|
|
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])
|
|
68
124
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
next += direction
|
|
75
|
-
if (next < 0) next = options.length - 1
|
|
76
|
-
if (next >= options.length) next = 0
|
|
77
|
-
loops++
|
|
78
|
-
} while (options[next]?.disabled && loops <= options.length)
|
|
79
|
-
return next
|
|
80
|
-
}
|
|
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])
|
|
81
130
|
|
|
131
|
+
// hotkey processing
|
|
82
132
|
const handleKeyDown = useCallback((e) => {
|
|
83
133
|
if (disabled) return
|
|
84
134
|
|
|
@@ -86,50 +136,34 @@ function useSelect({
|
|
|
86
136
|
case 'Enter':
|
|
87
137
|
case ' ':
|
|
88
138
|
e.preventDefault()
|
|
89
|
-
if (
|
|
139
|
+
if (open) {
|
|
90
140
|
if (highlightedIndex !== -1 && options[highlightedIndex]) {
|
|
91
141
|
selectOption(options[highlightedIndex], e)
|
|
92
142
|
}
|
|
93
|
-
} else
|
|
94
|
-
setIsOpen(true)
|
|
95
|
-
}
|
|
143
|
+
} else setOpen(true)
|
|
96
144
|
break
|
|
97
145
|
case 'Escape':
|
|
98
146
|
e.preventDefault()
|
|
99
|
-
|
|
147
|
+
setOpen(false)
|
|
100
148
|
break
|
|
101
149
|
case 'ArrowDown':
|
|
102
150
|
e.preventDefault()
|
|
103
|
-
|
|
104
|
-
setIsOpen(true)
|
|
105
|
-
} else {
|
|
106
|
-
setHighlightedIndex(prev => getNextIndex(prev, 1))
|
|
107
|
-
}
|
|
151
|
+
open ? setHighlightedIndex(prev => getNextIndex(prev, 1)) : setOpen(true)
|
|
108
152
|
break
|
|
109
153
|
case 'ArrowUp':
|
|
110
154
|
e.preventDefault()
|
|
111
|
-
|
|
112
|
-
setIsOpen(true)
|
|
113
|
-
} else {
|
|
114
|
-
setHighlightedIndex(prev => getNextIndex(prev, -1))
|
|
115
|
-
}
|
|
155
|
+
open ? setHighlightedIndex(prev => getNextIndex(prev, -1)) : setOpen(true)
|
|
116
156
|
break
|
|
117
157
|
case 'Tab':
|
|
118
|
-
if (
|
|
119
|
-
break
|
|
120
|
-
default:
|
|
158
|
+
if (open) setOpen(false)
|
|
121
159
|
break
|
|
122
160
|
}
|
|
123
|
-
}, [disabled,
|
|
161
|
+
}, [disabled, open, setOpen, highlightedIndex, options, selectOption, getNextIndex])
|
|
124
162
|
|
|
125
163
|
return useMemo(() => ({
|
|
126
|
-
handleBlur,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
handleKeyDown,
|
|
130
|
-
highlightedIndex,
|
|
131
|
-
setHighlightedIndex
|
|
132
|
-
}), [handleBlur, handleFocus, handleToggle, handleKeyDown, highlightedIndex, setHighlightedIndex])
|
|
164
|
+
handleBlur, handleFocus, handleToggle, handleKeyDown,
|
|
165
|
+
highlightedIndex, setHighlightedIndex, handleListScroll
|
|
166
|
+
}), [handleBlur, handleFocus, handleToggle, handleKeyDown, highlightedIndex, handleListScroll])
|
|
133
167
|
}
|
|
134
168
|
|
|
135
169
|
export default useSelect
|