@tamagui/select 2.7.7 → 3.0.0-beta.1091.1

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 (248) hide show
  1. package/dist/cjs/Select.cjs +406 -467
  2. package/dist/cjs/Select.native.cjs +495 -0
  3. package/dist/cjs/Select.native.js +445 -492
  4. package/dist/cjs/Select.native.js.map +1 -1
  5. package/dist/cjs/SelectContent.cjs +79 -79
  6. package/dist/cjs/SelectContent.native.cjs +29 -0
  7. package/dist/cjs/SelectContent.native.js +17 -22
  8. package/dist/cjs/SelectContent.native.js.map +1 -1
  9. package/dist/cjs/SelectImpl.cjs +305 -320
  10. package/dist/cjs/SelectImpl.native.cjs +29 -0
  11. package/dist/cjs/SelectImpl.native.js +16 -24
  12. package/dist/cjs/SelectImpl.native.js.map +1 -1
  13. package/dist/cjs/SelectItem.cjs +256 -205
  14. package/dist/cjs/SelectItem.native.cjs +288 -0
  15. package/dist/cjs/SelectItem.native.js +264 -213
  16. package/dist/cjs/SelectItem.native.js.map +1 -1
  17. package/dist/cjs/SelectItemText.cjs +48 -79
  18. package/dist/cjs/SelectItemText.native.cjs +73 -0
  19. package/dist/cjs/SelectItemText.native.js +49 -79
  20. package/dist/cjs/SelectItemText.native.js.map +1 -1
  21. package/dist/cjs/SelectScrollButton.cjs +110 -130
  22. package/dist/cjs/SelectScrollButton.native.cjs +37 -0
  23. package/dist/cjs/SelectScrollButton.native.js +24 -22
  24. package/dist/cjs/SelectScrollButton.native.js.map +1 -1
  25. package/dist/cjs/SelectTrigger.cjs +68 -84
  26. package/dist/cjs/SelectTrigger.native.cjs +90 -0
  27. package/dist/cjs/SelectTrigger.native.js +70 -66
  28. package/dist/cjs/SelectTrigger.native.js.map +1 -1
  29. package/dist/cjs/SelectViewport.cjs +121 -142
  30. package/dist/cjs/SelectViewport.native.cjs +72 -0
  31. package/dist/cjs/SelectViewport.native.js +53 -50
  32. package/dist/cjs/SelectViewport.native.js.map +1 -1
  33. package/dist/cjs/constants.cjs +20 -22
  34. package/dist/cjs/constants.native.cjs +40 -0
  35. package/dist/cjs/constants.native.js +20 -21
  36. package/dist/cjs/constants.native.js.map +1 -1
  37. package/dist/cjs/context.cjs +37 -48
  38. package/dist/cjs/context.native.cjs +57 -0
  39. package/dist/cjs/context.native.js +39 -49
  40. package/dist/cjs/context.native.js.map +1 -1
  41. package/dist/cjs/index.cjs +14 -11
  42. package/dist/cjs/index.native.cjs +26 -0
  43. package/dist/cjs/index.native.js +14 -10
  44. package/dist/cjs/index.native.js.map +1 -1
  45. package/dist/cjs/selectionController.cjs +343 -0
  46. package/dist/cjs/selectionController.native.cjs +407 -0
  47. package/dist/cjs/selectionController.native.js +409 -0
  48. package/dist/cjs/selectionController.native.js.map +1 -0
  49. package/dist/cjs/types.cjs +9 -11
  50. package/dist/cjs/types.native.cjs +18 -0
  51. package/dist/cjs/types.native.js +9 -10
  52. package/dist/cjs/types.native.js.map +1 -1
  53. package/dist/esm/Select.mjs +381 -443
  54. package/dist/esm/Select.mjs.map +1 -1
  55. package/dist/esm/Select.native.js +420 -469
  56. package/dist/esm/Select.native.js.map +1 -1
  57. package/dist/esm/SelectContent.mjs +67 -61
  58. package/dist/esm/SelectContent.mjs.map +1 -1
  59. package/dist/esm/SelectContent.native.js +4 -5
  60. package/dist/esm/SelectContent.native.js.map +1 -1
  61. package/dist/esm/SelectImpl.mjs +291 -295
  62. package/dist/esm/SelectImpl.mjs.map +1 -1
  63. package/dist/esm/SelectImpl.native.js +5 -8
  64. package/dist/esm/SelectImpl.native.js.map +1 -1
  65. package/dist/esm/SelectItem.mjs +240 -181
  66. package/dist/esm/SelectItem.mjs.map +1 -1
  67. package/dist/esm/SelectItem.native.js +249 -191
  68. package/dist/esm/SelectItem.native.js.map +1 -1
  69. package/dist/esm/SelectItemText.mjs +33 -55
  70. package/dist/esm/SelectItemText.mjs.map +1 -1
  71. package/dist/esm/SelectItemText.native.js +34 -56
  72. package/dist/esm/SelectItemText.native.js.map +1 -1
  73. package/dist/esm/SelectScrollButton.mjs +95 -107
  74. package/dist/esm/SelectScrollButton.mjs.map +1 -1
  75. package/dist/esm/SelectScrollButton.native.js +11 -7
  76. package/dist/esm/SelectScrollButton.native.js.map +1 -1
  77. package/dist/esm/SelectTrigger.mjs +56 -58
  78. package/dist/esm/SelectTrigger.mjs.map +1 -1
  79. package/dist/esm/SelectTrigger.native.js +54 -44
  80. package/dist/esm/SelectTrigger.native.js.map +1 -1
  81. package/dist/esm/SelectViewport.mjs +107 -119
  82. package/dist/esm/SelectViewport.mjs.map +1 -1
  83. package/dist/esm/SelectViewport.native.js +44 -39
  84. package/dist/esm/SelectViewport.native.js.map +1 -1
  85. package/dist/esm/constants.mjs +1 -0
  86. package/dist/esm/constants.mjs.map +1 -1
  87. package/dist/esm/constants.native.js +1 -0
  88. package/dist/esm/constants.native.js.map +1 -1
  89. package/dist/esm/context.mjs +20 -28
  90. package/dist/esm/context.mjs.map +1 -1
  91. package/dist/esm/context.native.js +23 -31
  92. package/dist/esm/context.native.js.map +1 -1
  93. package/dist/esm/index.js +13 -3
  94. package/dist/esm/index.mjs +13 -3
  95. package/dist/esm/index.native.js +13 -3
  96. package/dist/esm/selectionController.mjs +315 -0
  97. package/dist/esm/selectionController.mjs.map +1 -0
  98. package/dist/esm/selectionController.native.js +377 -0
  99. package/dist/esm/selectionController.native.js.map +1 -0
  100. package/dist/esm/types.mjs +0 -2
  101. package/dist/esm/types.native.js +0 -2
  102. package/dist/jsx/Select.mjs +381 -443
  103. package/dist/jsx/Select.mjs.map +1 -1
  104. package/dist/jsx/Select.native.cjs +495 -0
  105. package/dist/jsx/Select.native.js +445 -492
  106. package/dist/jsx/Select.native.js.map +1 -1
  107. package/dist/jsx/SelectContent.mjs +67 -61
  108. package/dist/jsx/SelectContent.mjs.map +1 -1
  109. package/dist/jsx/SelectContent.native.cjs +29 -0
  110. package/dist/jsx/SelectContent.native.js +17 -22
  111. package/dist/jsx/SelectContent.native.js.map +1 -1
  112. package/dist/jsx/SelectImpl.mjs +291 -295
  113. package/dist/jsx/SelectImpl.mjs.map +1 -1
  114. package/dist/jsx/SelectImpl.native.cjs +29 -0
  115. package/dist/jsx/SelectImpl.native.js +16 -24
  116. package/dist/jsx/SelectImpl.native.js.map +1 -1
  117. package/dist/jsx/SelectItem.mjs +240 -181
  118. package/dist/jsx/SelectItem.mjs.map +1 -1
  119. package/dist/jsx/SelectItem.native.cjs +288 -0
  120. package/dist/jsx/SelectItem.native.js +264 -213
  121. package/dist/jsx/SelectItem.native.js.map +1 -1
  122. package/dist/jsx/SelectItemText.mjs +33 -55
  123. package/dist/jsx/SelectItemText.mjs.map +1 -1
  124. package/dist/jsx/SelectItemText.native.cjs +73 -0
  125. package/dist/jsx/SelectItemText.native.js +49 -79
  126. package/dist/jsx/SelectItemText.native.js.map +1 -1
  127. package/dist/jsx/SelectScrollButton.mjs +95 -107
  128. package/dist/jsx/SelectScrollButton.mjs.map +1 -1
  129. package/dist/jsx/SelectScrollButton.native.cjs +37 -0
  130. package/dist/jsx/SelectScrollButton.native.js +24 -22
  131. package/dist/jsx/SelectScrollButton.native.js.map +1 -1
  132. package/dist/jsx/SelectTrigger.mjs +56 -58
  133. package/dist/jsx/SelectTrigger.mjs.map +1 -1
  134. package/dist/jsx/SelectTrigger.native.cjs +90 -0
  135. package/dist/jsx/SelectTrigger.native.js +70 -66
  136. package/dist/jsx/SelectTrigger.native.js.map +1 -1
  137. package/dist/jsx/SelectViewport.mjs +107 -119
  138. package/dist/jsx/SelectViewport.mjs.map +1 -1
  139. package/dist/jsx/SelectViewport.native.cjs +72 -0
  140. package/dist/jsx/SelectViewport.native.js +53 -50
  141. package/dist/jsx/SelectViewport.native.js.map +1 -1
  142. package/dist/jsx/constants.mjs +1 -0
  143. package/dist/jsx/constants.mjs.map +1 -1
  144. package/dist/jsx/constants.native.cjs +40 -0
  145. package/dist/jsx/constants.native.js +20 -21
  146. package/dist/jsx/constants.native.js.map +1 -1
  147. package/dist/jsx/context.mjs +20 -28
  148. package/dist/jsx/context.mjs.map +1 -1
  149. package/dist/jsx/context.native.cjs +57 -0
  150. package/dist/jsx/context.native.js +39 -49
  151. package/dist/jsx/context.native.js.map +1 -1
  152. package/dist/jsx/index.js +13 -3
  153. package/dist/jsx/index.mjs +13 -3
  154. package/dist/jsx/index.native.cjs +26 -0
  155. package/dist/jsx/index.native.js +14 -10
  156. package/dist/jsx/index.native.js.map +1 -1
  157. package/dist/jsx/selectionController.mjs +315 -0
  158. package/dist/jsx/selectionController.mjs.map +1 -0
  159. package/dist/jsx/selectionController.native.cjs +407 -0
  160. package/dist/jsx/selectionController.native.js +409 -0
  161. package/dist/jsx/selectionController.native.js.map +1 -0
  162. package/dist/jsx/types.mjs +0 -2
  163. package/dist/jsx/types.native.cjs +18 -0
  164. package/dist/jsx/types.native.js +9 -10
  165. package/dist/jsx/types.native.js.map +1 -1
  166. package/package.json +31 -30
  167. package/src/Select.tsx +478 -378
  168. package/src/SelectContent.tsx +46 -16
  169. package/src/SelectImpl.native.tsx +0 -8
  170. package/src/SelectImpl.tsx +149 -160
  171. package/src/SelectItem.tsx +280 -159
  172. package/src/SelectItemText.tsx +16 -33
  173. package/src/SelectScrollButton.native.tsx +13 -4
  174. package/src/SelectScrollButton.tsx +57 -42
  175. package/src/SelectTrigger.tsx +77 -53
  176. package/src/SelectViewport.native.tsx +42 -25
  177. package/src/SelectViewport.tsx +60 -60
  178. package/src/context.tsx +6 -2
  179. package/src/index.tsx +5 -2
  180. package/src/selectionController.ts +404 -0
  181. package/src/types.tsx +105 -59
  182. package/types/Select.d.ts +139 -201
  183. package/types/Select.d.ts.map +1 -1
  184. package/types/SelectContent.d.ts +1 -1
  185. package/types/SelectContent.d.ts.map +1 -1
  186. package/types/SelectContent.native.d.ts.map +1 -1
  187. package/types/SelectImpl.d.ts +2 -1
  188. package/types/SelectImpl.d.ts.map +1 -1
  189. package/types/SelectImpl.native.d.ts +1 -5
  190. package/types/SelectImpl.native.d.ts.map +1 -1
  191. package/types/SelectItem.d.ts +13 -16
  192. package/types/SelectItem.d.ts.map +1 -1
  193. package/types/SelectItemText.d.ts +9 -13
  194. package/types/SelectItemText.d.ts.map +1 -1
  195. package/types/SelectScrollButton.d.ts +15 -2
  196. package/types/SelectScrollButton.d.ts.map +1 -1
  197. package/types/SelectScrollButton.native.d.ts +16 -3
  198. package/types/SelectScrollButton.native.d.ts.map +1 -1
  199. package/types/SelectTrigger.d.ts +11 -23
  200. package/types/SelectTrigger.d.ts.map +1 -1
  201. package/types/SelectViewport.d.ts +11 -38
  202. package/types/SelectViewport.d.ts.map +1 -1
  203. package/types/SelectViewport.native.d.ts +15 -4
  204. package/types/SelectViewport.native.d.ts.map +1 -1
  205. package/types/context.d.ts +2 -0
  206. package/types/context.d.ts.map +1 -1
  207. package/types/index.d.ts +5 -2
  208. package/types/index.d.ts.map +1 -1
  209. package/types/selectionController.d.ts +90 -0
  210. package/types/selectionController.d.ts.map +1 -0
  211. package/types/types.d.ts +66 -50
  212. package/types/types.d.ts.map +1 -1
  213. package/dist/cjs/BubbleSelect.cjs +0 -50
  214. package/dist/cjs/BubbleSelect.native.js +0 -30
  215. package/dist/cjs/BubbleSelect.native.js.map +0 -1
  216. package/dist/cjs/useSelectBreakpointActive.cjs +0 -32
  217. package/dist/cjs/useSelectBreakpointActive.native.js +0 -35
  218. package/dist/cjs/useSelectBreakpointActive.native.js.map +0 -1
  219. package/dist/esm/BubbleSelect.mjs +0 -27
  220. package/dist/esm/BubbleSelect.mjs.map +0 -1
  221. package/dist/esm/BubbleSelect.native.js +0 -4
  222. package/dist/esm/BubbleSelect.native.js.map +0 -1
  223. package/dist/esm/index.js.map +0 -1
  224. package/dist/esm/index.mjs.map +0 -1
  225. package/dist/esm/index.native.js.map +0 -1
  226. package/dist/esm/types.mjs.map +0 -1
  227. package/dist/esm/types.native.js.map +0 -1
  228. package/dist/esm/useSelectBreakpointActive.mjs +0 -7
  229. package/dist/esm/useSelectBreakpointActive.mjs.map +0 -1
  230. package/dist/esm/useSelectBreakpointActive.native.js +0 -7
  231. package/dist/esm/useSelectBreakpointActive.native.js.map +0 -1
  232. package/dist/jsx/BubbleSelect.mjs +0 -27
  233. package/dist/jsx/BubbleSelect.mjs.map +0 -1
  234. package/dist/jsx/BubbleSelect.native.js +0 -30
  235. package/dist/jsx/BubbleSelect.native.js.map +0 -1
  236. package/dist/jsx/index.js.map +0 -1
  237. package/dist/jsx/index.mjs.map +0 -1
  238. package/dist/jsx/types.mjs.map +0 -1
  239. package/dist/jsx/useSelectBreakpointActive.mjs +0 -7
  240. package/dist/jsx/useSelectBreakpointActive.mjs.map +0 -1
  241. package/dist/jsx/useSelectBreakpointActive.native.js +0 -35
  242. package/dist/jsx/useSelectBreakpointActive.native.js.map +0 -1
  243. package/src/BubbleSelect.tsx +0 -50
  244. package/src/useSelectBreakpointActive.tsx +0 -7
  245. package/types/BubbleSelect.d.ts +0 -2
  246. package/types/BubbleSelect.d.ts.map +0 -1
  247. package/types/useSelectBreakpointActive.d.ts +0 -3
  248. package/types/useSelectBreakpointActive.d.ts.map +0 -1
package/src/Select.tsx CHANGED
@@ -1,26 +1,24 @@
1
1
  import { Adapt, AdaptParent, useAdaptIsActive } from '@tamagui/adapt'
2
2
  import { useComposedRefs } from '@tamagui/compose-refs'
3
3
  import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'
4
- import type { FontSizeTokens, GetProps, SizeTokens, TamaguiElement } from '@tamagui/core'
4
+ import type { GetProps } from '@tamagui/core'
5
5
  import {
6
+ createChangeEventDetails,
7
+ createStyledHOC,
6
8
  createStyledContext,
7
- getVariableValue,
8
9
  styled,
9
- useEvent,
10
- useGet,
10
+ Text,
11
+ View,
11
12
  } from '@tamagui/core'
12
13
  import { FocusScopeController } from '@tamagui/focus-scope'
13
14
  import { registerFocusable } from '@tamagui/focusable'
14
- import { getSpace } from '@tamagui/get-token'
15
15
  import { withStaticProperties } from '@tamagui/helpers'
16
- import { Separator } from '@tamagui/separator'
17
- import { SheetController } from '@tamagui/sheet/controller'
18
- import { XStack, YStack } from '@tamagui/stacks'
19
- import { Paragraph, SizableText } from '@tamagui/text'
20
16
  import { useControllableState } from '@tamagui/use-controllable-state'
17
+ import type { ControllableStateSetter } from '@tamagui/use-controllable-state'
21
18
  import * as React from 'react'
22
19
  import {
23
20
  SelectItemParentProvider,
21
+ SelectNativeComponentContext,
24
22
  SelectProvider,
25
23
  SelectZIndexContext,
26
24
  useSelectContext,
@@ -29,12 +27,31 @@ import {
29
27
  import { SelectContent } from './SelectContent'
30
28
  import { SelectInlineImpl } from './SelectImpl'
31
29
  import { SelectItem, useSelectItemContext } from './SelectItem'
32
- import { ITEM_TEXT_NAME, SelectItemText } from './SelectItemText'
30
+ import { SelectItemText } from './SelectItemText'
33
31
  import { SelectScrollDownButton, SelectScrollUpButton } from './SelectScrollButton'
34
32
  import { SelectTrigger } from './SelectTrigger'
35
33
  import { SelectViewport } from './SelectViewport'
36
- import type { SelectImplProps, SelectProps, SelectScopedProps } from './types'
37
- import { useShowSelectSheet } from './useSelectBreakpointActive'
34
+ import {
35
+ createSelectItemRegistry,
36
+ createSelectSelectionController,
37
+ selectedValuesFromSelection,
38
+ type SelectMode,
39
+ type SelectSelection,
40
+ } from './selectionController'
41
+ import type {
42
+ SelectActiveChangeDetails,
43
+ SelectImplProps,
44
+ SelectOpenChangeDetails,
45
+ SelectProps,
46
+ SelectScopedProps,
47
+ SelectValueForMode,
48
+ SelectValueChangeDetails,
49
+ } from './types'
50
+
51
+ export type SelectValue<
52
+ Value extends string = string,
53
+ Multiple extends boolean | undefined = false,
54
+ > = SelectValueForMode<Value, Multiple>
38
55
 
39
56
  /* -------------------------------------------------------------------------------------------------
40
57
  * SelectValue
@@ -42,9 +59,8 @@ import { useShowSelectSheet } from './useSelectBreakpointActive'
42
59
 
43
60
  const VALUE_NAME = 'SelectValue'
44
61
 
45
- const SelectValueFrame = styled(SizableText, {
46
- name: VALUE_NAME,
47
- userSelect: 'none',
62
+ export const SelectValueFrame = styled(Text, {
63
+ displayName: VALUE_NAME,
48
64
  })
49
65
 
50
66
  export type SelectValueExtraProps = SelectScopedProps<{
@@ -53,9 +69,10 @@ export type SelectValueExtraProps = SelectScopedProps<{
53
69
 
54
70
  export type SelectValueProps = GetProps<typeof SelectValueFrame> & SelectValueExtraProps
55
71
 
56
- const SelectValue = SelectValueFrame.styleable<SelectValueExtraProps>(
72
+ export const SelectValue = createStyledHOC(
73
+ SelectValueFrame,
57
74
  function SelectValue(
58
- { scope, children: childrenProp, placeholder, ...props },
75
+ { scope, children: childrenProp, placeholder, ...props }: SelectValueProps,
59
76
  forwardedRef
60
77
  ) {
61
78
  // We ignore `className` and `style` as this part shouldn't be styled.
@@ -63,76 +80,77 @@ const SelectValue = SelectValueFrame.styleable<SelectValueExtraProps>(
63
80
  const itemParentContext = useSelectItemParentContext(scope)
64
81
 
65
82
  const composedRefs = useComposedRefs(
66
- // @ts-ignore TODO react 19 type needs fix
83
+ // @ts-ignore react 19 ref type mismatch
67
84
  forwardedRef,
68
85
  context.onValueNodeChange as any
69
86
  )
70
- const isEmptyValue = context.value == null || context.value === ''
87
+ const isEmptyValue = context.selectedValues.length === 0
88
+
89
+ // the label comes from the registry, which items fill in after mount and
90
+ // outside any context value, so this is the one registry subscriber
91
+ const [, rerender] = React.useReducer((version: number) => version + 1, 0)
92
+ useIsomorphicLayoutEffect(
93
+ () => itemParentContext.registry.subscribe(rerender),
94
+ [itemParentContext.registry]
95
+ )
71
96
 
72
- // Use renderValue for SSR support - called synchronously during render
73
- // Falls back to the portal-based selectedItem, then to the raw value for SSR
74
- const renderedValue = context.renderValue?.(context.value)
97
+ // renderValue is synchronous for ssr and lazy item mounting
98
+ const registeredValue =
99
+ context.mode === 'multiple'
100
+ ? context.selectedValues.map((value, index) => (
101
+ <React.Fragment key={value}>
102
+ {index > 0 ? ', ' : null}
103
+ {itemParentContext.registry.getItem(value)?.label ?? value}
104
+ </React.Fragment>
105
+ ))
106
+ : (itemParentContext.registry.getItem(context.selectedValues[0])?.label ??
107
+ context.value)
75
108
  const children =
76
- childrenProp ?? renderedValue ?? itemParentContext.selectedItem ?? context.value
109
+ childrenProp !== undefined
110
+ ? childrenProp
111
+ : context.renderValue
112
+ ? context.renderValue(context.value)
113
+ : registeredValue
77
114
  const selectValueChildren = isEmptyValue ? (placeholder ?? children) : children
78
115
 
79
116
  return (
80
- <SelectValueFrame
81
- {...(!props.unstyled && {
82
- size: itemParentContext.size as any,
83
- ellipsis: true,
84
- // we don't want events from the portalled `SelectValue` children to bubble
85
- // through the item they came from
86
- pointerEvents: 'none',
87
- })}
88
- ref={composedRefs}
89
- {...props}
90
- >
91
- {unwrapSelectItem(selectValueChildren)}
117
+ <SelectValueFrame pointerEvents="none" ref={composedRefs} {...props}>
118
+ {selectValueChildren}
92
119
  </SelectValueFrame>
93
120
  )
94
121
  }
95
122
  )
96
123
 
97
- function unwrapSelectItem(selectValueChildren: any) {
98
- return React.Children.map(selectValueChildren, (child) => {
99
- if (child) {
100
- if (child.type?.staticConfig?.componentName === ITEM_TEXT_NAME) {
101
- return child.props.children
102
- }
103
- if (child.props?.children) {
104
- return unwrapSelectItem(child.props.children)
105
- }
106
- }
107
- return child
108
- })
109
- }
110
-
111
124
  /* -------------------------------------------------------------------------------------------------
112
125
  * SelectIcon
113
126
  * -----------------------------------------------------------------------------------------------*/
114
127
 
115
- export const SelectIcon = styled(XStack, {
116
- name: 'SelectIcon',
128
+ export const SelectIcon = styled(View, {
129
+ displayName: 'SelectIcon',
117
130
  // @ts-ignore
118
131
  'aria-hidden': true,
119
- children: <Paragraph>▼</Paragraph>,
120
132
  })
121
133
 
122
134
  /* -------------------------------------------------------------------------------------------------
123
135
  * SelectItemIndicator
124
136
  * -----------------------------------------------------------------------------------------------*/
125
137
 
126
- const SelectItemIndicatorFrame = styled(XStack, {
127
- name: 'SelectItemIndicator',
138
+ export const SelectItemIndicatorFrame = styled(View, {
139
+ displayName: 'SelectItemIndicator',
128
140
  })
129
141
 
130
- type SelectItemIndicatorProps = SelectScopedProps<
142
+ export type SelectItemIndicatorProps = SelectScopedProps<
131
143
  GetProps<typeof SelectItemIndicatorFrame>
132
144
  >
133
145
 
134
- const SelectItemIndicator = React.forwardRef<TamaguiElement, SelectItemIndicatorProps>(
135
- function SelectItemIndicator(props, forwardedRef) {
146
+ export const SelectItemIndicator = createStyledHOC(
147
+ SelectItemIndicatorFrame,
148
+ function SelectItemIndicator(
149
+ props: {
150
+ scope?: string
151
+ },
152
+ forwardedRef
153
+ ) {
136
154
  const { scope, ...itemIndicatorProps } = props
137
155
  const context = useSelectItemParentContext(scope)
138
156
  const itemContext = useSelectItemContext(scope)
@@ -151,77 +169,68 @@ const SelectItemIndicator = React.forwardRef<TamaguiElement, SelectItemIndicator
151
169
  * SelectIndicator
152
170
  * -----------------------------------------------------------------------------------------------*/
153
171
 
154
- const SelectIndicatorFrame = styled(YStack, {
155
- name: 'SelectIndicator',
156
-
157
- variants: {
158
- unstyled: {
159
- false: {
160
- position: 'absolute',
161
- top: 0,
162
- left: 0,
163
- pointerEvents: 'none',
164
- zIndex: 10,
165
- backgroundColor: '$background',
166
- borderRadius: 0,
167
- },
168
- },
169
- } as const,
170
-
171
- defaultVariants: {
172
- unstyled: process.env.TAMAGUI_HEADLESS === '1',
173
- },
172
+ export const SelectIndicatorFrame = styled(View, {
173
+ displayName: 'SelectIndicator',
174
+ position: 'absolute',
175
+ top: 0,
176
+ left: 0,
177
+ pointerEvents: 'none',
178
+ zIndex: 10,
174
179
  })
175
180
 
176
181
  export type SelectIndicatorProps = GetProps<typeof SelectIndicatorFrame>
177
182
 
178
- const SelectIndicator = SelectIndicatorFrame.styleable<
179
- SelectScopedProps<SelectIndicatorProps>
180
- >(function SelectIndicator({ scope, ...props }, forwardedRef) {
181
- const itemContext = useSelectItemParentContext(scope)
182
- const context = useSelectContext(scope)
183
- const [layout, setLayout] = React.useState<any>(null)
184
-
185
- const rafRef = React.useRef<any>(0)
186
-
187
- React.useLayoutEffect(() => {
188
- const update = (index: number | null) => {
189
- if (typeof index !== 'number') return
190
- cancelAnimationFrame(rafRef.current)
191
- rafRef.current = requestAnimationFrame(() => {
192
- const node = itemContext.listRef?.current?.[index]
193
- if (node) {
194
- setLayout({
195
- width: Math.round(node.offsetWidth),
196
- height: Math.round(node.offsetHeight),
197
- x: Math.round(node.offsetLeft),
198
- y: Math.round(node.offsetTop),
199
- })
200
- }
201
- })
202
- }
183
+ export const SelectIndicator = createStyledHOC(
184
+ SelectIndicatorFrame,
185
+ function SelectIndicator(
186
+ { scope, ...props }: SelectScopedProps<SelectIndicatorProps>,
187
+ forwardedRef
188
+ ) {
189
+ const itemContext = useSelectItemParentContext(scope)
190
+ const context = useSelectContext(scope)
191
+ const [layout, setLayout] = React.useState<any>(null)
192
+
193
+ const rafRef = React.useRef<any>(0)
194
+
195
+ useIsomorphicLayoutEffect(() => {
196
+ const update = (index: number | null) => {
197
+ if (typeof index !== 'number') return
198
+ cancelAnimationFrame(rafRef.current)
199
+ rafRef.current = requestAnimationFrame(() => {
200
+ const node = itemContext.listRef?.current?.[index]
201
+ if (node) {
202
+ setLayout({
203
+ width: Math.round(node.offsetWidth),
204
+ height: Math.round(node.offsetHeight),
205
+ x: Math.round(node.offsetLeft),
206
+ y: Math.round(node.offsetTop),
207
+ })
208
+ }
209
+ })
210
+ }
203
211
 
204
- // use ref for initial read to avoid depending on state
205
- if (context.open && context.activeIndexRef.current !== null) {
206
- update(context.activeIndexRef.current)
207
- }
212
+ // use ref for initial read to avoid depending on state
213
+ if (context.open && context.activeIndexRef.current !== null) {
214
+ update(context.activeIndexRef.current)
215
+ }
208
216
 
209
- return itemContext.activeIndexSubscribe(update)
210
- }, [context.open, itemContext.listRef])
217
+ return itemContext.activeIndexSubscribe(update)
218
+ }, [context.open, itemContext.listRef])
211
219
 
212
- if (!layout) return null
220
+ if (!layout) return null
213
221
 
214
- return (
215
- <SelectIndicatorFrame
216
- ref={forwardedRef}
217
- {...props}
218
- width={layout.width}
219
- height={layout.height}
220
- x={layout.x}
221
- y={layout.y}
222
- />
223
- )
224
- })
222
+ return (
223
+ <SelectIndicatorFrame
224
+ ref={forwardedRef}
225
+ {...props}
226
+ width={layout.width}
227
+ height={layout.height}
228
+ x={layout.x}
229
+ y={layout.y}
230
+ />
231
+ )
232
+ }
233
+ )
225
234
 
226
235
  /* -------------------------------------------------------------------------------------------------
227
236
  * SelectGroup
@@ -234,96 +243,61 @@ type SelectGroupContextValue = { id: string }
234
243
  const { Provider: SelectGroupContextProvider, useStyledContext: useSelectGroupContext } =
235
244
  createStyledContext<SelectGroupContextValue>({ id: '' }, 'SelectGroup')
236
245
 
237
- export const SelectGroupFrame = styled(YStack, {
238
- name: GROUP_NAME,
239
- width: '100%',
246
+ export const SelectGroupFrame = styled(View, {
247
+ displayName: GROUP_NAME,
240
248
  })
241
249
 
242
- const NativeSelectTextFrame = styled(SizableText, {
250
+ const NativeSelectFrame = styled(Text, {
251
+ displayName: 'NativeSelect',
243
252
  render: 'select',
244
- backgroundColor: '$background',
245
- borderColor: '$borderColor',
246
- hoverStyle: {
247
- backgroundColor: '$backgroundHover',
248
- },
249
- })
250
-
251
- const NativeSelectFrame = styled(YStack, {
252
- name: 'NativeSelect',
253
-
254
- variants: {
255
- size: {
256
- '...size': (val, extras) => {
257
- const { tokens } = extras
258
- const paddingHorizontal = getVariableValue(tokens.space[val])
259
-
260
- return {
261
- borderRadius: tokens.radius[val] ?? val,
262
- minHeight: tokens.size[val],
263
- paddingRight: paddingHorizontal + 20,
264
- paddingLeft: paddingHorizontal,
265
- paddingVertical: getSpace(val, {
266
- shift: -3,
267
- }),
268
- }
269
- },
270
- },
271
-
272
- unstyled: {
273
- false: {
274
- borderWidth: 1,
275
- borderColor: '$borderColor',
276
- userSelect: 'none',
277
- outlineWidth: 0,
278
- paddingRight: 10,
279
- },
280
- },
281
- } as const,
282
-
283
- defaultVariants: {
284
- size: '$2',
285
- unstyled: process.env.TAMAGUI_HEADLESS === '1' ? true : false,
286
- },
287
253
  })
254
+ const NativeSelect = NativeSelectFrame as any
288
255
 
289
256
  type SelectGroupProps = SelectScopedProps<GetProps<typeof SelectGroupFrame>>
290
257
 
291
- const SelectGroup = React.forwardRef<TamaguiElement, SelectGroupProps>(
292
- (props, forwardedRef) => {
258
+ export const SelectGroup = createStyledHOC(
259
+ SelectGroupFrame,
260
+ (props: SelectGroupProps, forwardedRef) => {
293
261
  const { scope, ...groupProps } = props
294
262
  const groupId = React.useId()
295
263
 
296
264
  const context = useSelectContext(scope)
297
265
  const itemParentContext = useSelectItemParentContext(scope)
298
- const size = itemParentContext.size ?? '$true'
266
+ const StyledNativeSelect = React.useContext(SelectNativeComponentContext)
299
267
  const nativeSelectRef = React.useRef<HTMLSelectElement>(null)
300
268
 
301
269
  const content = (() => {
302
270
  if (itemParentContext.shouldRenderWebNative) {
303
271
  return (
304
- <NativeSelectFrame
305
- asChild
306
- size={size}
307
- // @ts-expect-error until we support typing based on tag
272
+ <NativeSelect
273
+ {...(StyledNativeSelect && {
274
+ render: StyledNativeSelect,
275
+ size: itemParentContext.size,
276
+ style: { appearance: 'none' },
277
+ })}
278
+ {...groupProps}
279
+ // @ts-ignore it's ok since render="select"
308
280
  value={context.value}
281
+ multiple={context.mode === 'multiple'}
282
+ name={itemParentContext.name}
283
+ form={itemParentContext.form}
309
284
  id={itemParentContext.id}
285
+ onChange={
286
+ ((event: React.ChangeEvent<HTMLSelectElement>) => {
287
+ const value =
288
+ context.mode === 'multiple'
289
+ ? Array.from(
290
+ event.currentTarget.selectedOptions,
291
+ (option) => option.value
292
+ )
293
+ : event.currentTarget.value
294
+ itemParentContext.changeNativeValue(value, event.nativeEvent)
295
+ }) as any
296
+ }
297
+ ref={nativeSelectRef as any}
310
298
  >
311
- <NativeSelectTextFrame
312
- // @ts-ignore it's ok since render="select"
313
- onChange={(event: React.ChangeEvent<HTMLSelectElement>) => {
314
- itemParentContext.onChange(event.currentTarget.value)
315
- }}
316
- size={size as FontSizeTokens}
317
- ref={nativeSelectRef as any}
318
- style={{
319
- color: 'var(--color)',
320
- // @ts-ignore
321
- appearance: 'none',
322
- }}
323
- >
324
- {props.children}
325
- </NativeSelectTextFrame>
326
- </NativeSelectFrame>
299
+ {props.children}
300
+ </NativeSelect>
327
301
  )
328
302
  }
329
303
  return (
@@ -353,39 +327,14 @@ SelectGroup.displayName = GROUP_NAME
353
327
 
354
328
  const LABEL_NAME = 'SelectLabel'
355
329
 
356
- const SelectLabelFrame = styled(SizableText, {
357
- name: LABEL_NAME,
358
-
359
- variants: {
360
- unstyled: {
361
- false: {
362
- size: '$true',
363
- ellipsis: true,
364
- maxWidth: '100%',
365
- cursor: 'default',
366
- },
367
- },
368
-
369
- size: {
370
- '...size': (val: SizeTokens, { tokens }) => {
371
- return {
372
- paddingHorizontal: tokens.space[val],
373
- paddingVertical: getSpace(tokens.space[val], {
374
- shift: -4,
375
- }),
376
- }
377
- },
378
- },
379
- } as const,
380
-
381
- defaultVariants: {
382
- unstyled: process.env.TAMAGUI_HEADLESS === '1',
383
- },
330
+ export const SelectLabelFrame = styled(Text, {
331
+ displayName: LABEL_NAME,
384
332
  })
385
333
 
386
334
  export type SelectLabelProps = SelectScopedProps<GetProps<typeof SelectLabelFrame>>
387
335
 
388
- const SelectLabel = SelectLabelFrame.styleable<{ scope?: any }>(
336
+ export const SelectLabel = createStyledHOC(
337
+ SelectLabelFrame,
389
338
  (props: SelectLabelProps, forwardedRef) => {
390
339
  const { scope, ...labelProps } = props
391
340
  const context = useSelectItemParentContext(scope)
@@ -399,7 +348,6 @@ const SelectLabel = SelectLabelFrame.styleable<{ scope?: any }>(
399
348
  <SelectLabelFrame
400
349
  render="div"
401
350
  id={groupContext.id}
402
- size={context.size}
403
351
  {...labelProps}
404
352
  ref={forwardedRef}
405
353
  />
@@ -411,36 +359,10 @@ const SelectLabel = SelectLabelFrame.styleable<{ scope?: any }>(
411
359
  * SelectSeparator
412
360
  * -----------------------------------------------------------------------------------------------*/
413
361
 
414
- export const SelectSeparator = styled(Separator, {
415
- name: 'SelectSeparator',
362
+ export const SelectSeparator = styled(View, {
363
+ displayName: 'SelectSeparator',
416
364
  })
417
365
 
418
- const SelectSheetController = (
419
- props: SelectScopedProps<{
420
- children: React.ReactNode
421
- onOpenChange: React.Dispatch<React.SetStateAction<boolean>>
422
- }>
423
- ) => {
424
- const context = useSelectContext(props.scope)
425
- const showSheet = useShowSelectSheet(context)
426
- const isAdapted = useAdaptIsActive(context.adaptScope)
427
- const getShowSheet = useGet(showSheet)
428
-
429
- return (
430
- <SheetController
431
- onOpenChange={(val) => {
432
- if (getShowSheet()) {
433
- props.onOpenChange(val)
434
- }
435
- }}
436
- open={context.open}
437
- hidden={!isAdapted}
438
- >
439
- {props.children}
440
- </SheetController>
441
- )
442
- }
443
-
444
366
  const SelectSheetImpl = (props: SelectImplProps) => {
445
367
  return <>{props.children}</>
446
368
  }
@@ -449,36 +371,72 @@ const SelectSheetImpl = (props: SelectImplProps) => {
449
371
  * Select
450
372
  * -----------------------------------------------------------------------------------------------*/
451
373
 
452
- export const Select = withStaticProperties(
453
- function Select<Value extends string = string>(
454
- props: SelectScopedProps<SelectProps<Value>>
455
- ) {
456
- const adaptScope = `AdaptSelect${props.scope || ''}`
374
+ export function SelectRoot<
375
+ Value extends string = string,
376
+ Multiple extends boolean | undefined = false,
377
+ >(props: SelectScopedProps<SelectProps<Value, Multiple>>) {
378
+ const adaptScope = `AdaptSelect${props.scope || ''}`
379
+
380
+ // open state lives here (above AdaptParent) so the Adapt handoff can drive
381
+ // the adapted Sheet's open/close and unmount timing directly, mirroring Dialog
382
+ const [open, requestOpenChange] = useControllableState<
383
+ boolean,
384
+ SelectOpenChangeDetails
385
+ >({
386
+ prop: props.open,
387
+ defaultProp: props.defaultOpen || false,
388
+ onChange: props.onOpenChange as any,
389
+ })
457
390
 
458
- return (
459
- <AdaptParent scope={adaptScope} portal>
460
- <SelectInner scope={props.scope} adaptScope={adaptScope} {...props} />
461
- </AdaptParent>
462
- )
463
- },
464
- {
465
- Adapt: Adapt,
466
- Content: SelectContent,
467
- Group: SelectGroup,
468
- Icon: SelectIcon,
469
- Item: SelectItem,
470
- ItemIndicator: SelectItemIndicator,
471
- ItemText: SelectItemText,
472
- Label: SelectLabel,
473
- ScrollDownButton: SelectScrollDownButton,
474
- ScrollUpButton: SelectScrollUpButton,
475
- Trigger: SelectTrigger,
476
- Value: SelectValue,
477
- Viewport: SelectViewport,
478
- Indicator: SelectIndicator,
479
- FocusScope: FocusScopeController,
480
- }
481
- )
391
+ const handleAdaptOpenChange = React.useCallback(
392
+ (nextOpen: boolean) => {
393
+ requestOpenChange(
394
+ nextOpen,
395
+ createChangeEventDetails(
396
+ nextOpen ? 'trigger-press' : 'outside-press'
397
+ ) as SelectOpenChangeDetails
398
+ )
399
+ },
400
+ [requestOpenChange]
401
+ )
402
+
403
+ return (
404
+ <AdaptParent
405
+ scope={adaptScope}
406
+ adaptChildren={props.children}
407
+ open={open}
408
+ onOpenChange={handleAdaptOpenChange}
409
+ >
410
+ <SelectInner
411
+ {...(props as SelectScopedProps<SelectProps<string, boolean>>)}
412
+ scope={props.scope}
413
+ adaptScope={adaptScope}
414
+ open={open}
415
+ requestOpenChange={requestOpenChange}
416
+ />
417
+ </AdaptParent>
418
+ )
419
+ }
420
+
421
+ export const Select = withStaticProperties(SelectRoot, {
422
+ Root: SelectRoot,
423
+ Adapt: Adapt,
424
+ Content: SelectContent,
425
+ Group: SelectGroup,
426
+ Icon: SelectIcon,
427
+ Item: SelectItem,
428
+ ItemIndicator: SelectItemIndicator,
429
+ ItemText: SelectItemText,
430
+ Label: SelectLabel,
431
+ ScrollDownButton: SelectScrollDownButton,
432
+ ScrollUpButton: SelectScrollUpButton,
433
+ Separator: SelectSeparator,
434
+ Trigger: SelectTrigger,
435
+ Value: SelectValue,
436
+ Viewport: SelectViewport,
437
+ Indicator: SelectIndicator,
438
+ FocusScope: FocusScopeController,
439
+ })
482
440
 
483
441
  function useEmitter<A>() {
484
442
  const listenersRef = React.useRef<Set<Function>>(new Set())
@@ -494,20 +452,30 @@ function useEmitter<A>() {
494
452
  return [emit, subscribe] as const
495
453
  }
496
454
 
497
- function SelectInner(props: SelectScopedProps<SelectProps> & { adaptScope: string }) {
455
+ function SelectInner(
456
+ props: SelectScopedProps<SelectProps<string, boolean>> & {
457
+ adaptScope: string
458
+ open: boolean
459
+ requestOpenChange: ControllableStateSetter<boolean, SelectOpenChangeDetails>
460
+ }
461
+ ) {
498
462
  const {
499
463
  scope = '',
500
464
  adaptScope,
501
465
  native,
502
466
  children,
503
- open: openProp,
504
- defaultOpen,
505
- onOpenChange,
467
+ open,
468
+ requestOpenChange,
469
+ defaultOpen: _defaultOpen,
470
+ onOpenChange: _onOpenChange,
506
471
  value: valueProp,
507
472
  defaultValue,
508
473
  onValueChange,
474
+ multiple = false,
475
+ name,
476
+ form,
509
477
  disablePreventBodyScroll,
510
- size: sizeProp = '$true',
478
+ size: sizeProp = true,
511
479
  onActiveChange,
512
480
  dir,
513
481
  id,
@@ -517,62 +485,94 @@ function SelectInner(props: SelectScopedProps<SelectProps> & { adaptScope: strin
517
485
  } = props
518
486
 
519
487
  const isAdapted = useAdaptIsActive(adaptScope)
520
- const SelectImpl = isAdapted || !isWeb ? SelectSheetImpl : SelectInlineImpl
521
- const forceUpdate = React.useReducer(() => ({}), {})[1]
522
- const [selectedItem, setSelectedItem] = React.useState<React.ReactNode>(null)
523
-
524
- const [open, setOpen] = useControllableState({
525
- prop: openProp,
526
- defaultProp: defaultOpen || false,
527
- onChange: onOpenChange,
528
- })
529
-
530
- const [value, setValue] = useControllableState({
531
- prop: valueProp,
532
- defaultProp: defaultValue || '',
533
- onChange: onValueChange,
488
+ const SelectImpl = isAdapted ? SelectSheetImpl : SelectInlineImpl
489
+ const mode: SelectMode = multiple ? 'multiple' : 'single'
490
+
491
+ const [valueState, requestValueChange] = useControllableState<
492
+ SelectSelection,
493
+ SelectValueChangeDetails
494
+ >({
495
+ prop: valueProp as SelectSelection | undefined,
496
+ defaultProp: (defaultValue ?? (mode === 'multiple' ? [] : '')) as SelectSelection,
497
+ onChange: onValueChange as any,
534
498
  transition: true,
535
499
  })
536
500
 
537
- React.useEffect(() => {
538
- if (open) {
539
- emitValue(value)
540
- }
541
- }, [open])
501
+ const value = React.useMemo(
502
+ () =>
503
+ mode === 'multiple'
504
+ ? selectedValuesFromSelection(mode, valueState)
505
+ : typeof valueState === 'string'
506
+ ? valueState
507
+ : '',
508
+ [mode, valueState]
509
+ )
510
+ const selectedValues = React.useMemo(
511
+ () => selectedValuesFromSelection(mode, value),
512
+ [mode, value]
513
+ )
542
514
 
543
- React.useEffect(() => {
544
- emitValue(value)
545
- }, [value])
546
-
547
- if (process.env.TAMAGUI_TARGET === 'native') {
548
- React.useEffect(() => {
549
- if (!props.id) return
550
-
551
- return registerFocusable(props.id, {
552
- focusAndSelect: () => {
553
- setOpen?.((value) => !value)
554
- },
555
- focus: () => {},
556
- })
557
- }, [props.id])
515
+ const [, rerenderRegistry] = React.useReducer((version) => version + 1, 0)
516
+ const registryRef = React.useRef<ReturnType<typeof createSelectItemRegistry> | null>(
517
+ null
518
+ )
519
+ if (!registryRef.current) {
520
+ registryRef.current = createSelectItemRegistry(rerenderRegistry)
558
521
  }
522
+ const registry = registryRef.current
523
+
524
+ const controllerRef = React.useRef<ReturnType<
525
+ typeof createSelectSelectionController
526
+ > | null>(null)
527
+ if (!controllerRef.current) {
528
+ controllerRef.current = createSelectSelectionController({
529
+ mode,
530
+ value,
531
+ registry,
532
+ })
533
+ }
534
+ const controller = controllerRef.current
535
+ controller.setMode(mode)
536
+ controller.setValue(value)
559
537
 
560
- // activeIndex is stored in a ref to avoid re-renders on every hover
561
- // we have two setters:
562
- // - setActiveIndexFast: updates ref + emits to subscribers (no re-render) - use for hover/navigation
563
- // - setActiveIndex: updates ref + emits + triggers re-render - use when UI needs to update
564
- // initialize to null so floating-ui starts from selectedIndex on first open
538
+ React.useEffect(() => {
539
+ if (process.env.TAMAGUI_TARGET !== 'native' || !props.id) return
540
+
541
+ return registerFocusable(props.id, {
542
+ focusAndSelect: () => {
543
+ requestOpenChange(
544
+ (current) => !current,
545
+ createChangeEventDetails('trigger-press') as SelectOpenChangeDetails
546
+ )
547
+ },
548
+ focus: () => {},
549
+ })
550
+ }, [props.id, requestOpenChange])
551
+
552
+ // the active index is a ref plus an emitter, never React state: hover and
553
+ // arrow keys must not re-render the list. an item that has to react (focus,
554
+ // the roving tabindex) subscribes and updates only itself.
555
+ // null until first open so floating-ui starts from selectedIndex
565
556
  const activeIndexRef = React.useRef<number | null>(null)
566
- const [activeIndex, setActiveIndexState] = React.useState<number | null>(null)
567
557
 
568
- const [emitValue, valueSubscribe] = useEmitter<any>()
569
- const [emitActiveIndex, activeIndexSubscribe] = useEmitter<number>()
558
+ const [emitActiveIndex, activeIndexSubscribe] = useEmitter<number | null>()
570
559
 
571
560
  const selectedIndexRef = React.useRef<number | null>(null)
561
+ // a focus-driven scroll makes the browser re-dispatch mousemove under a
562
+ // pointer that never moved; items compare against this to ignore it
563
+ const lastPointerRef = React.useRef({ x: -1, y: -1 })
572
564
  const listContentRef = React.useRef<string[]>([])
573
- const [selectedIndex, setSelectedIndex] = React.useState(0)
565
+ const listRef = React.useRef<Array<HTMLElement | null>>([])
574
566
  const [valueNode, setValueNode] = React.useState<HTMLElement | null>(null)
575
-
567
+ const selectedIndex = Math.max(0, controller.selectionAnchorIndex())
568
+ listContentRef.current = registry.getTypeaheadLabels()
569
+ // the registry re-renders this on every registration and node change, so
570
+ // the list ref (floating-ui's focus target array) mirrors it here
571
+ const itemNodes = registry.getNodes()
572
+ listRef.current.length = itemNodes.length
573
+ for (let i = 0; i < itemNodes.length; i++) listRef.current[i] = itemNodes[i]
574
+
575
+ // Intentionally dependency-less: selectedIndexRef mirrors every render for non-reactive reads.
576
576
  useIsomorphicLayoutEffect(() => {
577
577
  selectedIndexRef.current = selectedIndex
578
578
  })
@@ -583,28 +583,123 @@ function SelectInner(props: SelectScopedProps<SelectProps> & { adaptScope: strin
583
583
  native === 'web' ||
584
584
  (Array.isArray(native) && native.includes('web')))
585
585
 
586
- // fast setter: updates ref + emits to subscribers without causing re-renders
587
- // use this for mouse hover / keyboard navigation where we don't need parent re-renders
588
- const setActiveIndexFast = React.useCallback(
589
- (index: number | null) => {
590
- if (activeIndexRef.current !== index) {
591
- activeIndexRef.current = index
592
- if (typeof index === 'number') {
593
- emitActiveIndex(index)
586
+ const setActiveIndex = React.useCallback(
587
+ (index: number | null, details?: SelectActiveChangeDetails) => {
588
+ if (activeIndexRef.current === index) return
589
+ activeIndexRef.current = index
590
+ controller.setActiveIndex(index)
591
+ emitActiveIndex(index)
592
+ if (typeof index === 'number') {
593
+ const item = registry.getItems()[index]
594
+ if (item && details) {
595
+ onActiveChange?.(item.value, details)
594
596
  }
595
597
  }
596
598
  },
597
- [emitActiveIndex]
599
+ [controller, emitActiveIndex, onActiveChange, registry]
598
600
  )
599
601
 
600
- // slow setter: also triggers a re-render for components that need the state value
601
- // use this sparingly, e.g., when controlled scrolling needs to scroll item into view
602
- const setActiveIndex = React.useCallback(
603
- (index: number | null) => {
604
- setActiveIndexFast(index)
605
- setActiveIndexState(index)
602
+ const activeDetails = React.useCallback(
603
+ (
604
+ reason: SelectActiveChangeDetails['reason'],
605
+ index: number,
606
+ event?: unknown
607
+ ): SelectActiveChangeDetails => ({
608
+ reason,
609
+ event,
610
+ trigger: undefined,
611
+ index,
612
+ }),
613
+ []
614
+ )
615
+
616
+ const moveActive = React.useCallback(
617
+ (direction: 1 | -1, event?: unknown) => {
618
+ const nextIndex = registry.nextEnabledIndex(activeIndexRef.current, direction)
619
+ if (nextIndex >= 0) {
620
+ setActiveIndex(nextIndex, activeDetails('keyboard', nextIndex, event))
621
+ }
622
+ },
623
+ [activeDetails, registry, setActiveIndex]
624
+ )
625
+
626
+ const typeaheadRef = React.useRef({ text: '', timeout: null as any })
627
+ const search = React.useCallback(
628
+ (text: string, event?: unknown) => {
629
+ clearTimeout(typeaheadRef.current.timeout)
630
+ typeaheadRef.current.text += text
631
+ let nextIndex = registry.findTypeaheadIndex(
632
+ typeaheadRef.current.text,
633
+ activeIndexRef.current
634
+ )
635
+ if (nextIndex < 0 && typeaheadRef.current.text.length > 1) {
636
+ typeaheadRef.current.text = text
637
+ nextIndex = registry.findTypeaheadIndex(text, activeIndexRef.current)
638
+ }
639
+ typeaheadRef.current.timeout = setTimeout(() => {
640
+ typeaheadRef.current.text = ''
641
+ }, 750)
642
+ if (nextIndex >= 0) {
643
+ setActiveIndex(nextIndex, activeDetails('keyboard', nextIndex, event))
644
+ }
645
+ },
646
+ [activeDetails, registry, setActiveIndex]
647
+ )
648
+
649
+ React.useEffect(() => {
650
+ return () => clearTimeout(typeaheadRef.current.timeout)
651
+ }, [])
652
+
653
+ React.useEffect(() => {
654
+ if (open) {
655
+ const nextIndex = controller.initialActiveIndex()
656
+ if (nextIndex >= 0) {
657
+ setActiveIndex(nextIndex, activeDetails('list-navigation', nextIndex))
658
+ }
659
+ } else {
660
+ setActiveIndex(null)
661
+ }
662
+ }, [open, selectedIndex, registry.getItems().length])
663
+
664
+ const selectValue = React.useCallback(
665
+ (nextItemValue: string, details: SelectValueChangeDetails) => {
666
+ const item = registry.getItem(nextItemValue)
667
+ if (!item || item.disabled) return
668
+ controller.setMode(mode)
669
+ controller.setValue(value)
670
+ const previousValue = controller.value
671
+ const nextValue = controller.toggle(nextItemValue)
672
+ requestValueChange(nextValue, details)
673
+ if (details.isCanceled) {
674
+ controller.setValue(previousValue)
675
+ return
676
+ }
677
+ if (controller.shouldCloseOnSelect) {
678
+ requestOpenChange(
679
+ false,
680
+ createChangeEventDetails(
681
+ details.reason === 'keyboard' ? 'keyboard' : 'item-press',
682
+ details.event,
683
+ details.trigger
684
+ ) as SelectOpenChangeDetails
685
+ )
686
+ }
606
687
  },
607
- [setActiveIndexFast]
688
+ [controller, mode, registry, requestOpenChange, requestValueChange, value]
689
+ )
690
+
691
+ const changeNativeValue = React.useCallback(
692
+ (nextValue: SelectSelection, event: unknown) => {
693
+ const details = createChangeEventDetails(
694
+ 'native-change',
695
+ event
696
+ ) as SelectValueChangeDetails
697
+ requestValueChange(nextValue, details)
698
+ if (!details.isCanceled) {
699
+ controller.setValue(nextValue)
700
+ }
701
+ },
702
+ [controller, requestValueChange]
608
703
  )
609
704
 
610
705
  const content = (
@@ -612,28 +707,25 @@ function SelectInner(props: SelectScopedProps<SelectProps> & { adaptScope: strin
612
707
  scopeName={scope}
613
708
  scope={scope}
614
709
  adaptScope={adaptScope}
615
- initialValue={React.useMemo(() => value, [open])}
710
+ mode={mode}
711
+ selectedValues={selectedValues}
712
+ registry={registry}
616
713
  size={sizeProp}
617
714
  activeIndexSubscribe={activeIndexSubscribe}
618
715
  activeIndexRef={activeIndexRef}
619
- valueSubscribe={valueSubscribe}
620
- setOpen={setOpen}
716
+ requestOpenChange={requestOpenChange}
621
717
  id={id}
622
- onChange={React.useCallback((val) => {
623
- setValue(val)
624
- emitValue(val)
625
- }, [])}
626
- onActiveChange={useEvent((value, index) => {
627
- onActiveChange?.(value, index)
628
- })}
629
- setSelectedIndex={setSelectedIndex}
630
- setValueAtIndex={React.useCallback((index, value) => {
631
- listContentRef.current[index] = value
632
- }, [])}
718
+ name={name}
719
+ form={form}
720
+ selectValue={selectValue}
721
+ changeNativeValue={changeNativeValue}
633
722
  shouldRenderWebNative={shouldRenderWebNative}
634
- setActiveIndexFast={setActiveIndexFast}
635
- selectedItem={selectedItem}
636
- setSelectedItem={setSelectedItem}
723
+ setActiveIndex={setActiveIndex}
724
+ selectedIndex={selectedIndex}
725
+ lastPointerRef={lastPointerRef}
726
+ listRef={listRef}
727
+ moveActive={moveActive}
728
+ search={search}
637
729
  >
638
730
  <SelectProvider
639
731
  scope={scope}
@@ -643,37 +735,45 @@ function SelectInner(props: SelectScopedProps<SelectProps> & { adaptScope: strin
643
735
  dir={dir}
644
736
  blockSelection={false}
645
737
  fallback={false}
646
- forceUpdate={forceUpdate}
647
738
  valueNode={valueNode}
648
739
  onValueNodeChange={setValueNode}
649
- activeIndex={activeIndex}
650
740
  activeIndexRef={activeIndexRef}
651
741
  selectedIndex={selectedIndex}
652
742
  setActiveIndex={setActiveIndex}
653
743
  value={value}
744
+ mode={mode}
745
+ selectedValues={selectedValues}
654
746
  open={open}
655
747
  native={native}
656
748
  renderValue={renderValue}
657
749
  lazyMount={lazyMount}
658
750
  >
659
- <SelectSheetController onOpenChange={setOpen} scope={scope}>
660
- {shouldRenderWebNative ? (
661
- children
662
- ) : (
663
- <SelectImpl
664
- activeIndexRef={activeIndexRef}
665
- listContentRef={listContentRef}
666
- selectedIndexRef={selectedIndexRef}
667
- setActiveIndexFast={setActiveIndexFast}
668
- {...props}
669
- open={open}
670
- value={value}
671
- >
672
- {children}
673
- </SelectImpl>
674
- )}
675
- </SelectSheetController>
751
+ {shouldRenderWebNative ? (
752
+ children
753
+ ) : (
754
+ <SelectImpl
755
+ activeIndexRef={activeIndexRef}
756
+ listContentRef={listContentRef}
757
+ selectedIndexRef={selectedIndexRef}
758
+ {...props}
759
+ open={open}
760
+ value={value}
761
+ >
762
+ {children}
763
+ </SelectImpl>
764
+ )}
676
765
  </SelectProvider>
766
+ {isWeb && !shouldRenderWebNative && name
767
+ ? (mode === 'multiple' ? selectedValues : [value as string]).map((inputValue) => (
768
+ <input
769
+ key={inputValue}
770
+ type="hidden"
771
+ name={name}
772
+ form={form}
773
+ value={inputValue}
774
+ />
775
+ ))
776
+ : null}
677
777
  </SelectItemParentProvider>
678
778
  )
679
779