@tamagui/select 1.0.1-beta.113 → 1.0.1-beta.116
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/dist/cjs/Select.js +61 -567
- package/dist/cjs/Select.js.map +2 -2
- package/dist/cjs/SelectContent.js +53 -0
- package/dist/cjs/SelectContent.js.map +7 -0
- package/dist/cjs/SelectContent.native.js +31 -0
- package/dist/cjs/SelectContent.native.js.map +7 -0
- package/dist/cjs/SelectImpl.js +393 -0
- package/dist/cjs/SelectImpl.js.map +7 -0
- package/dist/cjs/SelectScrollButton.js +128 -0
- package/dist/cjs/SelectScrollButton.js.map +7 -0
- package/dist/cjs/SelectScrollButton.native.js +32 -0
- package/dist/cjs/SelectScrollButton.native.js.map +7 -0
- package/dist/cjs/SelectViewport.js +96 -0
- package/dist/cjs/SelectViewport.js.map +7 -0
- package/dist/cjs/SelectViewport.native.js +43 -0
- package/dist/cjs/SelectViewport.native.js.map +7 -0
- package/dist/cjs/constants.js +47 -0
- package/dist/cjs/constants.js.map +7 -0
- package/dist/cjs/context.js +38 -0
- package/dist/cjs/context.js.map +7 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/index.js.map +2 -2
- package/dist/cjs/types.js +17 -0
- package/dist/cjs/types.js.map +7 -0
- package/dist/esm/Select.js +44 -574
- package/dist/esm/Select.js.map +2 -2
- package/dist/esm/SelectContent.js +26 -0
- package/dist/esm/SelectContent.js.map +7 -0
- package/dist/esm/SelectContent.native.js +7 -0
- package/dist/esm/SelectContent.native.js.map +7 -0
- package/dist/esm/SelectImpl.js +378 -0
- package/dist/esm/SelectImpl.js.map +7 -0
- package/dist/esm/SelectScrollButton.js +100 -0
- package/dist/esm/SelectScrollButton.js.map +7 -0
- package/dist/esm/SelectScrollButton.native.js +7 -0
- package/dist/esm/SelectScrollButton.native.js.map +7 -0
- package/dist/esm/SelectViewport.js +68 -0
- package/dist/esm/SelectViewport.js.map +7 -0
- package/dist/esm/SelectViewport.native.js +16 -0
- package/dist/esm/SelectViewport.native.js.map +7 -0
- package/dist/esm/constants.js +17 -0
- package/dist/esm/constants.js.map +7 -0
- package/dist/esm/context.js +11 -0
- package/dist/esm/context.js.map +7 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +2 -2
- package/dist/esm/types.js +1 -0
- package/dist/esm/types.js.map +7 -0
- package/dist/jsx/Select.js +43 -516
- package/dist/jsx/Select.js.map +2 -2
- package/dist/jsx/SelectContent.js +19 -0
- package/dist/jsx/SelectContent.js.map +7 -0
- package/dist/jsx/SelectContent.native.js +7 -0
- package/dist/jsx/SelectContent.native.js.map +7 -0
- package/dist/jsx/SelectImpl.js +366 -0
- package/dist/jsx/SelectImpl.js.map +7 -0
- package/dist/jsx/SelectScrollButton.js +78 -0
- package/dist/jsx/SelectScrollButton.js.map +7 -0
- package/dist/jsx/SelectScrollButton.native.js +7 -0
- package/dist/jsx/SelectScrollButton.native.js.map +7 -0
- package/dist/jsx/SelectViewport.js +57 -0
- package/dist/jsx/SelectViewport.js.map +7 -0
- package/dist/jsx/SelectViewport.native.js +14 -0
- package/dist/jsx/SelectViewport.native.js.map +7 -0
- package/dist/jsx/constants.js +17 -0
- package/dist/jsx/constants.js.map +7 -0
- package/dist/jsx/context.js +11 -0
- package/dist/jsx/context.js.map +7 -0
- package/dist/jsx/index.js +1 -0
- package/dist/jsx/index.js.map +2 -2
- package/dist/jsx/types.js +1 -0
- package/dist/jsx/types.js.map +7 -0
- package/package.json +12 -12
- package/src/Select.tsx +69 -890
- package/src/SelectContent.native.tsx +5 -0
- package/src/SelectContent.tsx +34 -0
- package/src/SelectImpl.tsx +511 -0
- package/src/SelectScrollButton.native.tsx +7 -0
- package/src/SelectScrollButton.tsx +150 -0
- package/src/SelectViewport.native.tsx +17 -0
- package/src/SelectViewport.tsx +86 -0
- package/src/constants.tsx +7 -0
- package/src/context.tsx +9 -0
- package/src/index.tsx +7 -0
- package/src/types.tsx +83 -0
- package/types/Select.d.ts +83 -241
- package/types/Select.d.ts.map +1 -1
- package/types/SelectContent.d.ts +4 -0
- package/types/SelectContent.d.ts.map +1 -0
- package/types/SelectContent.native.d.ts +4 -0
- package/types/SelectContent.native.d.ts.map +1 -0
- package/types/SelectImpl.d.ts +9 -0
- package/types/SelectImpl.d.ts.map +1 -0
- package/types/SelectScrollButton.d.ts +6 -0
- package/types/SelectScrollButton.d.ts.map +1 -0
- package/types/SelectScrollButton.native.d.ts +4 -0
- package/types/SelectScrollButton.native.d.ts.map +1 -0
- package/types/SelectViewport.d.ts +127 -0
- package/types/SelectViewport.d.ts.map +1 -0
- package/types/SelectViewport.native.d.ts +54 -0
- package/types/SelectViewport.native.d.ts.map +1 -0
- package/types/constants.d.ts +8 -0
- package/types/constants.d.ts.map +1 -0
- package/types/context.d.ts +17 -0
- package/types/context.d.ts.map +1 -0
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/types.d.ts +74 -0
- package/types/types.d.ts.map +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { FloatingOverlay, FloatingPortal } from '@floating-ui/react-dom-interactions'
|
|
2
|
+
import { Theme, useThemeName } from '@tamagui/core'
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import { useSelectContext } from './context'
|
|
6
|
+
import { useShowSelectSheet } from './Select'
|
|
7
|
+
import { SelectContentProps } from './types'
|
|
8
|
+
|
|
9
|
+
/* -------------------------------------------------------------------------------------------------
|
|
10
|
+
* SelectContent
|
|
11
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
12
|
+
|
|
13
|
+
const CONTENT_NAME = 'SelectContent'
|
|
14
|
+
|
|
15
|
+
export const SelectContent = ({ children, __scopeSelect }: SelectContentProps) => {
|
|
16
|
+
const context = useSelectContext(CONTENT_NAME, __scopeSelect)
|
|
17
|
+
const themeName = useThemeName()
|
|
18
|
+
const showSheet = useShowSelectSheet(context)
|
|
19
|
+
const contents = <Theme name={themeName}>{children}</Theme>
|
|
20
|
+
|
|
21
|
+
if (showSheet && context.open) {
|
|
22
|
+
return contents
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<FloatingPortal>
|
|
27
|
+
{context.open ? (
|
|
28
|
+
<FloatingOverlay lockScroll>{contents}</FloatingOverlay>
|
|
29
|
+
) : (
|
|
30
|
+
<div style={{ display: 'none' }}>{contents}</div>
|
|
31
|
+
)}
|
|
32
|
+
</FloatingPortal>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectOverflow,
|
|
3
|
+
flip,
|
|
4
|
+
offset,
|
|
5
|
+
size,
|
|
6
|
+
useClick,
|
|
7
|
+
useDismiss,
|
|
8
|
+
useFloating,
|
|
9
|
+
useInteractions,
|
|
10
|
+
useListNavigation,
|
|
11
|
+
useRole,
|
|
12
|
+
useTypeahead,
|
|
13
|
+
} from '@floating-ui/react-dom-interactions'
|
|
14
|
+
import { usePrevious } from '@radix-ui/react-use-previous'
|
|
15
|
+
import * as React from 'react'
|
|
16
|
+
|
|
17
|
+
import { FALLBACK_THRESHOLD, MIN_HEIGHT, SCROLL_ARROW_THRESHOLD, WINDOW_PADDING } from './constants'
|
|
18
|
+
import { SelectProvider, useSelectContext } from './context'
|
|
19
|
+
import { getVisualOffsetTop, isFirefox } from './Select'
|
|
20
|
+
import { ScopedProps, SelectProps } from './types'
|
|
21
|
+
|
|
22
|
+
export type SelectImplProps = ScopedProps<SelectProps> & {
|
|
23
|
+
activeIndexRef: any
|
|
24
|
+
selectedIndexRef: any
|
|
25
|
+
listContentRef: any
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// TODO use id for focusing from label
|
|
29
|
+
export const SelectInlineImpl = (props: SelectImplProps) => {
|
|
30
|
+
const {
|
|
31
|
+
__scopeSelect,
|
|
32
|
+
children,
|
|
33
|
+
open = false,
|
|
34
|
+
activeIndexRef,
|
|
35
|
+
selectedIndexRef,
|
|
36
|
+
listContentRef,
|
|
37
|
+
} = props
|
|
38
|
+
|
|
39
|
+
const selectContext = useSelectContext('SelectSheetImpl', __scopeSelect)
|
|
40
|
+
const { setActiveIndex, setOpen, setSelectedIndex, selectedIndex, activeIndex, forceUpdate } =
|
|
41
|
+
selectContext
|
|
42
|
+
const [showArrows, setShowArrows] = React.useState(false)
|
|
43
|
+
const [scrollTop, setScrollTop] = React.useState(0)
|
|
44
|
+
const prevActiveIndex = usePrevious<number | null>(activeIndex)
|
|
45
|
+
|
|
46
|
+
const listItemsRef = React.useRef<Array<HTMLElement | null>>([])
|
|
47
|
+
|
|
48
|
+
const [controlledScrolling, setControlledScrolling] = React.useState(false)
|
|
49
|
+
const [middlewareType, setMiddlewareType] = React.useState<'align' | 'fallback'>('align')
|
|
50
|
+
|
|
51
|
+
// Wait for scroll position to settle before showing arrows to prevent
|
|
52
|
+
// interference with pointer events.
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
const frame = requestAnimationFrame(() => {
|
|
55
|
+
setShowArrows(open)
|
|
56
|
+
|
|
57
|
+
if (!open) {
|
|
58
|
+
setScrollTop(0)
|
|
59
|
+
setMiddlewareType('align')
|
|
60
|
+
setActiveIndex(null)
|
|
61
|
+
setControlledScrolling(false)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
return () => {
|
|
65
|
+
cancelAnimationFrame(frame)
|
|
66
|
+
}
|
|
67
|
+
}, [open, setActiveIndex])
|
|
68
|
+
|
|
69
|
+
function getFloatingPadding(floating: HTMLElement | null) {
|
|
70
|
+
if (!floating) {
|
|
71
|
+
return 0
|
|
72
|
+
}
|
|
73
|
+
return Number(getComputedStyle(floating).paddingLeft?.replace('px', ''))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { x, y, reference, floating, strategy, context, refs, middlewareData, update } =
|
|
77
|
+
useFloating({
|
|
78
|
+
open,
|
|
79
|
+
onOpenChange: setOpen,
|
|
80
|
+
placement: 'bottom',
|
|
81
|
+
middleware:
|
|
82
|
+
middlewareType === 'align'
|
|
83
|
+
? [
|
|
84
|
+
offset(({ rects }) => {
|
|
85
|
+
const index = activeIndexRef.current ?? selectedIndexRef.current
|
|
86
|
+
|
|
87
|
+
if (index == null) {
|
|
88
|
+
return 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const item = listItemsRef.current[index]
|
|
92
|
+
|
|
93
|
+
if (item == null) {
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const offsetTop = item.offsetTop
|
|
98
|
+
const itemHeight = item.offsetHeight
|
|
99
|
+
const height = rects.reference.height
|
|
100
|
+
|
|
101
|
+
return -offsetTop - height - (itemHeight - height) / 2
|
|
102
|
+
}),
|
|
103
|
+
// Custom `size` that can handle the opposite direction of the placement
|
|
104
|
+
{
|
|
105
|
+
name: 'size',
|
|
106
|
+
async fn(args) {
|
|
107
|
+
const {
|
|
108
|
+
elements: { floating },
|
|
109
|
+
rects: { reference },
|
|
110
|
+
middlewareData,
|
|
111
|
+
} = args
|
|
112
|
+
|
|
113
|
+
const overflow = await detectOverflow(args, {
|
|
114
|
+
padding: WINDOW_PADDING,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const top = Math.max(0, overflow.top)
|
|
118
|
+
const bottom = Math.max(0, overflow.bottom)
|
|
119
|
+
const nextY = args.y + top
|
|
120
|
+
|
|
121
|
+
if (middlewareData.size?.skip) {
|
|
122
|
+
return {
|
|
123
|
+
y: nextY,
|
|
124
|
+
data: {
|
|
125
|
+
y: middlewareData.size.y,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
Object.assign(floating.style, {
|
|
131
|
+
maxHeight: `${floating.scrollHeight - Math.abs(top + bottom)}px`,
|
|
132
|
+
minWidth: `${reference.width + getFloatingPadding(floating) * 2}px`,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
y: nextY,
|
|
137
|
+
data: {
|
|
138
|
+
y: top,
|
|
139
|
+
skip: true,
|
|
140
|
+
},
|
|
141
|
+
reset: {
|
|
142
|
+
rects: true,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
: [
|
|
149
|
+
offset(5),
|
|
150
|
+
flip(),
|
|
151
|
+
size({
|
|
152
|
+
apply({ rects, availableHeight, elements }) {
|
|
153
|
+
Object.assign(elements.floating.style, {
|
|
154
|
+
width: `${rects.reference.width}px`,
|
|
155
|
+
maxHeight: `${availableHeight}px`,
|
|
156
|
+
})
|
|
157
|
+
},
|
|
158
|
+
padding: WINDOW_PADDING,
|
|
159
|
+
}),
|
|
160
|
+
],
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const floatingRef = refs.floating
|
|
164
|
+
|
|
165
|
+
const showUpArrow = showArrows && scrollTop > SCROLL_ARROW_THRESHOLD
|
|
166
|
+
const showDownArrow =
|
|
167
|
+
showArrows &&
|
|
168
|
+
floatingRef.current &&
|
|
169
|
+
scrollTop <
|
|
170
|
+
floatingRef.current.scrollHeight - floatingRef.current.clientHeight - SCROLL_ARROW_THRESHOLD
|
|
171
|
+
|
|
172
|
+
const interactions = useInteractions([
|
|
173
|
+
useClick(context, { pointerDown: true }),
|
|
174
|
+
useRole(context, { role: 'listbox' }),
|
|
175
|
+
useDismiss(context),
|
|
176
|
+
useListNavigation(context, {
|
|
177
|
+
listRef: listItemsRef,
|
|
178
|
+
activeIndex,
|
|
179
|
+
selectedIndex,
|
|
180
|
+
onNavigate: setActiveIndex,
|
|
181
|
+
}),
|
|
182
|
+
useTypeahead(context, {
|
|
183
|
+
listRef: listContentRef,
|
|
184
|
+
onMatch: open ? setActiveIndex : setSelectedIndex,
|
|
185
|
+
selectedIndex,
|
|
186
|
+
activeIndex,
|
|
187
|
+
}),
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
const increaseHeight = React.useCallback(
|
|
191
|
+
(floating: HTMLElement, amount = 0) => {
|
|
192
|
+
if (middlewareType === 'fallback') {
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const currentMaxHeight = Number(floating.style.maxHeight.replace('px', ''))
|
|
197
|
+
const currentTop = Number(floating.style.top.replace('px', ''))
|
|
198
|
+
const rect = floating.getBoundingClientRect()
|
|
199
|
+
const rectTop = rect.top
|
|
200
|
+
const rectBottom = rect.bottom
|
|
201
|
+
const viewportHeight = visualViewport?.height ?? 0
|
|
202
|
+
const visualMaxHeight = viewportHeight - WINDOW_PADDING * 2
|
|
203
|
+
|
|
204
|
+
if (
|
|
205
|
+
amount < 0 &&
|
|
206
|
+
selectedIndexRef.current != null &&
|
|
207
|
+
Math.round(rectBottom) < Math.round(viewportHeight + getVisualOffsetTop() - WINDOW_PADDING)
|
|
208
|
+
) {
|
|
209
|
+
floating.style.maxHeight = `${Math.min(visualMaxHeight, currentMaxHeight - amount)}px`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (
|
|
213
|
+
amount > 0 &&
|
|
214
|
+
Math.round(rectTop) > Math.round(WINDOW_PADDING - getVisualOffsetTop()) &&
|
|
215
|
+
floating.scrollHeight > floating.offsetHeight
|
|
216
|
+
) {
|
|
217
|
+
const nextTop = Math.max(WINDOW_PADDING + getVisualOffsetTop(), currentTop - amount)
|
|
218
|
+
|
|
219
|
+
const nextMaxHeight = Math.min(visualMaxHeight, currentMaxHeight + amount)
|
|
220
|
+
|
|
221
|
+
Object.assign(floating.style, {
|
|
222
|
+
maxHeight: `${nextMaxHeight}px`,
|
|
223
|
+
top: `${nextTop}px`,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
if (nextTop - WINDOW_PADDING > getVisualOffsetTop()) {
|
|
227
|
+
floating.scrollTop -= nextMaxHeight - currentMaxHeight + getFloatingPadding(floating)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return currentTop - nextTop
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
[middlewareType, selectedIndexRef]
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const touchPageYRef = React.useRef<number | null>(null)
|
|
237
|
+
|
|
238
|
+
const handleWheel = React.useCallback(
|
|
239
|
+
(event: WheelEvent | TouchEvent) => {
|
|
240
|
+
const pinching = event.ctrlKey
|
|
241
|
+
|
|
242
|
+
const currentTarget = event.currentTarget as HTMLElement
|
|
243
|
+
|
|
244
|
+
function isWheelEvent(event: any): event is WheelEvent {
|
|
245
|
+
return typeof event.deltaY === 'number'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isTouchEvent(event: any): event is TouchEvent {
|
|
249
|
+
return event.touches != null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (
|
|
253
|
+
Math.abs(
|
|
254
|
+
(currentTarget?.offsetHeight ?? 0) - ((visualViewport?.height ?? 0) - WINDOW_PADDING * 2)
|
|
255
|
+
) > 1 &&
|
|
256
|
+
!pinching
|
|
257
|
+
) {
|
|
258
|
+
event.preventDefault()
|
|
259
|
+
} else if (isWheelEvent(event) && isFirefox) {
|
|
260
|
+
// Firefox needs this to propagate scrolling
|
|
261
|
+
// during momentum scrolling phase if the
|
|
262
|
+
// height reached its maximum (at boundaries)
|
|
263
|
+
currentTarget.scrollTop += event.deltaY
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!pinching) {
|
|
267
|
+
let delta = 5
|
|
268
|
+
|
|
269
|
+
if (isTouchEvent(event)) {
|
|
270
|
+
const currentPageY = touchPageYRef.current
|
|
271
|
+
const pageY = event.touches[0]?.pageY
|
|
272
|
+
|
|
273
|
+
if (pageY != null) {
|
|
274
|
+
touchPageYRef.current = pageY
|
|
275
|
+
|
|
276
|
+
if (currentPageY != null) {
|
|
277
|
+
delta = currentPageY - pageY
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
increaseHeight(currentTarget, isWheelEvent(event) ? event.deltaY : delta)
|
|
283
|
+
setScrollTop(currentTarget.scrollTop)
|
|
284
|
+
// Ensure derived data (scroll arrows) is fresh
|
|
285
|
+
forceUpdate()
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
[forceUpdate, increaseHeight]
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
// Handle `onWheel` event in an effect to remove the `passive` option so we
|
|
292
|
+
// can .preventDefault() it
|
|
293
|
+
React.useEffect(() => {
|
|
294
|
+
function onTouchEnd() {
|
|
295
|
+
touchPageYRef.current = null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const floating = floatingRef.current
|
|
299
|
+
if (open && floating && middlewareType === 'align') {
|
|
300
|
+
floating.addEventListener('wheel', handleWheel)
|
|
301
|
+
floating.addEventListener('touchmove', handleWheel)
|
|
302
|
+
floating.addEventListener('touchend', onTouchEnd, { passive: true })
|
|
303
|
+
return () => {
|
|
304
|
+
floating.removeEventListener('wheel', handleWheel)
|
|
305
|
+
floating.removeEventListener('touchmove', handleWheel)
|
|
306
|
+
floating.removeEventListener('touchend', onTouchEnd)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}, [open, floatingRef, handleWheel, middlewareType])
|
|
310
|
+
|
|
311
|
+
// Ensure the menu remains attached to the reference element when resizing.
|
|
312
|
+
React.useEffect(() => {
|
|
313
|
+
window.addEventListener('resize', update)
|
|
314
|
+
return () => {
|
|
315
|
+
window.removeEventListener('resize', update)
|
|
316
|
+
}
|
|
317
|
+
}, [update])
|
|
318
|
+
|
|
319
|
+
// Scroll the active or selected item into view when in `controlledScrolling`
|
|
320
|
+
// mode (i.e. arrow key nav).
|
|
321
|
+
React.useLayoutEffect(() => {
|
|
322
|
+
const floating = floatingRef.current
|
|
323
|
+
|
|
324
|
+
if (open && controlledScrolling && floating) {
|
|
325
|
+
const item =
|
|
326
|
+
activeIndex != null
|
|
327
|
+
? listItemsRef.current[activeIndex]
|
|
328
|
+
: selectedIndex != null
|
|
329
|
+
? listItemsRef.current[selectedIndex]
|
|
330
|
+
: null
|
|
331
|
+
|
|
332
|
+
if (item && prevActiveIndex != null) {
|
|
333
|
+
const itemHeight = listItemsRef.current[prevActiveIndex]?.offsetHeight ?? 0
|
|
334
|
+
|
|
335
|
+
const floatingHeight = floating.offsetHeight
|
|
336
|
+
const top = item.offsetTop
|
|
337
|
+
const bottom = top + itemHeight
|
|
338
|
+
|
|
339
|
+
if (top < floating.scrollTop + 20) {
|
|
340
|
+
const diff = floating.scrollTop - top + 20
|
|
341
|
+
floating.scrollTop -= diff
|
|
342
|
+
|
|
343
|
+
if (activeIndex != selectedIndex && activeIndex != null) {
|
|
344
|
+
increaseHeight(floating, -diff)
|
|
345
|
+
}
|
|
346
|
+
} else if (bottom > floatingHeight + floating.scrollTop - 20) {
|
|
347
|
+
const diff = bottom - floatingHeight - floating.scrollTop + 20
|
|
348
|
+
|
|
349
|
+
floating.scrollTop += diff
|
|
350
|
+
|
|
351
|
+
if (activeIndex != selectedIndex && activeIndex != null) {
|
|
352
|
+
floating.scrollTop -= increaseHeight(floating, diff) ?? 0
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}, [
|
|
358
|
+
open,
|
|
359
|
+
controlledScrolling,
|
|
360
|
+
prevActiveIndex,
|
|
361
|
+
activeIndex,
|
|
362
|
+
selectedIndex,
|
|
363
|
+
floatingRef,
|
|
364
|
+
increaseHeight,
|
|
365
|
+
])
|
|
366
|
+
|
|
367
|
+
// Sync the height and the scrollTop values and device whether to use fallback
|
|
368
|
+
// positioning.
|
|
369
|
+
React.useLayoutEffect(() => {
|
|
370
|
+
const floating = refs.floating.current
|
|
371
|
+
const reference = refs.reference.current
|
|
372
|
+
|
|
373
|
+
if (open && floating && reference && floating.offsetHeight < floating.scrollHeight) {
|
|
374
|
+
const referenceRect = reference.getBoundingClientRect()
|
|
375
|
+
|
|
376
|
+
if (middlewareType === 'fallback') {
|
|
377
|
+
const item = listItemsRef.current[selectedIndex]
|
|
378
|
+
if (item) {
|
|
379
|
+
floating.scrollTop = item.offsetTop - floating.clientHeight + referenceRect.height
|
|
380
|
+
}
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
floating.scrollTop = middlewareData.size?.y
|
|
385
|
+
|
|
386
|
+
const closeToBottom =
|
|
387
|
+
(visualViewport?.height ?? 0) + getVisualOffsetTop() - referenceRect.bottom <
|
|
388
|
+
FALLBACK_THRESHOLD
|
|
389
|
+
const closeToTop = referenceRect.top < FALLBACK_THRESHOLD
|
|
390
|
+
|
|
391
|
+
if (floating.offsetHeight < MIN_HEIGHT || closeToTop || closeToBottom) {
|
|
392
|
+
setMiddlewareType('fallback')
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}, [
|
|
396
|
+
open,
|
|
397
|
+
increaseHeight,
|
|
398
|
+
selectedIndex,
|
|
399
|
+
middlewareType,
|
|
400
|
+
refs.floating,
|
|
401
|
+
refs.reference,
|
|
402
|
+
// Always re-run this effect when the position has been computed so the
|
|
403
|
+
// .scrollTop change works with fresh sizing.
|
|
404
|
+
middlewareData,
|
|
405
|
+
])
|
|
406
|
+
|
|
407
|
+
React.useLayoutEffect(() => {
|
|
408
|
+
if (open && selectedIndex != null) {
|
|
409
|
+
requestAnimationFrame(() => {
|
|
410
|
+
listItemsRef.current[selectedIndex]?.focus({ preventScroll: true })
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
}, [listItemsRef, selectedIndex, open])
|
|
414
|
+
|
|
415
|
+
// Wait for scroll position to settle before showing arrows to prevent
|
|
416
|
+
// interference with pointer events.
|
|
417
|
+
React.useEffect(() => {
|
|
418
|
+
const frame = requestAnimationFrame(() => {
|
|
419
|
+
setShowArrows(open)
|
|
420
|
+
|
|
421
|
+
if (!open) {
|
|
422
|
+
setScrollTop(0)
|
|
423
|
+
setMiddlewareType('align')
|
|
424
|
+
setActiveIndex(null)
|
|
425
|
+
setControlledScrolling(false)
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
return () => cancelAnimationFrame(frame)
|
|
429
|
+
}, [open, setActiveIndex])
|
|
430
|
+
|
|
431
|
+
// We set this to true by default so that events bubble to forms without JS (SSR)
|
|
432
|
+
// const isFormControl = trigger ? Boolean(trigger.closest('form')) : true
|
|
433
|
+
// const [bubbleSelect, setBubbleSelect] = React.useState<HTMLSelectElement | null>(null)
|
|
434
|
+
// const triggerPointerDownPosRef = React.useRef<{ x: number; y: number } | null>(null)
|
|
435
|
+
return (
|
|
436
|
+
<SelectProvider
|
|
437
|
+
scope={__scopeSelect}
|
|
438
|
+
{...(selectContext as Required<typeof selectContext>)}
|
|
439
|
+
increaseHeight={increaseHeight}
|
|
440
|
+
floatingRef={floatingRef}
|
|
441
|
+
setValueAtIndex={(index, value) => {
|
|
442
|
+
listContentRef.current[index] = value
|
|
443
|
+
}}
|
|
444
|
+
interactions={{
|
|
445
|
+
...interactions,
|
|
446
|
+
getReferenceProps() {
|
|
447
|
+
return interactions.getReferenceProps({
|
|
448
|
+
ref: reference,
|
|
449
|
+
className: 'SelectTrigger',
|
|
450
|
+
onKeyDown(event) {
|
|
451
|
+
if (event.key === 'Enter' || (event.key === ' ' && !context.dataRef.current.typing)) {
|
|
452
|
+
event.preventDefault()
|
|
453
|
+
setOpen(true)
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
})
|
|
457
|
+
},
|
|
458
|
+
getFloatingProps(props) {
|
|
459
|
+
return interactions.getFloatingProps({
|
|
460
|
+
ref: floating,
|
|
461
|
+
className: 'Select',
|
|
462
|
+
...props,
|
|
463
|
+
style: {
|
|
464
|
+
position: strategy,
|
|
465
|
+
top: y ?? '',
|
|
466
|
+
left: x ?? '',
|
|
467
|
+
outline: 0,
|
|
468
|
+
listStyleType: 'none',
|
|
469
|
+
scrollbarWidth: 'none',
|
|
470
|
+
userSelect: 'none',
|
|
471
|
+
...props?.style,
|
|
472
|
+
},
|
|
473
|
+
onPointerEnter() {
|
|
474
|
+
setControlledScrolling(false)
|
|
475
|
+
},
|
|
476
|
+
onPointerMove() {
|
|
477
|
+
setControlledScrolling(false)
|
|
478
|
+
},
|
|
479
|
+
onKeyDown() {
|
|
480
|
+
setControlledScrolling(true)
|
|
481
|
+
},
|
|
482
|
+
onScroll(event) {
|
|
483
|
+
setScrollTop(event.currentTarget.scrollTop)
|
|
484
|
+
},
|
|
485
|
+
})
|
|
486
|
+
},
|
|
487
|
+
}}
|
|
488
|
+
floatingContext={context}
|
|
489
|
+
activeIndex={activeIndex}
|
|
490
|
+
canScrollDown={!!showDownArrow}
|
|
491
|
+
canScrollUp={!!showUpArrow}
|
|
492
|
+
controlledScrolling
|
|
493
|
+
dataRef={context.dataRef}
|
|
494
|
+
listRef={listItemsRef}
|
|
495
|
+
>
|
|
496
|
+
{children}
|
|
497
|
+
{/* {isFormControl ? (
|
|
498
|
+
<BubbleSelect
|
|
499
|
+
ref={setBubbleSelect}
|
|
500
|
+
aria-hidden
|
|
501
|
+
tabIndex={-1}
|
|
502
|
+
name={name}
|
|
503
|
+
autoComplete={autoComplete}
|
|
504
|
+
value={value}
|
|
505
|
+
// enable form autofill
|
|
506
|
+
onChange={(event) => setValue(event.target.value)}
|
|
507
|
+
/>
|
|
508
|
+
) : null} */}
|
|
509
|
+
</SelectProvider>
|
|
510
|
+
)
|
|
511
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ScopedProps, SelectScrollButtonProps } from './types'
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
4
|
+
export const SelectScrollUpButton = (_: ScopedProps<SelectScrollButtonProps>) => null
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
export const SelectScrollDownButton = (_: ScopedProps<SelectScrollButtonProps>) => null
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { autoUpdate, offset, useFloating } from '@floating-ui/react-dom-interactions'
|
|
2
|
+
import { useComposedRefs } from '@tamagui/compose-refs'
|
|
3
|
+
import { TamaguiElement } from '@tamagui/core'
|
|
4
|
+
import { YStack } from '@tamagui/stacks'
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
|
|
7
|
+
import { SCROLL_ARROW_THRESHOLD, SCROLL_ARROW_VELOCITY } from './constants'
|
|
8
|
+
import { useSelectContext } from './context'
|
|
9
|
+
import { ScopedProps, SelectScrollButtonImplProps, SelectScrollButtonProps } from './types'
|
|
10
|
+
|
|
11
|
+
/* -------------------------------------------------------------------------------------------------
|
|
12
|
+
* SelectScrollUpButton
|
|
13
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
14
|
+
|
|
15
|
+
const SCROLL_UP_BUTTON_NAME = 'SelectScrollUpButton'
|
|
16
|
+
|
|
17
|
+
export const SelectScrollUpButton = React.forwardRef<TamaguiElement, SelectScrollButtonProps>(
|
|
18
|
+
(props: ScopedProps<SelectScrollButtonProps>, forwardedRef) => {
|
|
19
|
+
return (
|
|
20
|
+
<SelectScrollButtonImpl
|
|
21
|
+
componentName={SCROLL_UP_BUTTON_NAME}
|
|
22
|
+
{...props}
|
|
23
|
+
dir="up"
|
|
24
|
+
ref={forwardedRef}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
SelectScrollUpButton.displayName = SCROLL_UP_BUTTON_NAME
|
|
31
|
+
|
|
32
|
+
/* -------------------------------------------------------------------------------------------------
|
|
33
|
+
* SelectScrollDownButton
|
|
34
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
35
|
+
|
|
36
|
+
const SCROLL_DOWN_BUTTON_NAME = 'SelectScrollDownButton'
|
|
37
|
+
|
|
38
|
+
export const SelectScrollDownButton = React.forwardRef<TamaguiElement, SelectScrollButtonProps>(
|
|
39
|
+
(props: ScopedProps<SelectScrollButtonProps>, forwardedRef) => {
|
|
40
|
+
return (
|
|
41
|
+
<SelectScrollButtonImpl
|
|
42
|
+
componentName={SCROLL_DOWN_BUTTON_NAME}
|
|
43
|
+
{...props}
|
|
44
|
+
dir="down"
|
|
45
|
+
ref={forwardedRef}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
SelectScrollDownButton.displayName = SCROLL_DOWN_BUTTON_NAME
|
|
52
|
+
|
|
53
|
+
type SelectScrollButtonImplElement = TamaguiElement
|
|
54
|
+
|
|
55
|
+
const SelectScrollButtonImpl = React.memo(
|
|
56
|
+
React.forwardRef<SelectScrollButtonImplElement, SelectScrollButtonImplProps>(
|
|
57
|
+
(props: ScopedProps<SelectScrollButtonImplProps>, forwardedRef) => {
|
|
58
|
+
const { __scopeSelect, dir, componentName, ...scrollIndicatorProps } = props
|
|
59
|
+
const { floatingRef, increaseHeight, forceUpdate, ...context } = useSelectContext(
|
|
60
|
+
componentName,
|
|
61
|
+
__scopeSelect
|
|
62
|
+
)
|
|
63
|
+
const intervalRef = React.useRef<any>()
|
|
64
|
+
const loopingRef = React.useRef(false)
|
|
65
|
+
const isVisible = context[dir === 'down' ? 'canScrollDown' : 'canScrollUp']
|
|
66
|
+
|
|
67
|
+
const { x, y, reference, floating, strategy, update, refs } = useFloating({
|
|
68
|
+
strategy: 'fixed',
|
|
69
|
+
placement: dir === 'up' ? 'top' : 'bottom',
|
|
70
|
+
middleware: [offset(({ rects }) => -rects.floating.height)],
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const composedRefs = useComposedRefs(forwardedRef, floating)
|
|
74
|
+
|
|
75
|
+
React.useLayoutEffect(() => {
|
|
76
|
+
if (!floatingRef) return
|
|
77
|
+
reference(floatingRef.current)
|
|
78
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
|
+
}, [reference, floatingRef?.current])
|
|
80
|
+
|
|
81
|
+
React.useEffect(() => {
|
|
82
|
+
if (!refs.reference.current || !refs.floating.current || !isVisible) {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const cleanup = autoUpdate(refs.reference.current, refs.floating.current, update, {
|
|
87
|
+
animationFrame: true,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return () => {
|
|
91
|
+
clearInterval(intervalRef.current)
|
|
92
|
+
loopingRef.current = false
|
|
93
|
+
cleanup()
|
|
94
|
+
}
|
|
95
|
+
}, [isVisible, update, refs.floating, refs.reference])
|
|
96
|
+
|
|
97
|
+
if (!isVisible) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const handleScrollArrowChange = () => {
|
|
102
|
+
if (!floatingRef) return
|
|
103
|
+
const floating = floatingRef.current
|
|
104
|
+
const isUp = dir === 'up'
|
|
105
|
+
|
|
106
|
+
if (floating) {
|
|
107
|
+
const value = isUp ? -SCROLL_ARROW_VELOCITY : SCROLL_ARROW_VELOCITY
|
|
108
|
+
const multi =
|
|
109
|
+
(isUp && floating.scrollTop <= SCROLL_ARROW_THRESHOLD * 2) ||
|
|
110
|
+
(!isUp &&
|
|
111
|
+
floating.scrollTop >=
|
|
112
|
+
floating.scrollHeight - floating.clientHeight - SCROLL_ARROW_THRESHOLD * 2)
|
|
113
|
+
? 2
|
|
114
|
+
: 1
|
|
115
|
+
|
|
116
|
+
floating.scrollTop += multi * (isUp ? -SCROLL_ARROW_VELOCITY : SCROLL_ARROW_VELOCITY)
|
|
117
|
+
|
|
118
|
+
increaseHeight?.(floating, multi === 2 ? value * 2 : value)
|
|
119
|
+
// Ensure derived data (scroll arrows) is fresh
|
|
120
|
+
forceUpdate()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<YStack
|
|
126
|
+
ref={composedRefs}
|
|
127
|
+
componentName={componentName}
|
|
128
|
+
aria-hidden
|
|
129
|
+
{...scrollIndicatorProps}
|
|
130
|
+
zIndex={1000}
|
|
131
|
+
// @ts-expect-error
|
|
132
|
+
position={strategy}
|
|
133
|
+
left={x || 0}
|
|
134
|
+
top={y || 0}
|
|
135
|
+
width={`calc(${(floatingRef?.current?.offsetWidth ?? 0) - 2}px)`}
|
|
136
|
+
onPointerMove={() => {
|
|
137
|
+
if (!loopingRef.current) {
|
|
138
|
+
intervalRef.current = setInterval(handleScrollArrowChange, 1000 / 60)
|
|
139
|
+
loopingRef.current = true
|
|
140
|
+
}
|
|
141
|
+
}}
|
|
142
|
+
onPointerLeave={() => {
|
|
143
|
+
loopingRef.current = false
|
|
144
|
+
clearInterval(intervalRef.current)
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
)
|