@tamagui/select 3.0.0-beta.922.1 → 3.0.0

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.
Files changed (106) hide show
  1. package/dist/cjs/Select.cjs +23 -25
  2. package/dist/cjs/Select.native.cjs +27 -26
  3. package/dist/cjs/Select.native.js +27 -26
  4. package/dist/cjs/Select.native.js.map +1 -1
  5. package/dist/cjs/SelectImpl.cjs +56 -50
  6. package/dist/cjs/SelectItem.cjs +49 -60
  7. package/dist/cjs/SelectItem.native.cjs +51 -79
  8. package/dist/cjs/SelectItem.native.js +51 -79
  9. package/dist/cjs/SelectItem.native.js.map +1 -1
  10. package/dist/cjs/SelectScrollButton.cjs +2 -2
  11. package/dist/cjs/SelectTrigger.cjs +22 -5
  12. package/dist/cjs/SelectTrigger.native.cjs +15 -5
  13. package/dist/cjs/SelectTrigger.native.js +15 -5
  14. package/dist/cjs/SelectTrigger.native.js.map +1 -1
  15. package/dist/cjs/SelectViewport.cjs +22 -30
  16. package/dist/cjs/SelectViewport.native.cjs +2 -2
  17. package/dist/cjs/SelectViewport.native.js +2 -2
  18. package/dist/cjs/SelectViewport.native.js.map +1 -1
  19. package/dist/cjs/selectionController.cjs +15 -5
  20. package/dist/cjs/selectionController.native.cjs +20 -15
  21. package/dist/cjs/selectionController.native.js +20 -15
  22. package/dist/cjs/selectionController.native.js.map +1 -1
  23. package/dist/esm/Select.mjs +27 -28
  24. package/dist/esm/Select.mjs.map +1 -1
  25. package/dist/esm/Select.native.js +31 -29
  26. package/dist/esm/Select.native.js.map +1 -1
  27. package/dist/esm/SelectImpl.mjs +56 -50
  28. package/dist/esm/SelectImpl.mjs.map +1 -1
  29. package/dist/esm/SelectItem.mjs +50 -61
  30. package/dist/esm/SelectItem.mjs.map +1 -1
  31. package/dist/esm/SelectItem.native.js +52 -80
  32. package/dist/esm/SelectItem.native.js.map +1 -1
  33. package/dist/esm/SelectScrollButton.mjs +2 -2
  34. package/dist/esm/SelectScrollButton.mjs.map +1 -1
  35. package/dist/esm/SelectTrigger.mjs +16 -5
  36. package/dist/esm/SelectTrigger.mjs.map +1 -1
  37. package/dist/esm/SelectTrigger.native.js +15 -4
  38. package/dist/esm/SelectTrigger.native.js.map +1 -1
  39. package/dist/esm/SelectViewport.mjs +22 -30
  40. package/dist/esm/SelectViewport.mjs.map +1 -1
  41. package/dist/esm/SelectViewport.native.js +3 -3
  42. package/dist/esm/SelectViewport.native.js.map +1 -1
  43. package/dist/esm/selectionController.mjs +15 -5
  44. package/dist/esm/selectionController.mjs.map +1 -1
  45. package/dist/esm/selectionController.native.js +20 -15
  46. package/dist/esm/selectionController.native.js.map +1 -1
  47. package/dist/jsx/Select.mjs +27 -28
  48. package/dist/jsx/Select.mjs.map +1 -1
  49. package/dist/jsx/Select.native.cjs +27 -26
  50. package/dist/jsx/Select.native.js +27 -26
  51. package/dist/jsx/Select.native.js.map +1 -1
  52. package/dist/jsx/SelectImpl.mjs +56 -50
  53. package/dist/jsx/SelectImpl.mjs.map +1 -1
  54. package/dist/jsx/SelectItem.mjs +50 -61
  55. package/dist/jsx/SelectItem.mjs.map +1 -1
  56. package/dist/jsx/SelectItem.native.cjs +51 -79
  57. package/dist/jsx/SelectItem.native.js +51 -79
  58. package/dist/jsx/SelectItem.native.js.map +1 -1
  59. package/dist/jsx/SelectScrollButton.mjs +2 -2
  60. package/dist/jsx/SelectScrollButton.mjs.map +1 -1
  61. package/dist/jsx/SelectTrigger.mjs +16 -5
  62. package/dist/jsx/SelectTrigger.mjs.map +1 -1
  63. package/dist/jsx/SelectTrigger.native.cjs +15 -5
  64. package/dist/jsx/SelectTrigger.native.js +15 -5
  65. package/dist/jsx/SelectTrigger.native.js.map +1 -1
  66. package/dist/jsx/SelectViewport.mjs +22 -30
  67. package/dist/jsx/SelectViewport.mjs.map +1 -1
  68. package/dist/jsx/SelectViewport.native.cjs +2 -2
  69. package/dist/jsx/SelectViewport.native.js +2 -2
  70. package/dist/jsx/SelectViewport.native.js.map +1 -1
  71. package/dist/jsx/selectionController.mjs +15 -5
  72. package/dist/jsx/selectionController.mjs.map +1 -1
  73. package/dist/jsx/selectionController.native.cjs +20 -15
  74. package/dist/jsx/selectionController.native.js +20 -15
  75. package/dist/jsx/selectionController.native.js.map +1 -1
  76. package/package.json +30 -29
  77. package/src/Select.tsx +41 -41
  78. package/src/SelectImpl.tsx +83 -81
  79. package/src/SelectItem.tsx +66 -91
  80. package/src/SelectScrollButton.tsx +2 -2
  81. package/src/SelectTrigger.tsx +19 -2
  82. package/src/SelectViewport.native.tsx +4 -4
  83. package/src/SelectViewport.tsx +21 -27
  84. package/src/selectionController.ts +20 -5
  85. package/src/types.tsx +26 -21
  86. package/types/Select.d.ts +42 -81
  87. package/types/Select.d.ts.map +1 -1
  88. package/types/SelectImpl.d.ts.map +1 -1
  89. package/types/SelectItem.d.ts +3 -5
  90. package/types/SelectItem.d.ts.map +1 -1
  91. package/types/SelectItemText.d.ts +3 -5
  92. package/types/SelectItemText.d.ts.map +1 -1
  93. package/types/SelectScrollButton.d.ts +5 -9
  94. package/types/SelectScrollButton.d.ts.map +1 -1
  95. package/types/SelectScrollButton.native.d.ts +5 -9
  96. package/types/SelectScrollButton.native.d.ts.map +1 -1
  97. package/types/SelectTrigger.d.ts +3 -5
  98. package/types/SelectTrigger.d.ts.map +1 -1
  99. package/types/SelectViewport.d.ts +4 -9
  100. package/types/SelectViewport.d.ts.map +1 -1
  101. package/types/SelectViewport.native.d.ts +4 -9
  102. package/types/SelectViewport.native.d.ts.map +1 -1
  103. package/types/selectionController.d.ts +2 -0
  104. package/types/selectionController.d.ts.map +1 -1
  105. package/types/types.d.ts +30 -21
  106. package/types/types.d.ts.map +1 -1
@@ -11,7 +11,7 @@ import {
11
11
  import type { GetProps } from '@tamagui/core'
12
12
  import { composeEventHandlers } from '@tamagui/helpers'
13
13
  import * as React from 'react'
14
- import { useSelectContext, useSelectItemParentContext } from './context'
14
+ import { useSelectItemParentContext } from './context'
15
15
  import { getSelectOptionProps } from './selectionController'
16
16
  import type {
17
17
  SelectActiveChangeDetails,
@@ -67,12 +67,12 @@ export const SelectItem = createStyledHOC(
67
67
  } = props
68
68
  const disabled = disabledProp ?? ariaDisabled === true
69
69
 
70
+ // items read only the item parent context: the select context carries
71
+ // viewport state (position, scroll arrows) that must not re-render the list
70
72
  const context = useSelectItemParentContext(scope)
71
- const selectContext = useSelectContext(scope)
72
- const isAdapted = useAdaptIsActive(selectContext.adaptScope)
73
+ const isAdapted = useAdaptIsActive(context.adaptScope)
73
74
 
74
75
  const {
75
- listRef,
76
76
  registry,
77
77
  mode,
78
78
  selectedValues,
@@ -82,9 +82,10 @@ export const SelectItem = createStyledHOC(
82
82
  allowMouseUpRef,
83
83
  allowSelectRef,
84
84
  selectTimeoutRef,
85
- interactions,
85
+ getItemProps,
86
86
  shouldRenderWebNative,
87
- setActiveIndexFast,
87
+ setActiveIndex,
88
+ lastPointerRef,
88
89
  moveActive,
89
90
  search,
90
91
  } = context
@@ -92,7 +93,6 @@ export const SelectItem = createStyledHOC(
92
93
  const isSelected = selectedValues.includes(value)
93
94
  const pendingMouseUpSelectionRef = React.useRef(false)
94
95
  const itemNodeRef = React.useRef<any>(null)
95
- const [, rerenderRegistry] = React.useReducer((version) => version + 1, 0)
96
96
  const registrationRef = React.useRef<ReturnType<typeof registry.registerItem> | null>(
97
97
  null
98
98
  )
@@ -102,38 +102,13 @@ export const SelectItem = createStyledHOC(
102
102
  textValue: textValueProp,
103
103
  })
104
104
 
105
- useIsomorphicLayoutEffect(
106
- () => registry.subscribe(() => rerenderRegistry()),
107
- [registry]
108
- )
109
-
105
+ // the item never subscribes to the registry: its index is read at event
106
+ // time, and the owner mirrors the registry's nodes into the list ref
110
107
  useIsomorphicLayoutEffect(() => {
111
108
  const registration = registry.registerItem(initialRegistration.current)
112
109
  registrationRef.current = registration
113
- const registeredNode = itemNodeRef.current
114
- registration.setNode(registeredNode)
115
- const registeredIndex = registry
116
- .getItems()
117
- .findIndex((item) => item.id === registration.id)
118
- if (
119
- isWeb &&
120
- listRef &&
121
- registeredIndex >= 0 &&
122
- registeredNode instanceof HTMLElement
123
- ) {
124
- listRef.current[registeredIndex] = registeredNode
125
- }
110
+ registration.setNode(itemNodeRef.current)
126
111
  return () => {
127
- const currentIndex = registry
128
- .getItems()
129
- .findIndex((item) => item.id === registration.id)
130
- if (
131
- listRef &&
132
- currentIndex >= 0 &&
133
- listRef.current[currentIndex] === registeredNode
134
- ) {
135
- listRef.current[currentIndex] = null
136
- }
137
112
  registrationRef.current = null
138
113
  registration.unregister()
139
114
  }
@@ -147,43 +122,41 @@ export const SelectItem = createStyledHOC(
147
122
  registrationRef.current?.setNode(itemNodeRef.current)
148
123
  })
149
124
 
150
- const index = registry.getIndex(value)
125
+ // the only per-item state: this item is the active one. the active index
126
+ // itself is a ref plus an emitter, so a hover re-renders two items, not the list
127
+ const [isActive, setIsActive] = React.useState(false)
128
+ // every item hears every change; only the two whose state flips may set
129
+ // it, since a same-value set still renders once when updates are queued
130
+ const isActiveRef = React.useRef(false)
151
131
 
152
132
  React.useEffect(() => {
153
- const handleActiveIndex = (i: number) => {
154
- if (index === i) {
155
- if (isWeb) {
156
- // use rAF to focus after browser's click handling completes
157
- // this prevents the trigger from stealing focus after we set it
158
- requestAnimationFrame(() => {
159
- listRef?.current[index]?.focus()
160
- })
161
- }
133
+ const handleActiveIndex = (i: number | null) => {
134
+ const next = i != null && registry.getIndex(value) === i
135
+ if (next !== isActiveRef.current) {
136
+ isActiveRef.current = next
137
+ setIsActive(next)
138
+ }
139
+ if (next && isWeb) {
140
+ // use rAF to focus after browser's click handling completes
141
+ // this prevents the trigger from stealing focus after we set it
142
+ requestAnimationFrame(() => {
143
+ itemNodeRef.current?.focus?.()
144
+ })
162
145
  }
163
146
  }
164
147
 
165
- // check initial value (parent effect may have set it before we subscribed)
166
- const currentActiveIndex = activeIndexRef?.current
167
- if (currentActiveIndex !== null && currentActiveIndex !== undefined) {
168
- handleActiveIndex(currentActiveIndex)
169
- }
148
+ // the parent effect may have set it before we subscribed
149
+ handleActiveIndex(activeIndexRef?.current ?? null)
170
150
 
171
151
  return activeIndexSubscribe(handleActiveIndex)
172
- }, [activeIndexRef, activeIndexSubscribe, index, listRef])
152
+ }, [activeIndexRef, activeIndexSubscribe, registry, value])
173
153
 
174
154
  const textId = React.useId()
175
155
 
176
- const refCallback = React.useCallback(
177
- (node) => {
178
- itemNodeRef.current = node
179
- registrationRef.current?.setNode(node)
180
- if (!isWeb) return
181
- if (listRef && index >= 0) {
182
- listRef.current[index] = node instanceof HTMLElement ? node : null
183
- }
184
- },
185
- [index, listRef]
186
- )
156
+ const refCallback = React.useCallback((node) => {
157
+ itemNodeRef.current = node
158
+ registrationRef.current?.setNode(node)
159
+ }, [])
187
160
 
188
161
  const composedRefs = useComposedRefs(forwardedRef, refCallback)
189
162
 
@@ -203,6 +176,25 @@ export const SelectItem = createStyledHOC(
203
176
  [disabled, selectValue, value]
204
177
  )
205
178
 
179
+ const handleMouseMove = React.useCallback(
180
+ (event: any) => {
181
+ if (disabled) return
182
+ const index = registry.getIndex(value)
183
+ if (index < 0) return
184
+ const last = lastPointerRef.current
185
+ if (event.clientX === last.x && event.clientY === last.y) return
186
+ last.x = event.clientX
187
+ last.y = event.clientY
188
+ setActiveIndex(index, {
189
+ reason: 'item-hover',
190
+ event: event.nativeEvent || event,
191
+ trigger: event.currentTarget,
192
+ index,
193
+ } as SelectActiveChangeDetails)
194
+ },
195
+ [disabled, lastPointerRef, registry, setActiveIndex, value]
196
+ )
197
+
206
198
  const handleKeyDown = React.useCallback(
207
199
  (event: any) => {
208
200
  if (disabled) return
@@ -218,7 +210,7 @@ export const SelectItem = createStyledHOC(
218
210
  return
219
211
  }
220
212
  if (
221
- !interactions &&
213
+ !getItemProps &&
222
214
  event.key?.length === 1 &&
223
215
  !event.metaKey &&
224
216
  !event.ctrlKey
@@ -229,11 +221,11 @@ export const SelectItem = createStyledHOC(
229
221
  allowSelectRef.current = true
230
222
  }
231
223
  },
232
- [allowSelectRef, disabled, handleSelect, interactions, moveActive, search]
224
+ [allowSelectRef, disabled, handleSelect, getItemProps, moveActive, search]
233
225
  )
234
226
 
235
227
  const selectItemProps = React.useMemo(() => {
236
- if (interactions) {
228
+ if (getItemProps) {
237
229
  const {
238
230
  onTouchMove,
239
231
  onTouchEnd,
@@ -244,7 +236,7 @@ export const SelectItem = createStyledHOC(
244
236
  onPress,
245
237
  ...itemProps
246
238
  } = restProps
247
- const interactionProps = interactions.getItemProps({
239
+ const interactionProps = getItemProps({
248
240
  ...itemProps,
249
241
  onTouchMove() {
250
242
  allowSelectRef!.current = true
@@ -254,15 +246,7 @@ export const SelectItem = createStyledHOC(
254
246
  allowSelectRef!.current = false
255
247
  allowMouseUpRef!.current = true
256
248
  },
257
- onMouseMove(event) {
258
- if (disabled || index < 0) return
259
- setActiveIndexFast?.(index, {
260
- reason: 'item-hover',
261
- event: event.nativeEvent || event,
262
- trigger: event.currentTarget,
263
- index,
264
- } as SelectActiveChangeDetails)
265
- },
249
+ onMouseMove: handleMouseMove,
266
250
  onKeyDown: handleKeyDown,
267
251
  onClick(event) {
268
252
  if (disabled) return
@@ -332,15 +316,7 @@ export const SelectItem = createStyledHOC(
332
316
  return {
333
317
  ...restProps,
334
318
  onKeyDown: composeEventHandlers(restProps.onKeyDown as any, handleKeyDown),
335
- onMouseMove: composeEventHandlers(restProps.onMouseMove as any, (event: any) => {
336
- if (disabled || index < 0) return
337
- setActiveIndexFast?.(index, {
338
- reason: 'item-hover',
339
- event: event.nativeEvent || event,
340
- trigger: event.currentTarget,
341
- index,
342
- } as SelectActiveChangeDetails)
343
- }),
319
+ onMouseMove: composeEventHandlers(restProps.onMouseMove as any, handleMouseMove),
344
320
  onPress: composeEventHandlers(restProps.onPress as any, handleSelect),
345
321
  }
346
322
  }, [
@@ -348,12 +324,11 @@ export const SelectItem = createStyledHOC(
348
324
  allowSelectRef,
349
325
  disabled,
350
326
  handleKeyDown,
327
+ handleMouseMove,
351
328
  handleSelect,
352
- index,
353
- interactions,
329
+ getItemProps,
354
330
  restProps,
355
331
  selectTimeoutRef,
356
- setActiveIndexFast,
357
332
  ])
358
333
 
359
334
  const accessibilityProps = getSelectOptionProps(
@@ -385,10 +360,10 @@ export const SelectItem = createStyledHOC(
385
360
  tabIndex={
386
361
  disabled
387
362
  ? undefined
388
- : isWeb && isAdapted
389
- ? index === (selectContext.activeIndex ?? selectContext.selectedIndex)
390
- ? 0
391
- : -1
363
+ : isWeb &&
364
+ isAdapted &&
365
+ (isActive || (activeIndexRef?.current == null && isSelected))
366
+ ? 0
392
367
  : -1
393
368
  }
394
369
  zIndex={100}
@@ -68,7 +68,7 @@ const SelectScrollButtonImpl = React.memo(
68
68
  const { scope, dir, partClassName, ...scrollIndicatorProps } = props
69
69
  const { className, onPointerEnter, onPointerLeave, ...frameProps } =
70
70
  scrollIndicatorProps
71
- const { open, fallback, setScrollTop, setInnerOffset, ...context } =
71
+ const { open, fallback, updateScrollArrows, setInnerOffset, ...context } =
72
72
  useSelectContext(scope)
73
73
  const floatingRef = context.floatingContext?.refs.floating
74
74
 
@@ -109,7 +109,7 @@ const SelectScrollButtonImpl = React.memo(
109
109
  if (fallback) {
110
110
  if (floating.current) {
111
111
  floating.current.scrollTop -= amount
112
- flushSync(() => setScrollTop!(floating.current?.scrollTop ?? 0))
112
+ updateScrollArrows!()
113
113
  }
114
114
  } else {
115
115
  flushSync(() => setInnerOffset!((value) => value - amount))
@@ -34,6 +34,8 @@ export const SelectTrigger = createStyledHOC(
34
34
  forwardedRef,
35
35
  context.floatingContext?.refs.setReference as any
36
36
  )
37
+ // whether the press in flight began as a touch, so its click opens
38
+ const pressIsTouch = React.useRef(false)
37
39
 
38
40
  if (itemParentContext.shouldRenderWebNative) {
39
41
  return null
@@ -55,8 +57,8 @@ export const SelectTrigger = createStyledHOC(
55
57
  }
56
58
  }
57
59
  const interactionProps =
58
- process.env.TAMAGUI_TARGET === 'web' && itemParentContext.interactions
59
- ? itemParentContext.interactions.getReferenceProps({
60
+ process.env.TAMAGUI_TARGET === 'web' && context.interactions
61
+ ? context.interactions.getReferenceProps({
60
62
  ...triggerProps,
61
63
  onMouseDown: composeEventHandlers(triggerProps.onMouseDown as any, () =>
62
64
  context.floatingContext?.update?.()
@@ -66,13 +68,28 @@ export const SelectTrigger = createStyledHOC(
66
68
  ...triggerProps,
67
69
  ...(isWeb
68
70
  ? {
71
+ // a mouse opens on press so a press, drag and release picks
72
+ // in one gesture. a touch opens on release, as a native
73
+ // select does: opening on touchstart mounts the sheet
74
+ // overlay under the finger, and the same tap's click then
75
+ // lands on it and closes the sheet again.
69
76
  onMouseDown: composeEventHandlers(
70
77
  triggerProps.onMouseDown as any,
71
78
  (event: any) => {
79
+ pressIsTouch.current = event.type === 'touchstart'
80
+ if (pressIsTouch.current) return
72
81
  event.preventDefault()
73
82
  toggleOpen(event)
74
83
  }
75
84
  ),
85
+ onPress: composeEventHandlers(
86
+ triggerProps.onPress as any,
87
+ (event: any) => {
88
+ if (!pressIsTouch.current) return
89
+ pressIsTouch.current = false
90
+ toggleOpen(event)
91
+ }
92
+ ),
76
93
  }
77
94
  : {
78
95
  onPress: composeEventHandlers(triggerProps.onPress as any, toggleOpen),
@@ -1,6 +1,6 @@
1
1
  import {
2
- AdaptContext,
3
2
  AdaptPortalContents,
3
+ ProvideAdaptContext,
4
4
  useAdaptContext,
5
5
  useAdaptIsActive,
6
6
  } from '@tamagui/adapt'
@@ -26,17 +26,17 @@ export const SelectViewport = createStyledHOC(
26
26
  const context = useSelectContext(scope)
27
27
  const itemParentContext = useSelectItemParentContext(scope)
28
28
  const themeName = useThemeName()
29
- const adaptContext = useAdaptContext()
29
+ const adaptContext = useAdaptContext(context.adaptScope)
30
30
  const isAdapted = useAdaptIsActive(context.adaptScope)
31
31
 
32
32
  const contents = (
33
33
  <Theme name={themeName}>
34
34
  <ForwardSelectContext itemContext={itemParentContext} context={context}>
35
- <AdaptContext.Provider {...adaptContext}>
35
+ <ProvideAdaptContext {...adaptContext}>
36
36
  <SelectViewportFrame {...viewportProps} ref={forwardedRef}>
37
37
  {children}
38
38
  </SelectViewportFrame>
39
- </AdaptContext.Provider>
39
+ </ProvideAdaptContext>
40
40
  </ForwardSelectContext>
41
41
  </Theme>
42
42
  )
@@ -5,7 +5,6 @@ import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'
5
5
  import { createStyledHOC, styled, View } from '@tamagui/core'
6
6
  import { needsPortalRepropagation } from '@tamagui/portal'
7
7
  import { YStack } from '@tamagui/stacks'
8
- import { startTransition } from '@tamagui/start-transition'
9
8
  import * as React from 'react'
10
9
  import { VIEWPORT_NAME } from './constants'
11
10
  import {
@@ -37,17 +36,13 @@ export const SelectViewport = createStyledHOC(
37
36
  const viewportRef = React.useRef<any>(null)
38
37
  const registeredItemCount = itemContext.registry.getItems().length
39
38
 
40
- // lazy mount: defer mounting children until first open using startTransition
41
- const [lazyMounted, setLazyMounted] = React.useState(context.lazyMount ? false : true)
42
-
43
- React.useEffect(() => {
44
- if (!context.lazyMount) return
45
- if (!context.open) return
46
- if (lazyMounted) return
47
- startTransition(() => {
48
- setLazyMounted(true)
49
- })
50
- }, [context.lazyMount, context.open, lazyMounted])
39
+ // lazy mount keeps the items out of the tree until the first open, but it
40
+ // has to happen in the render that opens, not in an effect after it: mounting
41
+ // a frame later paints an empty popover, then floating-ui positions that empty
42
+ // box and has to jump once the real height arrives. once mounted it stays.
43
+ const hasOpenedRef = React.useRef(!context.lazyMount)
44
+ if (context.open) hasOpenedRef.current = true
45
+ const lazyMounted = hasOpenedRef.current
51
46
 
52
47
  React.useEffect(() => {
53
48
  if (!isWeb || !isAdapted || !context.open) return
@@ -62,7 +57,7 @@ export const SelectViewport = createStyledHOC(
62
57
  activeItem?.focus()
63
58
  })
64
59
  return () => cancelAnimationFrame(frame)
65
- }, [context.activeIndex, context.open, isAdapted, registeredItemCount])
60
+ }, [context.open, isAdapted, registeredItemCount])
66
61
 
67
62
  const composedRefs = useComposedRefs(
68
63
  // @ts-ignore react 19 ref type mismatch
@@ -77,14 +72,14 @@ export const SelectViewport = createStyledHOC(
77
72
  }
78
73
  }, [isAdapted])
79
74
 
80
- // after lazy children mount, force floating-ui to recompute so inner middleware
81
- // can position using the now-present list items
75
+ // items register in layout effects, so the inner middleware can only position
76
+ // against the selected one once the registry has settled
82
77
  React.useEffect(() => {
83
- if (context.lazyMount && lazyMounted && context.open && context.update) {
78
+ if (context.lazyMount && context.open && context.update) {
84
79
  const frame = requestAnimationFrame(context.update)
85
80
  return () => cancelAnimationFrame(frame)
86
81
  }
87
- }, [lazyMounted, registeredItemCount])
82
+ }, [context.open, registeredItemCount])
88
83
 
89
84
  if (itemContext.shouldRenderWebNative) {
90
85
  return <YStack position="relative">{children}</YStack>
@@ -115,16 +110,11 @@ export const SelectViewport = createStyledHOC(
115
110
  )
116
111
  }
117
112
 
118
- if (!itemContext.interactions) {
113
+ if (!context.interactions) {
119
114
  return null
120
115
  }
121
116
 
122
- const {
123
- style,
124
- // remove this, it was set to "Select" always
125
- className,
126
- ...floatingProps
127
- } = itemContext.interactions.getFloatingProps()
117
+ const floatingProps = context.interactions.getFloatingProps()
128
118
 
129
119
  // FloatingFocusManager removed — SelectContent already wraps with FocusScope
130
120
  // that handles focus trapping and auto-focus
@@ -137,7 +127,10 @@ export const SelectViewport = createStyledHOC(
137
127
  }}
138
128
  />
139
129
  )}
140
- <AnimatePresence>
130
+ {/* every styled item reads the presence context, and the default
131
+ (layout-affecting) presence hands out a new context per render, so
132
+ each viewport render would re-render the whole list */}
133
+ <AnimatePresence presenceAffectsLayout={false}>
141
134
  {context.open ? (
142
135
  <SelectViewportFrame
143
136
  key="select-viewport"
@@ -146,10 +139,11 @@ export const SelectViewport = createStyledHOC(
146
139
  'data-state': context.open ? 'open' : 'closed',
147
140
  })}
148
141
  {...viewportProps}
149
- {...style}
142
+ {...context.floatingPosition}
143
+ outlineWidth={0}
150
144
  {...floatingProps}
151
145
  {...getSelectListboxProps(itemContext.mode)}
152
- overflowY={disableScroll ? undefined : (style.overflow ?? 'auto')}
146
+ overflowY={disableScroll ? undefined : 'auto'}
153
147
  ref={composedRefs}
154
148
  >
155
149
  {lazyMounted ? children : null}
@@ -43,7 +43,12 @@ export function createSelectItemRegistry(onChange?: () => void) {
43
43
  const itemNodes = new Map<symbol, any>()
44
44
  const listeners = new Set<() => void>()
45
45
 
46
+ // value -> first index, rebuilt after any change: items look themselves up
47
+ // on every active-index emit, so the lookup must not walk the list
48
+ let indexByValue: Map<string, number> | null = null
49
+
46
50
  const notify = () => {
51
+ indexByValue = null
47
52
  onChange?.()
48
53
  listeners.forEach((listener) => listener())
49
54
  }
@@ -52,7 +57,18 @@ export function createSelectItemRegistry(onChange?: () => void) {
52
57
 
53
58
  const getItem = (value: string) => items.find((item) => item.value === value)
54
59
 
55
- const getIndex = (value: string) => items.findIndex((item) => item.value === value)
60
+ const getIndex = (value: string) => {
61
+ if (!indexByValue) {
62
+ indexByValue = new Map()
63
+ items.forEach((item, index) => {
64
+ if (!indexByValue!.has(item.value)) indexByValue!.set(item.value, index)
65
+ })
66
+ }
67
+ return indexByValue.get(value) ?? -1
68
+ }
69
+
70
+ /** the mounted nodes in item order; the owner mirrors this into its list ref */
71
+ const getNodes = () => items.map((item) => itemNodes.get(item.id) ?? null)
56
72
 
57
73
  const getRegisteredLabel = (value: string) => {
58
74
  const registrations = labelRegistrations.get(value)
@@ -78,7 +94,6 @@ export function createSelectItemRegistry(onChange?: () => void) {
78
94
  }
79
95
 
80
96
  const syncNodeOrder = () => {
81
- const previousOrder = items.map((item) => item.id)
82
97
  items.sort((a, b) => {
83
98
  const aNode = itemNodes.get(a.id)
84
99
  const bNode = itemNodes.get(b.id)
@@ -90,9 +105,6 @@ export function createSelectItemRegistry(onChange?: () => void) {
90
105
  if (position & 2) return 1
91
106
  return 0
92
107
  })
93
- if (items.some((item, index) => item.id !== previousOrder[index])) {
94
- notify()
95
- }
96
108
  }
97
109
 
98
110
  const registerItem = (
@@ -138,12 +150,14 @@ export function createSelectItemRegistry(onChange?: () => void) {
138
150
  notify()
139
151
  },
140
152
  setNode(node) {
153
+ if ((itemNodes.get(id) ?? null) === (node ?? null)) return
141
154
  if (node) {
142
155
  itemNodes.set(id, node)
143
156
  syncNodeOrder()
144
157
  } else {
145
158
  itemNodes.delete(id)
146
159
  }
160
+ notify()
147
161
  },
148
162
  unregister() {
149
163
  const index = items.findIndex((current) => current.id === id)
@@ -223,6 +237,7 @@ export function createSelectItemRegistry(onChange?: () => void) {
223
237
  getItems,
224
238
  getItem,
225
239
  getIndex,
240
+ getNodes,
226
241
  firstEnabledIndex,
227
242
  nextEnabledIndex,
228
243
  findTypeaheadIndex,
package/src/types.tsx CHANGED
@@ -1,6 +1,5 @@
1
1
  import type {
2
2
  NativeValue,
3
- SizeTokens,
4
3
  TamaguiChangeEventDetails,
5
4
  TamaguiEventDetails,
6
5
  ViewProps,
@@ -62,8 +61,6 @@ export type SelectImplProps = SelectScopedProps<SelectProps<string, boolean>> &
62
61
  activeIndexRef: any
63
62
  selectedIndexRef: any
64
63
  listContentRef: any
65
- /** fast setter: updates ref + emits to subscribers (no re-render) - use for hover/navigation */
66
- setActiveIndexFast: (index: number | null) => void
67
64
  }
68
65
 
69
66
  export interface SelectProps<
@@ -83,7 +80,8 @@ export interface SelectProps<
83
80
  defaultOpen?: boolean
84
81
  onOpenChange?(open: boolean, details: SelectOpenChangeDetails): void
85
82
  dir?: SelectDirection
86
- size?: SizeTokens | true
83
+ /** a skin-owned size name, threaded opaquely to the skin parts */
84
+ size?: string | boolean
87
85
  /**
88
86
  * If passed, will render a native component instead of the custom one. Currently only `web` is supported.
89
87
  */
@@ -154,40 +152,39 @@ export interface SelectItemParentContextValue {
154
152
  requestOpenChange: (open: boolean, details: SelectOpenChangeDetails) => void
155
153
  selectValue: (value: string, details: SelectValueChangeDetails) => void
156
154
  changeNativeValue: (value: SelectSelection, event: unknown) => void
157
- activeIndexSubscribe: EmitterSubscriber<number>
155
+ activeIndexSubscribe: EmitterSubscriber<number | null>
158
156
  activeIndexRef?: MutableRefObject<number | null>
159
157
  allowSelectRef?: MutableRefObject<boolean>
160
158
  allowMouseUpRef?: MutableRefObject<boolean>
161
159
  selectTimeoutRef?: MutableRefObject<any>
162
160
  dataRef?: MutableRefObject<ContextData>
163
- interactions?: {
164
- getReferenceProps: (userProps?: HTMLProps<Element> | undefined) => any
165
- getFloatingProps: (userProps?: HTMLProps<HTMLElement> | undefined) => any
166
- getItemProps: (userProps?: HTMLProps<HTMLElement> | undefined) => any
167
- }
161
+ /** web only: stable once the list mounts, so it can live beside the items */
162
+ getItemProps?: (userProps?: HTMLProps<HTMLElement> | undefined) => any
168
163
  shouldRenderWebNative?: boolean
169
- size?: SizeTokens | true
170
- /** fast setter: updates ref + emits to subscribers (no re-render) - use for keyboard navigation */
171
- setActiveIndexFast?: (index: number | null, details?: SelectActiveChangeDetails) => void
164
+ /** a skin-owned size name, threaded opaquely to the skin parts */
165
+ size?: string | boolean
166
+ setActiveIndex: (index: number | null, details?: SelectActiveChangeDetails) => void
167
+ selectedIndex: number
168
+ lastPointerRef: MutableRefObject<{ x: number; y: number }>
172
169
  moveActive: (direction: 1 | -1, event?: unknown) => void
173
170
  search: (text: string, event?: unknown) => void
174
171
  }
175
172
 
176
173
  export interface SelectContextValue {
174
+ /** web only: the trigger and viewport prop getters; change with the floating element */
175
+ interactions?: {
176
+ getReferenceProps: (userProps?: HTMLProps<Element> | undefined) => any
177
+ getFloatingProps: (userProps?: HTMLProps<HTMLElement> | undefined) => any
178
+ }
177
179
  dir?: SelectDirection
178
180
  scopeName: string
179
181
  adaptScope: string
180
182
  value: any
181
183
  mode: SelectMode
182
184
  selectedValues: string[]
183
- activeItem?: string
184
- selectionAnchor?: string
185
185
  selectedIndex: number
186
- /** current active index state - use for rendering, may lag behind ref */
187
- activeIndex: number | null
188
- /** ref to current active index - always up to date, use for reads */
186
+ /** the active index is never state: read the ref, or subscribe through the item parent context */
189
187
  activeIndexRef: MutableRefObject<number | null>
190
- /** slow setter: updates ref + emits + triggers re-render */
191
188
  setActiveIndex: (index: number | null, details?: SelectActiveChangeDetails) => void
192
189
  open: boolean
193
190
  valueNode: Element | null
@@ -197,12 +194,19 @@ export interface SelectContextValue {
197
194
  blockSelection: boolean
198
195
  upArrowRef?: MutableRefObject<HTMLDivElement | null>
199
196
  downArrowRef?: MutableRefObject<HTMLDivElement | null>
200
- setScrollTop?: Function
197
+ /** re-reads the viewport and flips canScrollUp/Down when they change */
198
+ updateScrollArrows?: () => void
201
199
  setInnerOffset?: Function
202
200
  controlledScrolling?: boolean
203
201
  canScrollUp?: boolean
204
202
  canScrollDown?: boolean
205
203
  floatingContext?: FloatingContext<ReferenceType>
204
+ /** InlineImpl only: where floating-ui put the viewport */
205
+ floatingPosition?: {
206
+ position: 'absolute' | 'fixed'
207
+ top: number | ''
208
+ left: number | ''
209
+ }
206
210
  native?: NativeValue
207
211
  disablePreventBodyScroll?: boolean
208
212
  /** update floating-ui to recalculate */
@@ -214,7 +218,8 @@ export interface SelectContextValue {
214
218
  }
215
219
 
216
220
  export type SelectViewportExtraProps = SelectScopedProps<{
217
- size?: SizeTokens | true
221
+ /** a skin-owned size name, threaded opaquely to the skin parts */
222
+ size?: string | boolean
218
223
  disableScroll?: boolean
219
224
  }>
220
225