@tamagui/select 1.0.1-beta.57 → 1.0.1-beta.61
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/package.json +11 -10
- package/src/BubbleSelect.tsx +46 -0
- package/src/Select.tsx +1364 -0
- package/src/index.tsx +1 -0
package/src/Select.tsx
ADDED
|
@@ -0,0 +1,1364 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ContextData,
|
|
3
|
+
FloatingContext,
|
|
4
|
+
FloatingFocusManager,
|
|
5
|
+
FloatingOverlay,
|
|
6
|
+
FloatingPortal,
|
|
7
|
+
ReferenceType,
|
|
8
|
+
autoUpdate,
|
|
9
|
+
detectOverflow,
|
|
10
|
+
flip,
|
|
11
|
+
offset,
|
|
12
|
+
size,
|
|
13
|
+
useClick,
|
|
14
|
+
useDismiss,
|
|
15
|
+
useFloating,
|
|
16
|
+
useInteractions,
|
|
17
|
+
useListNavigation,
|
|
18
|
+
useRole,
|
|
19
|
+
useTypeahead,
|
|
20
|
+
} from '@floating-ui/react-dom-interactions'
|
|
21
|
+
import { useCallbackRef } from '@radix-ui/react-use-callback-ref'
|
|
22
|
+
import { usePrevious } from '@radix-ui/react-use-previous'
|
|
23
|
+
import { useComposedRefs } from '@tamagui/compose-refs'
|
|
24
|
+
import {
|
|
25
|
+
GetProps,
|
|
26
|
+
SizeTokens,
|
|
27
|
+
Text,
|
|
28
|
+
VariantSpreadExtras,
|
|
29
|
+
VariantSpreadFunction,
|
|
30
|
+
composeEventHandlers,
|
|
31
|
+
getVariableValue,
|
|
32
|
+
styled,
|
|
33
|
+
useIsomorphicLayoutEffect,
|
|
34
|
+
withStaticProperties,
|
|
35
|
+
} from '@tamagui/core'
|
|
36
|
+
// import { useDirection } from '@tamagui/react-direction'
|
|
37
|
+
// import { DismissableLayer } from '@tamagui/react-dismissable-layer'
|
|
38
|
+
// import { FocusScope } from '@tamagui/react-focus-scope'
|
|
39
|
+
import { useId } from '@tamagui/core'
|
|
40
|
+
import { createContextScope } from '@tamagui/create-context'
|
|
41
|
+
import type { Scope } from '@tamagui/create-context'
|
|
42
|
+
import { clamp } from '@tamagui/helpers'
|
|
43
|
+
import { ListItem, ListItemProps } from '@tamagui/list-item'
|
|
44
|
+
// import { useLabelContext } from '@tamagui/react-label'
|
|
45
|
+
import { Portal } from '@tamagui/portal'
|
|
46
|
+
import { Separator } from '@tamagui/separator'
|
|
47
|
+
import { ThemeableStack, XStack, YStack, YStackProps } from '@tamagui/stacks'
|
|
48
|
+
import { Paragraph } from '@tamagui/text'
|
|
49
|
+
// import { Primitive } from '@tamagui/react-primitive'
|
|
50
|
+
// import type * as Radix from '@tamagui/react-primitive'
|
|
51
|
+
import { useControllableState } from '@tamagui/use-controllable-state'
|
|
52
|
+
// import { useLayoutEffect } from '@tamagui/react-use-layout-effect'
|
|
53
|
+
// import { usePrevious } from '@tamagui/react-use-previous'
|
|
54
|
+
// import { VisuallyHidden } from '@tamagui/core'
|
|
55
|
+
// import { hideOthers } from 'aria-hidden'
|
|
56
|
+
import * as React from 'react'
|
|
57
|
+
import * as ReactDOM from 'react-dom'
|
|
58
|
+
import { View } from 'react-native'
|
|
59
|
+
|
|
60
|
+
// import { RemoveScroll } from 'react-remove-scroll'
|
|
61
|
+
|
|
62
|
+
type TamaguiElement = HTMLElement | View
|
|
63
|
+
|
|
64
|
+
// Cross browser fixes for pinch-zooming/backdrop-filter 🙄
|
|
65
|
+
const userAgent = (typeof navigator !== 'undefined' && navigator.userAgent) || ''
|
|
66
|
+
const isFirefox = userAgent.toLowerCase().includes('firefox')
|
|
67
|
+
if (isFirefox) {
|
|
68
|
+
document.body.classList.add('firefox')
|
|
69
|
+
}
|
|
70
|
+
function getVisualOffsetTop() {
|
|
71
|
+
return !/^((?!chrome|android).)*safari/i.test(userAgent) ? visualViewport.offsetTop : 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const getSelectItemSize = (val: SizeTokens, { tokens }: VariantSpreadExtras<any>) => {
|
|
75
|
+
const padding = getVariableValue(tokens.size[val])
|
|
76
|
+
return {
|
|
77
|
+
paddingHorizontal: padding / 2,
|
|
78
|
+
paddingVertical: padding / 4,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* -------------------------------------------------------------------------------------------------
|
|
83
|
+
* SelectContext
|
|
84
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
85
|
+
|
|
86
|
+
const SELECT_NAME = 'Select'
|
|
87
|
+
const WINDOW_PADDING = 8
|
|
88
|
+
const SCROLL_ARROW_VELOCITY = 8
|
|
89
|
+
const SCROLL_ARROW_THRESHOLD = 8
|
|
90
|
+
const MIN_HEIGHT = 80
|
|
91
|
+
const FALLBACK_THRESHOLD = 16
|
|
92
|
+
|
|
93
|
+
interface SelectContextValue {
|
|
94
|
+
value: any
|
|
95
|
+
selectedIndex: number
|
|
96
|
+
setSelectedIndex: (index: number) => void
|
|
97
|
+
activeIndex: number | null
|
|
98
|
+
setActiveIndex: (index: number | null) => void
|
|
99
|
+
setValueAtIndex: (index: number, value: string) => void
|
|
100
|
+
listRef: React.MutableRefObject<Array<HTMLElement | null>>
|
|
101
|
+
floatingRef: React.MutableRefObject<HTMLElement | null>
|
|
102
|
+
open: boolean
|
|
103
|
+
setOpen: (open: boolean) => void
|
|
104
|
+
onChange: (value: string) => void
|
|
105
|
+
dataRef: React.MutableRefObject<ContextData>
|
|
106
|
+
controlledScrolling: boolean
|
|
107
|
+
valueNode: Element | null
|
|
108
|
+
onValueNodeChange(node: HTMLElement): void
|
|
109
|
+
valueNodeHasChildren: boolean
|
|
110
|
+
onValueNodeHasChildrenChange(hasChildren: boolean): void
|
|
111
|
+
canScrollUp: boolean
|
|
112
|
+
canScrollDown: boolean
|
|
113
|
+
floatingContext: FloatingContext<ReferenceType>
|
|
114
|
+
increaseHeight: (floating: HTMLElement, amount?: any) => number | undefined
|
|
115
|
+
forceUpdate: React.DispatchWithoutAction
|
|
116
|
+
interactions: {
|
|
117
|
+
getReferenceProps: (userProps?: React.HTMLProps<Element> | undefined) => any
|
|
118
|
+
getFloatingProps: (userProps?: React.HTMLProps<HTMLElement> | undefined) => any
|
|
119
|
+
getItemProps: (userProps?: React.HTMLProps<HTMLElement> | undefined) => any
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type ScopedProps<P> = P & { __scopeSelect?: Scope }
|
|
124
|
+
|
|
125
|
+
const [createSelectContext, createSelectScope] = createContextScope(SELECT_NAME)
|
|
126
|
+
const [SelectProvider, useSelectContext] = createSelectContext<SelectContextValue>(SELECT_NAME)
|
|
127
|
+
|
|
128
|
+
// const [SelectContentContextProvider, useSelectContentContext] =
|
|
129
|
+
// createSelectContext<SelectContentContextValue>(CONTENT_NAME);
|
|
130
|
+
|
|
131
|
+
type GenericElement = HTMLElement | View
|
|
132
|
+
|
|
133
|
+
type Direction = 'ltr' | 'rtl'
|
|
134
|
+
|
|
135
|
+
const OPEN_KEYS = [' ', 'Enter', 'ArrowUp', 'ArrowDown']
|
|
136
|
+
const SELECTION_KEYS = [' ', 'Enter']
|
|
137
|
+
|
|
138
|
+
/* -------------------------------------------------------------------------------------------------
|
|
139
|
+
* SelectTrigger
|
|
140
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
141
|
+
|
|
142
|
+
const TRIGGER_NAME = 'SelectTrigger'
|
|
143
|
+
|
|
144
|
+
export type SelectTriggerProps = ListItemProps
|
|
145
|
+
|
|
146
|
+
export const SelectTrigger = React.forwardRef<GenericElement, SelectTriggerProps>(
|
|
147
|
+
(props: ScopedProps<SelectTriggerProps>, forwardedRef) => {
|
|
148
|
+
const {
|
|
149
|
+
__scopeSelect,
|
|
150
|
+
disabled = false,
|
|
151
|
+
// @ts-ignore
|
|
152
|
+
'aria-labelledby': ariaLabelledby,
|
|
153
|
+
...triggerProps
|
|
154
|
+
} = props
|
|
155
|
+
const context = useSelectContext(TRIGGER_NAME, __scopeSelect)
|
|
156
|
+
// const composedRefs = useComposedRefs(forwardedRef, context.onTriggerChange)
|
|
157
|
+
// const getItems = useCollection(__scopeSelect)
|
|
158
|
+
// const labelId = useLabelContext(context.trigger)
|
|
159
|
+
// const labelledBy = ariaLabelledby || labelId
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<ListItem
|
|
163
|
+
backgrounded
|
|
164
|
+
radiused
|
|
165
|
+
hoverable
|
|
166
|
+
pressable
|
|
167
|
+
focusable
|
|
168
|
+
tabIndex={-1}
|
|
169
|
+
borderWidth={1}
|
|
170
|
+
componentName={TRIGGER_NAME}
|
|
171
|
+
// aria-controls={context.contentId}
|
|
172
|
+
aria-expanded={context.open}
|
|
173
|
+
aria-autocomplete="none"
|
|
174
|
+
// aria-labelledby={labelledBy}
|
|
175
|
+
// dir={context.dir}
|
|
176
|
+
disabled={disabled}
|
|
177
|
+
data-disabled={disabled ? '' : undefined}
|
|
178
|
+
{...triggerProps}
|
|
179
|
+
ref={forwardedRef}
|
|
180
|
+
{...context.interactions.getReferenceProps()}
|
|
181
|
+
/>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
SelectTrigger.displayName = TRIGGER_NAME
|
|
187
|
+
|
|
188
|
+
/* -------------------------------------------------------------------------------------------------
|
|
189
|
+
* SelectValue
|
|
190
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
191
|
+
|
|
192
|
+
const VALUE_NAME = 'SelectValue'
|
|
193
|
+
|
|
194
|
+
const SelectValueFrame = styled(Paragraph, {
|
|
195
|
+
name: VALUE_NAME,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
type SelectValueProps = GetProps<typeof SelectValueFrame> & {
|
|
199
|
+
placeholder?: React.ReactNode
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const SelectValue = SelectValueFrame.extractable(
|
|
203
|
+
React.forwardRef<TamaguiElement, SelectValueProps>(
|
|
204
|
+
({ __scopeSelect, children, placeholder }: ScopedProps<SelectValueProps>, forwardedRef) => {
|
|
205
|
+
// We ignore `className` and `style` as this part shouldn't be styled.
|
|
206
|
+
const context = useSelectContext(VALUE_NAME, __scopeSelect)
|
|
207
|
+
const { onValueNodeHasChildrenChange } = context
|
|
208
|
+
const hasChildren = children !== undefined
|
|
209
|
+
const composedRefs = useComposedRefs(forwardedRef, context.onValueNodeChange)
|
|
210
|
+
|
|
211
|
+
React.useLayoutEffect(() => {
|
|
212
|
+
onValueNodeHasChildrenChange(hasChildren)
|
|
213
|
+
}, [onValueNodeHasChildrenChange, hasChildren])
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<SelectValueFrame
|
|
217
|
+
ref={composedRefs}
|
|
218
|
+
// we don't want events from the portalled `SelectValue` children to bubble
|
|
219
|
+
// through the item they came from
|
|
220
|
+
pointerEvents="none"
|
|
221
|
+
>
|
|
222
|
+
{context.value === undefined && placeholder !== undefined ? placeholder : children}
|
|
223
|
+
</SelectValueFrame>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
SelectValue.displayName = VALUE_NAME
|
|
230
|
+
|
|
231
|
+
/* -------------------------------------------------------------------------------------------------
|
|
232
|
+
* SelectIcon
|
|
233
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
234
|
+
|
|
235
|
+
export const SelectIcon = styled(XStack, {
|
|
236
|
+
name: 'SelectIcon',
|
|
237
|
+
// @ts-ignore
|
|
238
|
+
'aria-hidden': true,
|
|
239
|
+
children: <Paragraph>▼</Paragraph>,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
/* -------------------------------------------------------------------------------------------------
|
|
243
|
+
* SelectContent
|
|
244
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
245
|
+
|
|
246
|
+
const CONTENT_NAME = 'SelectContent'
|
|
247
|
+
|
|
248
|
+
type SelectContentElement = any
|
|
249
|
+
type SelectContentProps = any
|
|
250
|
+
|
|
251
|
+
const SelectContent = React.forwardRef<SelectContentElement, SelectContentProps>(
|
|
252
|
+
({ children, __scopeSelect }: ScopedProps<SelectContentProps>, forwardedRef) => {
|
|
253
|
+
const context = useSelectContext(CONTENT_NAME, __scopeSelect)
|
|
254
|
+
return (
|
|
255
|
+
<FloatingPortal>
|
|
256
|
+
{context.open ? (
|
|
257
|
+
<FloatingOverlay lockScroll>{children}</FloatingOverlay>
|
|
258
|
+
) : (
|
|
259
|
+
<div style={{ display: 'none' }}>{children}</div>
|
|
260
|
+
)}
|
|
261
|
+
</FloatingPortal>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
/* -------------------------------------------------------------------------------------------------
|
|
267
|
+
* SelectViewport
|
|
268
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
269
|
+
|
|
270
|
+
const VIEWPORT_NAME = 'SelectViewport'
|
|
271
|
+
|
|
272
|
+
export const SelectViewportFrame = styled(ThemeableStack, {
|
|
273
|
+
name: VIEWPORT_NAME,
|
|
274
|
+
backgroundColor: '$background',
|
|
275
|
+
elevate: true,
|
|
276
|
+
overflow: 'hidden',
|
|
277
|
+
|
|
278
|
+
// works on web just not typed
|
|
279
|
+
// @ts-ignore
|
|
280
|
+
userSelect: 'none',
|
|
281
|
+
|
|
282
|
+
variants: {
|
|
283
|
+
size: {
|
|
284
|
+
'...size': (val, { tokens }) => {
|
|
285
|
+
return {
|
|
286
|
+
borderRadius: tokens.size[val] ?? val,
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
defaultVariants: {
|
|
293
|
+
size: '$2',
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
export type SelectViewportProps = GetProps<typeof SelectViewportFrame>
|
|
298
|
+
|
|
299
|
+
const SelectViewport = React.forwardRef<TamaguiElement, SelectViewportProps>(
|
|
300
|
+
(props: ScopedProps<SelectViewportProps>, forwardedRef) => {
|
|
301
|
+
const { __scopeSelect, children, ...viewportProps } = props
|
|
302
|
+
const context = useSelectContext(VIEWPORT_NAME, __scopeSelect)
|
|
303
|
+
const composedRefs = useComposedRefs(
|
|
304
|
+
forwardedRef
|
|
305
|
+
// contentContext.onViewportChange
|
|
306
|
+
)
|
|
307
|
+
const prevScrollTopRef = React.useRef(0)
|
|
308
|
+
return (
|
|
309
|
+
<>
|
|
310
|
+
{/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */}
|
|
311
|
+
<style
|
|
312
|
+
dangerouslySetInnerHTML={{
|
|
313
|
+
__html: `[data-radix-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-select-viewport]::-webkit-scrollbar{display:none}`,
|
|
314
|
+
}}
|
|
315
|
+
/>
|
|
316
|
+
<FloatingFocusManager context={context.floatingContext} preventTabbing>
|
|
317
|
+
<div {...context.interactions.getFloatingProps()}>
|
|
318
|
+
<SelectViewportFrame
|
|
319
|
+
data-radix-select-viewport=""
|
|
320
|
+
// @ts-ignore
|
|
321
|
+
role="presentation"
|
|
322
|
+
{...viewportProps}
|
|
323
|
+
ref={composedRefs}
|
|
324
|
+
>
|
|
325
|
+
{children}
|
|
326
|
+
</SelectViewportFrame>
|
|
327
|
+
</div>
|
|
328
|
+
</FloatingFocusManager>
|
|
329
|
+
</>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
SelectViewport.displayName = VIEWPORT_NAME
|
|
335
|
+
|
|
336
|
+
/* -------------------------------------------------------------------------------------------------
|
|
337
|
+
* SelectItem
|
|
338
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
339
|
+
|
|
340
|
+
const ITEM_NAME = 'SelectItem'
|
|
341
|
+
|
|
342
|
+
type SelectItemContextValue = {
|
|
343
|
+
value: string
|
|
344
|
+
textId: string
|
|
345
|
+
isSelected: boolean
|
|
346
|
+
onItemTextChange(node: TamaguiElement | null): void
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const [SelectItemContextProvider, useSelectItemContext] =
|
|
350
|
+
createSelectContext<SelectItemContextValue>(ITEM_NAME)
|
|
351
|
+
|
|
352
|
+
interface SelectItemProps extends YStackProps {
|
|
353
|
+
value: string
|
|
354
|
+
index: number
|
|
355
|
+
disabled?: boolean
|
|
356
|
+
textValue?: string
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// const SelectItemFrame = styled(YStack, {
|
|
360
|
+
// name: ITEM_NAME,
|
|
361
|
+
// tag: 'li',
|
|
362
|
+
// // @ts-ignore
|
|
363
|
+
// role: 'option',
|
|
364
|
+
// width: '100%',
|
|
365
|
+
|
|
366
|
+
// hoverStyle: {
|
|
367
|
+
// backgroundColor: '$backgroundHover',
|
|
368
|
+
// },
|
|
369
|
+
|
|
370
|
+
// focusStyle: {
|
|
371
|
+
// backgroundColor: '$backgroundFocus',
|
|
372
|
+
// },
|
|
373
|
+
|
|
374
|
+
// variants: {
|
|
375
|
+
// size: {
|
|
376
|
+
// '...size': getSelectItemSize,
|
|
377
|
+
// },
|
|
378
|
+
// },
|
|
379
|
+
|
|
380
|
+
// defaultVariants: {
|
|
381
|
+
// size: '$4',
|
|
382
|
+
// },
|
|
383
|
+
// })
|
|
384
|
+
|
|
385
|
+
const SelectItem = React.forwardRef<TamaguiElement, SelectItemProps>(
|
|
386
|
+
(props: ScopedProps<SelectItemProps>, forwardedRef) => {
|
|
387
|
+
const {
|
|
388
|
+
__scopeSelect,
|
|
389
|
+
value,
|
|
390
|
+
disabled = false,
|
|
391
|
+
textValue: textValueProp,
|
|
392
|
+
index,
|
|
393
|
+
...itemProps
|
|
394
|
+
} = props
|
|
395
|
+
const context = useSelectContext(ITEM_NAME, __scopeSelect)
|
|
396
|
+
const isSelected = context.value === value
|
|
397
|
+
const [textValue, setTextValue] = React.useState(textValueProp ?? '')
|
|
398
|
+
const [isFocused, setIsFocused] = React.useState(false)
|
|
399
|
+
const textId = useId()
|
|
400
|
+
|
|
401
|
+
const {
|
|
402
|
+
selectedIndex,
|
|
403
|
+
setSelectedIndex,
|
|
404
|
+
listRef,
|
|
405
|
+
open,
|
|
406
|
+
setOpen,
|
|
407
|
+
onChange,
|
|
408
|
+
activeIndex,
|
|
409
|
+
setActiveIndex,
|
|
410
|
+
setValueAtIndex,
|
|
411
|
+
dataRef,
|
|
412
|
+
} = context
|
|
413
|
+
|
|
414
|
+
const composedRefs = useComposedRefs(
|
|
415
|
+
forwardedRef,
|
|
416
|
+
(node) => {
|
|
417
|
+
if (node instanceof HTMLElement) {
|
|
418
|
+
listRef.current[index] = node
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// isSelected ? context.onSelectedItemChange : undefined
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
React.useEffect(() => {
|
|
425
|
+
setValueAtIndex(index, value)
|
|
426
|
+
}, [index])
|
|
427
|
+
|
|
428
|
+
const timeoutRef = React.useRef<any>()
|
|
429
|
+
const [allowMouseUp, setAllowMouseUp] = React.useState(false)
|
|
430
|
+
|
|
431
|
+
function handleSelect() {
|
|
432
|
+
setSelectedIndex(index)
|
|
433
|
+
onChange(value)
|
|
434
|
+
setOpen(false)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
React.useEffect(() => {
|
|
438
|
+
clearTimeout(timeoutRef.current)
|
|
439
|
+
if (open) {
|
|
440
|
+
if (selectedIndex !== index) {
|
|
441
|
+
setAllowMouseUp(true)
|
|
442
|
+
} else {
|
|
443
|
+
timeoutRef.current = setTimeout(() => {
|
|
444
|
+
setAllowMouseUp(true)
|
|
445
|
+
}, 300)
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
setAllowMouseUp(false)
|
|
449
|
+
}
|
|
450
|
+
}, [open, index, setActiveIndex, selectedIndex])
|
|
451
|
+
|
|
452
|
+
function handleKeyDown(event: React.KeyboardEvent) {
|
|
453
|
+
if (event.key === 'Enter' || (event.key === ' ' && !dataRef.current.typing)) {
|
|
454
|
+
event.preventDefault()
|
|
455
|
+
handleSelect()
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const selectItemProps = context.interactions.getItemProps({
|
|
460
|
+
onClick: allowMouseUp ? handleSelect : undefined,
|
|
461
|
+
onMouseUp: allowMouseUp ? handleSelect : undefined,
|
|
462
|
+
onKeyDown: handleKeyDown,
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<SelectItemContextProvider
|
|
467
|
+
scope={__scopeSelect}
|
|
468
|
+
value={value}
|
|
469
|
+
textId={textId || ''}
|
|
470
|
+
isSelected={isSelected}
|
|
471
|
+
onItemTextChange={React.useCallback((node) => {
|
|
472
|
+
// setTextValue((prevTextValue) => prevTextValue || (node?.textContent ?? '').trim())
|
|
473
|
+
}, [])}
|
|
474
|
+
>
|
|
475
|
+
<ListItem
|
|
476
|
+
backgrounded
|
|
477
|
+
hoverable
|
|
478
|
+
pressable
|
|
479
|
+
focusable
|
|
480
|
+
componentName={ITEM_NAME}
|
|
481
|
+
ref={composedRefs}
|
|
482
|
+
aria-labelledby={textId}
|
|
483
|
+
// `isFocused` caveat fixes stuttering in VoiceOver
|
|
484
|
+
aria-selected={isSelected && isFocused}
|
|
485
|
+
data-state={isSelected ? 'active' : 'inactive'}
|
|
486
|
+
aria-disabled={disabled || undefined}
|
|
487
|
+
data-disabled={disabled ? '' : undefined}
|
|
488
|
+
tabIndex={disabled ? undefined : -1}
|
|
489
|
+
{...itemProps}
|
|
490
|
+
{...selectItemProps}
|
|
491
|
+
/>
|
|
492
|
+
</SelectItemContextProvider>
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
SelectItem.displayName = ITEM_NAME
|
|
498
|
+
|
|
499
|
+
/* -------------------------------------------------------------------------------------------------
|
|
500
|
+
* SelectItemText
|
|
501
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
502
|
+
|
|
503
|
+
const ITEM_TEXT_NAME = 'SelectItemText'
|
|
504
|
+
|
|
505
|
+
const SelectItemTextFrame = styled(Paragraph, {
|
|
506
|
+
name: ITEM_TEXT_NAME,
|
|
507
|
+
cursor: 'default',
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
type SelectItemTextProps = GetProps<typeof SelectItemTextFrame>
|
|
511
|
+
|
|
512
|
+
const SelectItemText = React.forwardRef<TamaguiElement, SelectItemTextProps>(
|
|
513
|
+
(props: ScopedProps<SelectItemTextProps>, forwardedRef) => {
|
|
514
|
+
// We ignore `className` and `style` as this part shouldn't be styled.
|
|
515
|
+
const { __scopeSelect, className, style, ...itemTextProps } = props
|
|
516
|
+
const context = useSelectContext(ITEM_TEXT_NAME, __scopeSelect)
|
|
517
|
+
const itemContext = useSelectItemContext(ITEM_TEXT_NAME, __scopeSelect)
|
|
518
|
+
const ref = React.useRef<TamaguiElement | null>(null)
|
|
519
|
+
const composedRefs = useComposedRefs(
|
|
520
|
+
forwardedRef,
|
|
521
|
+
ref,
|
|
522
|
+
itemContext.onItemTextChange
|
|
523
|
+
// itemContext.isSelected ? contentContext.onSelectedItemTextChange : undefined
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<>
|
|
528
|
+
<SelectItemTextFrame id={itemContext.textId} {...itemTextProps} ref={composedRefs} />
|
|
529
|
+
|
|
530
|
+
{/* Portal the select item text into the trigger value node */}
|
|
531
|
+
{itemContext.isSelected && context.valueNode && !context.valueNodeHasChildren
|
|
532
|
+
? ReactDOM.createPortal(itemTextProps.children, context.valueNode)
|
|
533
|
+
: null}
|
|
534
|
+
|
|
535
|
+
{/* Portal an option in the bubble select */}
|
|
536
|
+
{/* {context.bubbleSelect
|
|
537
|
+
? ReactDOM.createPortal(
|
|
538
|
+
// we use `.textContent` because `option` only support `string` or `number`
|
|
539
|
+
<option value={itemContext.value}>{ref.current?.textContent}</option>,
|
|
540
|
+
context.bubbleSelect
|
|
541
|
+
)
|
|
542
|
+
: null} */}
|
|
543
|
+
</>
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
SelectItemText.displayName = ITEM_TEXT_NAME
|
|
549
|
+
|
|
550
|
+
/* -------------------------------------------------------------------------------------------------
|
|
551
|
+
* SelectItemIndicator
|
|
552
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
553
|
+
|
|
554
|
+
const ITEM_INDICATOR_NAME = 'SelectItemIndicator'
|
|
555
|
+
|
|
556
|
+
const SelectItemIndicatorFrame = styled(XStack, {
|
|
557
|
+
name: ITEM_TEXT_NAME,
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
type SelectItemIndicatorProps = GetProps<typeof SelectItemIndicatorFrame>
|
|
561
|
+
|
|
562
|
+
const SelectItemIndicator = React.forwardRef<TamaguiElement, SelectItemIndicatorProps>(
|
|
563
|
+
(props: ScopedProps<SelectItemIndicatorProps>, forwardedRef) => {
|
|
564
|
+
const { __scopeSelect, ...itemIndicatorProps } = props
|
|
565
|
+
const itemContext = useSelectItemContext(ITEM_INDICATOR_NAME, __scopeSelect)
|
|
566
|
+
return itemContext.isSelected ? (
|
|
567
|
+
<SelectItemIndicatorFrame aria-hidden {...itemIndicatorProps} ref={forwardedRef} />
|
|
568
|
+
) : null
|
|
569
|
+
}
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
SelectItemIndicator.displayName = ITEM_INDICATOR_NAME
|
|
573
|
+
|
|
574
|
+
/* -------------------------------------------------------------------------------------------------
|
|
575
|
+
* SelectGroup
|
|
576
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
577
|
+
|
|
578
|
+
const GROUP_NAME = 'SelectGroup'
|
|
579
|
+
|
|
580
|
+
type SelectGroupContextValue = { id: string }
|
|
581
|
+
|
|
582
|
+
const [SelectGroupContextProvider, useSelectGroupContext] =
|
|
583
|
+
createSelectContext<SelectGroupContextValue>(GROUP_NAME)
|
|
584
|
+
|
|
585
|
+
const SelectGroupFrame = styled(YStack, {
|
|
586
|
+
name: GROUP_NAME,
|
|
587
|
+
width: '100%',
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
type SelectGroupProps = GetProps<typeof SelectGroupFrame>
|
|
591
|
+
|
|
592
|
+
const SelectGroup = React.forwardRef<TamaguiElement, SelectGroupProps>(
|
|
593
|
+
(props: ScopedProps<SelectGroupProps>, forwardedRef) => {
|
|
594
|
+
const { __scopeSelect, ...groupProps } = props
|
|
595
|
+
const groupId = useId()
|
|
596
|
+
return (
|
|
597
|
+
<SelectGroupContextProvider scope={__scopeSelect} id={groupId || ''}>
|
|
598
|
+
<SelectGroupFrame
|
|
599
|
+
// @ts-ignore
|
|
600
|
+
role="group"
|
|
601
|
+
aria-labelledby={groupId}
|
|
602
|
+
{...groupProps}
|
|
603
|
+
ref={forwardedRef}
|
|
604
|
+
/>
|
|
605
|
+
</SelectGroupContextProvider>
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
SelectGroup.displayName = GROUP_NAME
|
|
611
|
+
|
|
612
|
+
/* -------------------------------------------------------------------------------------------------
|
|
613
|
+
* SelectLabel
|
|
614
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
615
|
+
|
|
616
|
+
const LABEL_NAME = 'SelectLabel'
|
|
617
|
+
|
|
618
|
+
export type SelectLabelProps = ListItemProps
|
|
619
|
+
|
|
620
|
+
const SelectLabel = React.forwardRef<TamaguiElement, SelectLabelProps>(
|
|
621
|
+
(props: ScopedProps<SelectLabelProps>, forwardedRef) => {
|
|
622
|
+
const { __scopeSelect, ...labelProps } = props
|
|
623
|
+
const groupContext = useSelectGroupContext(LABEL_NAME, __scopeSelect)
|
|
624
|
+
return (
|
|
625
|
+
<ListItem
|
|
626
|
+
componentName={LABEL_NAME}
|
|
627
|
+
fontWeight="800"
|
|
628
|
+
id={groupContext.id}
|
|
629
|
+
{...labelProps}
|
|
630
|
+
// @ts-expect-error
|
|
631
|
+
ref={forwardedRef}
|
|
632
|
+
/>
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
SelectLabel.displayName = LABEL_NAME
|
|
638
|
+
|
|
639
|
+
/* -------------------------------------------------------------------------------------------------
|
|
640
|
+
* SelectScrollUpButton
|
|
641
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
642
|
+
|
|
643
|
+
const SCROLL_UP_BUTTON_NAME = 'SelectScrollUpButton'
|
|
644
|
+
|
|
645
|
+
interface SelectScrollButtonProps
|
|
646
|
+
extends Omit<SelectScrollButtonImplProps, 'dir' | 'componentName'> {}
|
|
647
|
+
|
|
648
|
+
const SelectScrollUpButton = React.forwardRef<TamaguiElement, SelectScrollButtonProps>(
|
|
649
|
+
(props: ScopedProps<SelectScrollButtonProps>, forwardedRef) => {
|
|
650
|
+
return (
|
|
651
|
+
<SelectScrollButtonImpl
|
|
652
|
+
componentName={SCROLL_UP_BUTTON_NAME}
|
|
653
|
+
{...props}
|
|
654
|
+
dir="up"
|
|
655
|
+
ref={forwardedRef}
|
|
656
|
+
/>
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
SelectScrollUpButton.displayName = SCROLL_UP_BUTTON_NAME
|
|
662
|
+
|
|
663
|
+
/* -------------------------------------------------------------------------------------------------
|
|
664
|
+
* SelectScrollDownButton
|
|
665
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
666
|
+
|
|
667
|
+
const SCROLL_DOWN_BUTTON_NAME = 'SelectScrollDownButton'
|
|
668
|
+
|
|
669
|
+
const SelectScrollDownButton = React.forwardRef<TamaguiElement, SelectScrollButtonProps>(
|
|
670
|
+
(props: ScopedProps<SelectScrollButtonProps>, forwardedRef) => {
|
|
671
|
+
return (
|
|
672
|
+
<SelectScrollButtonImpl
|
|
673
|
+
componentName={SCROLL_DOWN_BUTTON_NAME}
|
|
674
|
+
{...props}
|
|
675
|
+
dir="down"
|
|
676
|
+
ref={forwardedRef}
|
|
677
|
+
/>
|
|
678
|
+
)
|
|
679
|
+
}
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
SelectScrollDownButton.displayName = SCROLL_DOWN_BUTTON_NAME
|
|
683
|
+
|
|
684
|
+
type SelectScrollButtonImplElement = TamaguiElement
|
|
685
|
+
interface SelectScrollButtonImplProps extends YStackProps {
|
|
686
|
+
dir: 'up' | 'down'
|
|
687
|
+
componentName: string
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const SelectScrollButtonImpl = React.forwardRef<
|
|
691
|
+
SelectScrollButtonImplElement,
|
|
692
|
+
SelectScrollButtonImplProps
|
|
693
|
+
>((props: ScopedProps<SelectScrollButtonImplProps>, forwardedRef) => {
|
|
694
|
+
const { __scopeSelect, dir, componentName, ...scrollIndicatorProps } = props
|
|
695
|
+
const { floatingRef, increaseHeight, forceUpdate, ...context } = useSelectContext(
|
|
696
|
+
componentName,
|
|
697
|
+
__scopeSelect
|
|
698
|
+
)
|
|
699
|
+
const intervalRef = React.useRef<any>()
|
|
700
|
+
const loopingRef = React.useRef(false)
|
|
701
|
+
const isVisible = context[dir === 'down' ? 'canScrollDown' : 'canScrollUp']
|
|
702
|
+
|
|
703
|
+
const { x, y, reference, floating, strategy, update, refs } = useFloating({
|
|
704
|
+
strategy: 'fixed',
|
|
705
|
+
placement: dir === 'up' ? 'top' : 'bottom',
|
|
706
|
+
middleware: [offset(({ rects }) => -rects.floating.height)],
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
const composedRefs = useComposedRefs(forwardedRef, floating)
|
|
710
|
+
|
|
711
|
+
React.useLayoutEffect(() => {
|
|
712
|
+
reference(floatingRef.current)
|
|
713
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
714
|
+
}, [reference, floatingRef.current])
|
|
715
|
+
|
|
716
|
+
React.useEffect(() => {
|
|
717
|
+
if (!refs.reference.current || !refs.floating.current || !isVisible) {
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const cleanup = autoUpdate(refs.reference.current, refs.floating.current, update, {
|
|
722
|
+
animationFrame: true,
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
return () => {
|
|
726
|
+
clearInterval(intervalRef.current)
|
|
727
|
+
loopingRef.current = false
|
|
728
|
+
cleanup()
|
|
729
|
+
}
|
|
730
|
+
}, [isVisible, update, refs.floating, refs.reference])
|
|
731
|
+
|
|
732
|
+
if (!isVisible) {
|
|
733
|
+
return null
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const handleScrollArrowChange = () => {
|
|
737
|
+
const floating = floatingRef.current
|
|
738
|
+
const isUp = dir === 'up'
|
|
739
|
+
if (floating) {
|
|
740
|
+
const value = isUp ? -SCROLL_ARROW_VELOCITY : SCROLL_ARROW_VELOCITY
|
|
741
|
+
const multi =
|
|
742
|
+
(isUp && floating.scrollTop <= SCROLL_ARROW_THRESHOLD * 2) ||
|
|
743
|
+
(!isUp &&
|
|
744
|
+
floating.scrollTop >=
|
|
745
|
+
floating.scrollHeight - floating.clientHeight - SCROLL_ARROW_THRESHOLD * 2)
|
|
746
|
+
? 2
|
|
747
|
+
: 1
|
|
748
|
+
floating.scrollTop += multi * (isUp ? -SCROLL_ARROW_VELOCITY : SCROLL_ARROW_VELOCITY)
|
|
749
|
+
|
|
750
|
+
increaseHeight(floating, multi === 2 ? value * 2 : value)
|
|
751
|
+
// Ensure derived data (scroll arrows) is fresh
|
|
752
|
+
forceUpdate()
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return (
|
|
757
|
+
<YStack
|
|
758
|
+
ref={composedRefs}
|
|
759
|
+
componentName={componentName}
|
|
760
|
+
aria-hidden
|
|
761
|
+
{...scrollIndicatorProps}
|
|
762
|
+
// @ts-expect-error
|
|
763
|
+
position={strategy}
|
|
764
|
+
left={x || 0}
|
|
765
|
+
top={y || 0}
|
|
766
|
+
width={`calc(${(floatingRef.current?.offsetWidth ?? 0) - 2}px)`}
|
|
767
|
+
background={`linear-gradient(to ${
|
|
768
|
+
dir === 'up' ? 'bottom' : 'top'
|
|
769
|
+
}, #29282b 50%, rgba(53, 54, 55, 0.01))`}
|
|
770
|
+
onPointerMove={() => {
|
|
771
|
+
if (!loopingRef.current) {
|
|
772
|
+
intervalRef.current = setInterval(handleScrollArrowChange, 1000 / 60)
|
|
773
|
+
loopingRef.current = true
|
|
774
|
+
}
|
|
775
|
+
}}
|
|
776
|
+
onPointerLeave={() => {
|
|
777
|
+
loopingRef.current = false
|
|
778
|
+
clearInterval(intervalRef.current)
|
|
779
|
+
}}
|
|
780
|
+
/>
|
|
781
|
+
)
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
/* -------------------------------------------------------------------------------------------------
|
|
785
|
+
* SelectSeparator
|
|
786
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
787
|
+
|
|
788
|
+
export const SelectSeparator = styled(Separator, {
|
|
789
|
+
name: 'SelectSeparator',
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
/* -------------------------------------------------------------------------------------------------
|
|
793
|
+
* Select
|
|
794
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
795
|
+
|
|
796
|
+
export interface SelectProps {
|
|
797
|
+
children?: React.ReactNode
|
|
798
|
+
value?: string
|
|
799
|
+
defaultValue?: string
|
|
800
|
+
onValueChange?(value: string): void
|
|
801
|
+
open?: boolean
|
|
802
|
+
defaultOpen?: boolean
|
|
803
|
+
onOpenChange?(open: boolean): void
|
|
804
|
+
dir?: Direction
|
|
805
|
+
name?: string
|
|
806
|
+
autoComplete?: string
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export const Select = withStaticProperties(
|
|
810
|
+
(props: ScopedProps<SelectProps>) => {
|
|
811
|
+
const {
|
|
812
|
+
__scopeSelect,
|
|
813
|
+
children,
|
|
814
|
+
open: openProp,
|
|
815
|
+
defaultOpen,
|
|
816
|
+
onOpenChange,
|
|
817
|
+
value: valueProp,
|
|
818
|
+
defaultValue,
|
|
819
|
+
onValueChange,
|
|
820
|
+
dir,
|
|
821
|
+
name,
|
|
822
|
+
autoComplete,
|
|
823
|
+
} = props
|
|
824
|
+
|
|
825
|
+
// const [trigger, setTrigger] = React.useState<SelectTriggerElement | null>(null)
|
|
826
|
+
|
|
827
|
+
const [open, setOpen] = useControllableState({
|
|
828
|
+
prop: openProp,
|
|
829
|
+
defaultProp: defaultOpen || false,
|
|
830
|
+
onChange: onOpenChange,
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
const [value, setValue] = useControllableState({
|
|
834
|
+
prop: valueProp,
|
|
835
|
+
defaultProp: defaultValue || '',
|
|
836
|
+
onChange: onValueChange,
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
const [activeIndex, setActiveIndex] = React.useState<number | null>(null)
|
|
840
|
+
const selectedIndexRef = React.useRef<number | null>(null)
|
|
841
|
+
const activeIndexRef = React.useRef<number | null>(null)
|
|
842
|
+
const prevActiveIndex = usePrevious<number | null>(activeIndex)
|
|
843
|
+
|
|
844
|
+
const [showArrows, setShowArrows] = React.useState(false)
|
|
845
|
+
const [scrollTop, setScrollTop] = React.useState(0)
|
|
846
|
+
const listItemsRef = React.useRef<Array<HTMLElement | null>>([])
|
|
847
|
+
const listContentRef = React.useRef<string[]>([])
|
|
848
|
+
|
|
849
|
+
const [selectedIndex, setSelectedIndex] = React.useState(
|
|
850
|
+
Math.max(0, listContentRef.current.indexOf(value))
|
|
851
|
+
)
|
|
852
|
+
const [controlledScrolling, setControlledScrolling] = React.useState(false)
|
|
853
|
+
const [middlewareType, setMiddlewareType] = React.useState<'align' | 'fallback'>('align')
|
|
854
|
+
|
|
855
|
+
useIsomorphicLayoutEffect(() => {
|
|
856
|
+
selectedIndexRef.current = selectedIndex
|
|
857
|
+
activeIndexRef.current = activeIndex
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
// Wait for scroll position to settle before showing arrows to prevent
|
|
861
|
+
// interference with pointer events.
|
|
862
|
+
React.useEffect(() => {
|
|
863
|
+
const frame = requestAnimationFrame(() => {
|
|
864
|
+
setShowArrows(open)
|
|
865
|
+
|
|
866
|
+
if (!open) {
|
|
867
|
+
setScrollTop(0)
|
|
868
|
+
setMiddlewareType('align')
|
|
869
|
+
setActiveIndex(null)
|
|
870
|
+
setControlledScrolling(false)
|
|
871
|
+
}
|
|
872
|
+
})
|
|
873
|
+
return () => {
|
|
874
|
+
cancelAnimationFrame(frame)
|
|
875
|
+
}
|
|
876
|
+
}, [open])
|
|
877
|
+
|
|
878
|
+
function getFloatingPadding(floating: HTMLElement | null) {
|
|
879
|
+
if (!floating) {
|
|
880
|
+
return 0
|
|
881
|
+
}
|
|
882
|
+
return Number(getComputedStyle(floating).paddingLeft?.replace('px', ''))
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const { x, y, reference, floating, strategy, context, refs, middlewareData, update } =
|
|
886
|
+
useFloating({
|
|
887
|
+
open,
|
|
888
|
+
onOpenChange: setOpen,
|
|
889
|
+
placement: 'bottom',
|
|
890
|
+
middleware:
|
|
891
|
+
middlewareType === 'align'
|
|
892
|
+
? [
|
|
893
|
+
offset(({ rects }) => {
|
|
894
|
+
const index = activeIndexRef.current ?? selectedIndexRef.current
|
|
895
|
+
|
|
896
|
+
if (index == null) {
|
|
897
|
+
return 0
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const item = listItemsRef.current[index]
|
|
901
|
+
|
|
902
|
+
if (item == null) {
|
|
903
|
+
return 0
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const offsetTop = item.offsetTop
|
|
907
|
+
const itemHeight = item.offsetHeight
|
|
908
|
+
const height = rects.reference.height
|
|
909
|
+
|
|
910
|
+
return -offsetTop - height - (itemHeight - height) / 2
|
|
911
|
+
}),
|
|
912
|
+
// Custom `size` that can handle the opposite direction of the
|
|
913
|
+
// placement
|
|
914
|
+
{
|
|
915
|
+
name: 'size',
|
|
916
|
+
async fn(args) {
|
|
917
|
+
const {
|
|
918
|
+
elements: { floating },
|
|
919
|
+
rects: { reference },
|
|
920
|
+
middlewareData,
|
|
921
|
+
} = args
|
|
922
|
+
|
|
923
|
+
const overflow = await detectOverflow(args, {
|
|
924
|
+
padding: WINDOW_PADDING,
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
const top = Math.max(0, overflow.top)
|
|
928
|
+
const bottom = Math.max(0, overflow.bottom)
|
|
929
|
+
const nextY = args.y + top
|
|
930
|
+
|
|
931
|
+
if (middlewareData.size?.skip) {
|
|
932
|
+
return {
|
|
933
|
+
y: nextY,
|
|
934
|
+
data: {
|
|
935
|
+
y: middlewareData.size.y,
|
|
936
|
+
},
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
Object.assign(floating.style, {
|
|
941
|
+
maxHeight: `${floating.scrollHeight - Math.abs(top + bottom)}px`,
|
|
942
|
+
minWidth: `${reference.width + getFloatingPadding(floating) * 2}px`,
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
y: nextY,
|
|
947
|
+
data: {
|
|
948
|
+
y: top,
|
|
949
|
+
skip: true,
|
|
950
|
+
},
|
|
951
|
+
reset: {
|
|
952
|
+
rects: true,
|
|
953
|
+
},
|
|
954
|
+
}
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
]
|
|
958
|
+
: [
|
|
959
|
+
offset(5),
|
|
960
|
+
flip(),
|
|
961
|
+
size({
|
|
962
|
+
apply({ rects, availableHeight, elements }) {
|
|
963
|
+
Object.assign(elements.floating.style, {
|
|
964
|
+
width: `${rects.reference.width}px`,
|
|
965
|
+
maxHeight: `${availableHeight}px`,
|
|
966
|
+
})
|
|
967
|
+
},
|
|
968
|
+
padding: WINDOW_PADDING,
|
|
969
|
+
}),
|
|
970
|
+
],
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
const floatingRef = refs.floating
|
|
974
|
+
const forceUpdate = React.useReducer(() => ({}), {})[1]
|
|
975
|
+
|
|
976
|
+
const showUpArrow = showArrows && scrollTop > SCROLL_ARROW_THRESHOLD
|
|
977
|
+
const showDownArrow =
|
|
978
|
+
showArrows &&
|
|
979
|
+
floatingRef.current &&
|
|
980
|
+
scrollTop <
|
|
981
|
+
floatingRef.current.scrollHeight - floatingRef.current.clientHeight - SCROLL_ARROW_THRESHOLD
|
|
982
|
+
|
|
983
|
+
const interactions = useInteractions([
|
|
984
|
+
useClick(context, { pointerDown: true }),
|
|
985
|
+
useRole(context, { role: 'listbox' }),
|
|
986
|
+
useDismiss(context),
|
|
987
|
+
useListNavigation(context, {
|
|
988
|
+
listRef: listItemsRef,
|
|
989
|
+
activeIndex,
|
|
990
|
+
selectedIndex,
|
|
991
|
+
onNavigate: setActiveIndex,
|
|
992
|
+
}),
|
|
993
|
+
useTypeahead(context, {
|
|
994
|
+
listRef: listContentRef,
|
|
995
|
+
onMatch: open ? setActiveIndex : setSelectedIndex,
|
|
996
|
+
selectedIndex,
|
|
997
|
+
activeIndex,
|
|
998
|
+
}),
|
|
999
|
+
])
|
|
1000
|
+
|
|
1001
|
+
const increaseHeight = React.useCallback(
|
|
1002
|
+
(floating: HTMLElement, amount = 0) => {
|
|
1003
|
+
if (middlewareType === 'fallback') {
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const currentMaxHeight = Number(floating.style.maxHeight.replace('px', ''))
|
|
1008
|
+
const currentTop = Number(floating.style.top.replace('px', ''))
|
|
1009
|
+
const rect = floating.getBoundingClientRect()
|
|
1010
|
+
const rectTop = rect.top
|
|
1011
|
+
const rectBottom = rect.bottom
|
|
1012
|
+
const visualMaxHeight = visualViewport.height - WINDOW_PADDING * 2
|
|
1013
|
+
|
|
1014
|
+
if (
|
|
1015
|
+
amount < 0 &&
|
|
1016
|
+
selectedIndexRef.current != null &&
|
|
1017
|
+
Math.round(rectBottom) <
|
|
1018
|
+
Math.round(visualViewport.height + getVisualOffsetTop() - WINDOW_PADDING)
|
|
1019
|
+
) {
|
|
1020
|
+
floating.style.maxHeight = `${Math.min(visualMaxHeight, currentMaxHeight - amount)}px`
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (
|
|
1024
|
+
amount > 0 &&
|
|
1025
|
+
Math.round(rectTop) > Math.round(WINDOW_PADDING - getVisualOffsetTop()) &&
|
|
1026
|
+
floating.scrollHeight > floating.offsetHeight
|
|
1027
|
+
) {
|
|
1028
|
+
const nextTop = Math.max(WINDOW_PADDING + getVisualOffsetTop(), currentTop - amount)
|
|
1029
|
+
|
|
1030
|
+
const nextMaxHeight = Math.min(visualMaxHeight, currentMaxHeight + amount)
|
|
1031
|
+
|
|
1032
|
+
Object.assign(floating.style, {
|
|
1033
|
+
maxHeight: `${nextMaxHeight}px`,
|
|
1034
|
+
top: `${nextTop}px`,
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
if (nextTop - WINDOW_PADDING > getVisualOffsetTop()) {
|
|
1038
|
+
floating.scrollTop -= nextMaxHeight - currentMaxHeight + getFloatingPadding(floating)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return currentTop - nextTop
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
[middlewareType]
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
const touchPageYRef = React.useRef<number | null>(null)
|
|
1048
|
+
|
|
1049
|
+
const handleWheel = React.useCallback(
|
|
1050
|
+
(event: WheelEvent | TouchEvent) => {
|
|
1051
|
+
const pinching = event.ctrlKey
|
|
1052
|
+
|
|
1053
|
+
const currentTarget = event.currentTarget as HTMLElement
|
|
1054
|
+
|
|
1055
|
+
function isWheelEvent(event: any): event is WheelEvent {
|
|
1056
|
+
return typeof event.deltaY === 'number'
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function isTouchEvent(event: any): event is TouchEvent {
|
|
1060
|
+
return event.touches != null
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (
|
|
1064
|
+
Math.abs(
|
|
1065
|
+
(currentTarget?.offsetHeight ?? 0) - (visualViewport.height - WINDOW_PADDING * 2)
|
|
1066
|
+
) > 1 &&
|
|
1067
|
+
!pinching
|
|
1068
|
+
) {
|
|
1069
|
+
event.preventDefault()
|
|
1070
|
+
} else if (isWheelEvent(event) && isFirefox) {
|
|
1071
|
+
// Firefox needs this to propagate scrolling
|
|
1072
|
+
// during momentum scrolling phase if the
|
|
1073
|
+
// height reached its maximum (at boundaries)
|
|
1074
|
+
currentTarget.scrollTop += event.deltaY
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (!pinching) {
|
|
1078
|
+
let delta = 5
|
|
1079
|
+
|
|
1080
|
+
if (isTouchEvent(event)) {
|
|
1081
|
+
const currentPageY = touchPageYRef.current
|
|
1082
|
+
const pageY = event.touches[0]?.pageY
|
|
1083
|
+
|
|
1084
|
+
if (pageY != null) {
|
|
1085
|
+
touchPageYRef.current = pageY
|
|
1086
|
+
|
|
1087
|
+
if (currentPageY != null) {
|
|
1088
|
+
delta = currentPageY - pageY
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
increaseHeight(currentTarget, isWheelEvent(event) ? event.deltaY : delta)
|
|
1094
|
+
setScrollTop(currentTarget.scrollTop)
|
|
1095
|
+
// Ensure derived data (scroll arrows) is fresh
|
|
1096
|
+
forceUpdate()
|
|
1097
|
+
}
|
|
1098
|
+
},
|
|
1099
|
+
[increaseHeight, forceUpdate]
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
// Handle `onWheel` event in an effect to remove the `passive` option so we
|
|
1103
|
+
// can .preventDefault() it
|
|
1104
|
+
React.useEffect(() => {
|
|
1105
|
+
function onTouchEnd() {
|
|
1106
|
+
touchPageYRef.current = null
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const floating = floatingRef.current
|
|
1110
|
+
if (open && floating && middlewareType === 'align') {
|
|
1111
|
+
floating.addEventListener('wheel', handleWheel)
|
|
1112
|
+
floating.addEventListener('touchmove', handleWheel)
|
|
1113
|
+
floating.addEventListener('touchend', onTouchEnd, { passive: true })
|
|
1114
|
+
return () => {
|
|
1115
|
+
floating.removeEventListener('wheel', handleWheel)
|
|
1116
|
+
floating.removeEventListener('touchmove', handleWheel)
|
|
1117
|
+
floating.removeEventListener('touchend', onTouchEnd)
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}, [open, floatingRef, handleWheel, middlewareType])
|
|
1121
|
+
|
|
1122
|
+
// Ensure the menu remains attached to the reference element when resizing.
|
|
1123
|
+
React.useEffect(() => {
|
|
1124
|
+
window.addEventListener('resize', update)
|
|
1125
|
+
return () => {
|
|
1126
|
+
window.removeEventListener('resize', update)
|
|
1127
|
+
}
|
|
1128
|
+
}, [update])
|
|
1129
|
+
|
|
1130
|
+
// Scroll the active or selected item into view when in `controlledScrolling`
|
|
1131
|
+
// mode (i.e. arrow key nav).
|
|
1132
|
+
React.useLayoutEffect(() => {
|
|
1133
|
+
const floating = floatingRef.current
|
|
1134
|
+
|
|
1135
|
+
if (open && controlledScrolling && floating) {
|
|
1136
|
+
const item =
|
|
1137
|
+
activeIndex != null
|
|
1138
|
+
? listItemsRef.current[activeIndex]
|
|
1139
|
+
: selectedIndex != null
|
|
1140
|
+
? listItemsRef.current[selectedIndex]
|
|
1141
|
+
: null
|
|
1142
|
+
|
|
1143
|
+
if (item && prevActiveIndex != null) {
|
|
1144
|
+
const itemHeight = listItemsRef.current[prevActiveIndex]?.offsetHeight ?? 0
|
|
1145
|
+
|
|
1146
|
+
const floatingHeight = floating.offsetHeight
|
|
1147
|
+
const top = item.offsetTop
|
|
1148
|
+
const bottom = top + itemHeight
|
|
1149
|
+
|
|
1150
|
+
if (top < floating.scrollTop + 20) {
|
|
1151
|
+
const diff = floating.scrollTop - top + 20
|
|
1152
|
+
floating.scrollTop -= diff
|
|
1153
|
+
|
|
1154
|
+
if (activeIndex != selectedIndex && activeIndex != null) {
|
|
1155
|
+
increaseHeight(floating, -diff)
|
|
1156
|
+
}
|
|
1157
|
+
} else if (bottom > floatingHeight + floating.scrollTop - 20) {
|
|
1158
|
+
const diff = bottom - floatingHeight - floating.scrollTop + 20
|
|
1159
|
+
|
|
1160
|
+
floating.scrollTop += diff
|
|
1161
|
+
|
|
1162
|
+
if (activeIndex != selectedIndex && activeIndex != null) {
|
|
1163
|
+
floating.scrollTop -= increaseHeight(floating, diff) ?? 0
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}, [
|
|
1169
|
+
open,
|
|
1170
|
+
controlledScrolling,
|
|
1171
|
+
prevActiveIndex,
|
|
1172
|
+
activeIndex,
|
|
1173
|
+
selectedIndex,
|
|
1174
|
+
floatingRef,
|
|
1175
|
+
increaseHeight,
|
|
1176
|
+
])
|
|
1177
|
+
|
|
1178
|
+
// Sync the height and the scrollTop values and device whether to use fallback
|
|
1179
|
+
// positioning.
|
|
1180
|
+
React.useLayoutEffect(() => {
|
|
1181
|
+
const floating = refs.floating.current
|
|
1182
|
+
const reference = refs.reference.current
|
|
1183
|
+
|
|
1184
|
+
if (open && floating && reference && floating.offsetHeight < floating.scrollHeight) {
|
|
1185
|
+
const referenceRect = reference.getBoundingClientRect()
|
|
1186
|
+
|
|
1187
|
+
if (middlewareType === 'fallback') {
|
|
1188
|
+
const item = listItemsRef.current[selectedIndex]
|
|
1189
|
+
if (item) {
|
|
1190
|
+
floating.scrollTop = item.offsetTop - floating.clientHeight + referenceRect.height
|
|
1191
|
+
}
|
|
1192
|
+
return
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
floating.scrollTop = middlewareData.size?.y
|
|
1196
|
+
|
|
1197
|
+
const closeToBottom =
|
|
1198
|
+
visualViewport.height + getVisualOffsetTop() - referenceRect.bottom < FALLBACK_THRESHOLD
|
|
1199
|
+
const closeToTop = referenceRect.top < FALLBACK_THRESHOLD
|
|
1200
|
+
|
|
1201
|
+
if (floating.offsetHeight < MIN_HEIGHT || closeToTop || closeToBottom) {
|
|
1202
|
+
setMiddlewareType('fallback')
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}, [
|
|
1206
|
+
open,
|
|
1207
|
+
increaseHeight,
|
|
1208
|
+
selectedIndex,
|
|
1209
|
+
middlewareType,
|
|
1210
|
+
refs.floating,
|
|
1211
|
+
refs.reference,
|
|
1212
|
+
// Always re-run this effect when the position has been computed so the
|
|
1213
|
+
// .scrollTop change works with fresh sizing.
|
|
1214
|
+
middlewareData,
|
|
1215
|
+
])
|
|
1216
|
+
|
|
1217
|
+
React.useLayoutEffect(() => {
|
|
1218
|
+
if (open && selectedIndex != null) {
|
|
1219
|
+
requestAnimationFrame(() => {
|
|
1220
|
+
listItemsRef.current[selectedIndex]?.focus({ preventScroll: true })
|
|
1221
|
+
})
|
|
1222
|
+
}
|
|
1223
|
+
}, [listItemsRef, selectedIndex, open])
|
|
1224
|
+
|
|
1225
|
+
// Wait for scroll position to settle before showing arrows to prevent
|
|
1226
|
+
// interference with pointer events.
|
|
1227
|
+
React.useEffect(() => {
|
|
1228
|
+
const frame = requestAnimationFrame(() => {
|
|
1229
|
+
setShowArrows(open)
|
|
1230
|
+
|
|
1231
|
+
if (!open) {
|
|
1232
|
+
setScrollTop(0)
|
|
1233
|
+
setMiddlewareType('align')
|
|
1234
|
+
setActiveIndex(null)
|
|
1235
|
+
setControlledScrolling(false)
|
|
1236
|
+
}
|
|
1237
|
+
})
|
|
1238
|
+
return () => cancelAnimationFrame(frame)
|
|
1239
|
+
}, [open])
|
|
1240
|
+
|
|
1241
|
+
// We set this to true by default so that events bubble to forms without JS (SSR)
|
|
1242
|
+
// const isFormControl = trigger ? Boolean(trigger.closest('form')) : true
|
|
1243
|
+
// const [bubbleSelect, setBubbleSelect] = React.useState<HTMLSelectElement | null>(null)
|
|
1244
|
+
// const triggerPointerDownPosRef = React.useRef<{ x: number; y: number } | null>(null)
|
|
1245
|
+
|
|
1246
|
+
const [valueNode, setValueNode] = React.useState<HTMLElement | null>(null)
|
|
1247
|
+
const [valueNodeHasChildren, setValueNodeHasChildren] = React.useState(false)
|
|
1248
|
+
|
|
1249
|
+
return (
|
|
1250
|
+
<SelectProvider
|
|
1251
|
+
scope={__scopeSelect}
|
|
1252
|
+
increaseHeight={increaseHeight}
|
|
1253
|
+
forceUpdate={forceUpdate}
|
|
1254
|
+
floatingRef={floatingRef}
|
|
1255
|
+
valueNode={valueNode}
|
|
1256
|
+
onValueNodeChange={setValueNode}
|
|
1257
|
+
valueNodeHasChildren={valueNodeHasChildren}
|
|
1258
|
+
onValueNodeHasChildrenChange={setValueNodeHasChildren}
|
|
1259
|
+
setValueAtIndex={(index, value) => {
|
|
1260
|
+
listContentRef.current[index] = value
|
|
1261
|
+
}}
|
|
1262
|
+
interactions={{
|
|
1263
|
+
...interactions,
|
|
1264
|
+
getReferenceProps() {
|
|
1265
|
+
return interactions.getReferenceProps({
|
|
1266
|
+
ref: reference,
|
|
1267
|
+
className: 'SelectTrigger',
|
|
1268
|
+
onKeyDown(event) {
|
|
1269
|
+
if (
|
|
1270
|
+
event.key === 'Enter' ||
|
|
1271
|
+
(event.key === ' ' && !context.dataRef.current.typing)
|
|
1272
|
+
) {
|
|
1273
|
+
event.preventDefault()
|
|
1274
|
+
setOpen(true)
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
})
|
|
1278
|
+
},
|
|
1279
|
+
getFloatingProps() {
|
|
1280
|
+
return interactions.getFloatingProps({
|
|
1281
|
+
ref: floating,
|
|
1282
|
+
className: 'Select',
|
|
1283
|
+
style: {
|
|
1284
|
+
position: strategy,
|
|
1285
|
+
top: y ?? '',
|
|
1286
|
+
left: x ?? '',
|
|
1287
|
+
},
|
|
1288
|
+
onPointerEnter() {
|
|
1289
|
+
setControlledScrolling(false)
|
|
1290
|
+
},
|
|
1291
|
+
onPointerMove() {
|
|
1292
|
+
setControlledScrolling(false)
|
|
1293
|
+
},
|
|
1294
|
+
onKeyDown() {
|
|
1295
|
+
setControlledScrolling(true)
|
|
1296
|
+
},
|
|
1297
|
+
onScroll(event) {
|
|
1298
|
+
setScrollTop(event.currentTarget.scrollTop)
|
|
1299
|
+
},
|
|
1300
|
+
})
|
|
1301
|
+
},
|
|
1302
|
+
}}
|
|
1303
|
+
floatingContext={context}
|
|
1304
|
+
activeIndex={activeIndex}
|
|
1305
|
+
canScrollDown={!!showDownArrow}
|
|
1306
|
+
canScrollUp={!!showUpArrow}
|
|
1307
|
+
controlledScrolling
|
|
1308
|
+
dataRef={context.dataRef}
|
|
1309
|
+
listRef={listItemsRef}
|
|
1310
|
+
onChange={setValue}
|
|
1311
|
+
selectedIndex={0}
|
|
1312
|
+
setActiveIndex={setActiveIndex}
|
|
1313
|
+
setOpen={setOpen}
|
|
1314
|
+
setSelectedIndex={setSelectedIndex}
|
|
1315
|
+
value={value}
|
|
1316
|
+
// trigger={trigger}
|
|
1317
|
+
// onTriggerChange={setTrigger}
|
|
1318
|
+
// contentId={useId() || ''}
|
|
1319
|
+
// value={value}
|
|
1320
|
+
// onValueChange={setValue}
|
|
1321
|
+
open={open}
|
|
1322
|
+
// onOpenChange={setOpen}
|
|
1323
|
+
// TODO
|
|
1324
|
+
// dir={'rtl'} //direction}
|
|
1325
|
+
// bubbleSelect={bubbleSelect}
|
|
1326
|
+
// triggerPointerDownPosRef={triggerPointerDownPosRef}
|
|
1327
|
+
>
|
|
1328
|
+
{/* <Collection.Provider scope={__scopeSelect}>{children}</Collection.Provider> */}
|
|
1329
|
+
{children}
|
|
1330
|
+
{/* {isFormControl ? (
|
|
1331
|
+
<BubbleSelect
|
|
1332
|
+
ref={setBubbleSelect}
|
|
1333
|
+
aria-hidden
|
|
1334
|
+
tabIndex={-1}
|
|
1335
|
+
name={name}
|
|
1336
|
+
autoComplete={autoComplete}
|
|
1337
|
+
value={value}
|
|
1338
|
+
// enable form autofill
|
|
1339
|
+
onChange={(event) => setValue(event.target.value)}
|
|
1340
|
+
/>
|
|
1341
|
+
) : null} */}
|
|
1342
|
+
</SelectProvider>
|
|
1343
|
+
)
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
Content: SelectContent,
|
|
1347
|
+
Group: SelectGroup,
|
|
1348
|
+
Icon: SelectIcon,
|
|
1349
|
+
Item: SelectItem,
|
|
1350
|
+
ItemIndicator: SelectItemIndicator,
|
|
1351
|
+
ItemText: SelectItemText,
|
|
1352
|
+
Label: SelectLabel,
|
|
1353
|
+
ScrollDownButton: SelectScrollDownButton,
|
|
1354
|
+
ScrollUpButton: SelectScrollUpButton,
|
|
1355
|
+
Trigger: SelectTrigger,
|
|
1356
|
+
Value: SelectValue,
|
|
1357
|
+
Viewport: SelectViewport,
|
|
1358
|
+
}
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
// @ts-ignore
|
|
1362
|
+
Select.displayName = SELECT_NAME
|
|
1363
|
+
|
|
1364
|
+
export { createSelectScope }
|