@cdx-ui/primitives 0.3.2 → 0.3.4

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 (188) hide show
  1. package/lib/commonjs/index.js +12 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/insight/InsightRoot.js +159 -0
  4. package/lib/commonjs/insight/InsightRoot.js.map +1 -0
  5. package/lib/commonjs/insight/context.js +11 -0
  6. package/lib/commonjs/insight/context.js.map +1 -0
  7. package/lib/commonjs/insight/index.js +26 -0
  8. package/lib/commonjs/insight/index.js.map +1 -0
  9. package/lib/commonjs/insight/types.js +6 -0
  10. package/lib/commonjs/insight/types.js.map +1 -0
  11. package/lib/commonjs/list-item/ListItemRoot.js +2 -18
  12. package/lib/commonjs/list-item/ListItemRoot.js.map +1 -1
  13. package/lib/commonjs/select/SelectContent.js +89 -45
  14. package/lib/commonjs/select/SelectContent.js.map +1 -1
  15. package/lib/commonjs/select/SelectDismissButton.js +14 -0
  16. package/lib/commonjs/select/SelectDismissButton.js.map +1 -0
  17. package/lib/commonjs/select/SelectDismissButton.web.js +28 -0
  18. package/lib/commonjs/select/SelectDismissButton.web.js.map +1 -0
  19. package/lib/commonjs/select/SelectEmpty.js +53 -0
  20. package/lib/commonjs/select/SelectEmpty.js.map +1 -0
  21. package/lib/commonjs/select/SelectItem.js +30 -9
  22. package/lib/commonjs/select/SelectItem.js.map +1 -1
  23. package/lib/commonjs/select/SelectListbox.js +47 -0
  24. package/lib/commonjs/select/SelectListbox.js.map +1 -0
  25. package/lib/commonjs/select/SelectRoot.js +110 -21
  26. package/lib/commonjs/select/SelectRoot.js.map +1 -1
  27. package/lib/commonjs/select/SelectSearch.js +81 -0
  28. package/lib/commonjs/select/SelectSearch.js.map +1 -0
  29. package/lib/commonjs/select/SelectSearchStatus.js +14 -0
  30. package/lib/commonjs/select/SelectSearchStatus.js.map +1 -0
  31. package/lib/commonjs/select/SelectSearchStatus.web.js +24 -0
  32. package/lib/commonjs/select/SelectSearchStatus.web.js.map +1 -0
  33. package/lib/commonjs/select/SelectSheetContent.js +67 -0
  34. package/lib/commonjs/select/SelectSheetContent.js.map +1 -0
  35. package/lib/commonjs/select/context.js +5 -1
  36. package/lib/commonjs/select/context.js.map +1 -1
  37. package/lib/commonjs/select/filter.js +16 -0
  38. package/lib/commonjs/select/filter.js.map +1 -0
  39. package/lib/commonjs/select/index.js +34 -0
  40. package/lib/commonjs/select/index.js.map +1 -1
  41. package/lib/commonjs/select/popupRole.js +27 -0
  42. package/lib/commonjs/select/popupRole.js.map +1 -0
  43. package/lib/commonjs/select/useContentFocus.js +10 -21
  44. package/lib/commonjs/select/useContentFocus.js.map +1 -1
  45. package/lib/commonjs/select/useListboxNavigation.js +21 -8
  46. package/lib/commonjs/select/useListboxNavigation.js.map +1 -1
  47. package/lib/commonjs/select/useQueryHighlight.js +50 -0
  48. package/lib/commonjs/select/useQueryHighlight.js.map +1 -0
  49. package/lib/commonjs/select/useSelectTrigger.js +4 -1
  50. package/lib/commonjs/select/useSelectTrigger.js.map +1 -1
  51. package/lib/commonjs/select/useSelectTrigger.web.js +4 -1
  52. package/lib/commonjs/select/useSelectTrigger.web.js.map +1 -1
  53. package/lib/commonjs/utils/splitContainerProps.js +26 -0
  54. package/lib/commonjs/utils/splitContainerProps.js.map +1 -0
  55. package/lib/module/index.js +1 -0
  56. package/lib/module/index.js.map +1 -1
  57. package/lib/module/insight/InsightRoot.js +154 -0
  58. package/lib/module/insight/InsightRoot.js.map +1 -0
  59. package/lib/module/insight/context.js +5 -0
  60. package/lib/module/insight/context.js.map +1 -0
  61. package/lib/module/insight/index.js +5 -0
  62. package/lib/module/insight/index.js.map +1 -0
  63. package/lib/module/insight/types.js +4 -0
  64. package/lib/module/insight/types.js.map +1 -0
  65. package/lib/module/list-item/ListItemRoot.js +1 -17
  66. package/lib/module/list-item/ListItemRoot.js.map +1 -1
  67. package/lib/module/select/SelectContent.js +94 -48
  68. package/lib/module/select/SelectContent.js.map +1 -1
  69. package/lib/module/select/SelectDismissButton.js +10 -0
  70. package/lib/module/select/SelectDismissButton.js.map +1 -0
  71. package/lib/module/select/SelectDismissButton.web.js +24 -0
  72. package/lib/module/select/SelectDismissButton.web.js.map +1 -0
  73. package/lib/module/select/SelectEmpty.js +49 -0
  74. package/lib/module/select/SelectEmpty.js.map +1 -0
  75. package/lib/module/select/SelectItem.js +32 -11
  76. package/lib/module/select/SelectItem.js.map +1 -1
  77. package/lib/module/select/SelectListbox.js +43 -0
  78. package/lib/module/select/SelectListbox.js.map +1 -0
  79. package/lib/module/select/SelectRoot.js +111 -22
  80. package/lib/module/select/SelectRoot.js.map +1 -1
  81. package/lib/module/select/SelectSearch.js +77 -0
  82. package/lib/module/select/SelectSearch.js.map +1 -0
  83. package/lib/module/select/SelectSearchStatus.js +10 -0
  84. package/lib/module/select/SelectSearchStatus.js.map +1 -0
  85. package/lib/module/select/SelectSearchStatus.web.js +20 -0
  86. package/lib/module/select/SelectSearchStatus.web.js.map +1 -0
  87. package/lib/module/select/SelectSheetContent.js +63 -0
  88. package/lib/module/select/SelectSheetContent.js.map +1 -0
  89. package/lib/module/select/context.js +2 -0
  90. package/lib/module/select/context.js.map +1 -1
  91. package/lib/module/select/filter.js +11 -0
  92. package/lib/module/select/filter.js.map +1 -0
  93. package/lib/module/select/index.js +5 -1
  94. package/lib/module/select/index.js.map +1 -1
  95. package/lib/module/select/popupRole.js +23 -0
  96. package/lib/module/select/popupRole.js.map +1 -0
  97. package/lib/module/select/useContentFocus.js +11 -22
  98. package/lib/module/select/useContentFocus.js.map +1 -1
  99. package/lib/module/select/useListboxNavigation.js +21 -8
  100. package/lib/module/select/useListboxNavigation.js.map +1 -1
  101. package/lib/module/select/useQueryHighlight.js +46 -0
  102. package/lib/module/select/useQueryHighlight.js.map +1 -0
  103. package/lib/module/select/useSelectTrigger.js +4 -1
  104. package/lib/module/select/useSelectTrigger.js.map +1 -1
  105. package/lib/module/select/useSelectTrigger.web.js +4 -1
  106. package/lib/module/select/useSelectTrigger.web.js.map +1 -1
  107. package/lib/module/utils/splitContainerProps.js +22 -0
  108. package/lib/module/utils/splitContainerProps.js.map +1 -0
  109. package/lib/typescript/index.d.ts +1 -0
  110. package/lib/typescript/index.d.ts.map +1 -1
  111. package/lib/typescript/insight/InsightRoot.d.ts +6 -0
  112. package/lib/typescript/insight/InsightRoot.d.ts.map +1 -0
  113. package/lib/typescript/insight/context.d.ts +6 -0
  114. package/lib/typescript/insight/context.d.ts.map +1 -0
  115. package/lib/typescript/insight/index.d.ts +4 -0
  116. package/lib/typescript/insight/index.d.ts.map +1 -0
  117. package/lib/typescript/insight/types.d.ts +44 -0
  118. package/lib/typescript/insight/types.d.ts.map +1 -0
  119. package/lib/typescript/list-item/ListItemRoot.d.ts.map +1 -1
  120. package/lib/typescript/select/SelectContent.d.ts.map +1 -1
  121. package/lib/typescript/select/SelectDismissButton.d.ts +10 -0
  122. package/lib/typescript/select/SelectDismissButton.d.ts.map +1 -0
  123. package/lib/typescript/select/SelectDismissButton.web.d.ts +9 -0
  124. package/lib/typescript/select/SelectDismissButton.web.d.ts.map +1 -0
  125. package/lib/typescript/select/SelectEmpty.d.ts +12 -0
  126. package/lib/typescript/select/SelectEmpty.d.ts.map +1 -0
  127. package/lib/typescript/select/SelectItem.d.ts.map +1 -1
  128. package/lib/typescript/select/SelectListbox.d.ts +12 -0
  129. package/lib/typescript/select/SelectListbox.d.ts.map +1 -0
  130. package/lib/typescript/select/SelectRoot.d.ts +2 -2
  131. package/lib/typescript/select/SelectRoot.d.ts.map +1 -1
  132. package/lib/typescript/select/SelectSearch.d.ts +18 -0
  133. package/lib/typescript/select/SelectSearch.d.ts.map +1 -0
  134. package/lib/typescript/select/SelectSearchStatus.d.ts +9 -0
  135. package/lib/typescript/select/SelectSearchStatus.d.ts.map +1 -0
  136. package/lib/typescript/select/SelectSearchStatus.web.d.ts +8 -0
  137. package/lib/typescript/select/SelectSearchStatus.web.d.ts.map +1 -0
  138. package/lib/typescript/select/SelectSheetContent.d.ts +14 -0
  139. package/lib/typescript/select/SelectSheetContent.d.ts.map +1 -0
  140. package/lib/typescript/select/context.d.ts +9 -0
  141. package/lib/typescript/select/context.d.ts.map +1 -1
  142. package/lib/typescript/select/filter.d.ts +4 -0
  143. package/lib/typescript/select/filter.d.ts.map +1 -0
  144. package/lib/typescript/select/index.d.ts +6 -2
  145. package/lib/typescript/select/index.d.ts.map +1 -1
  146. package/lib/typescript/select/popupRole.d.ts +18 -0
  147. package/lib/typescript/select/popupRole.d.ts.map +1 -0
  148. package/lib/typescript/select/types.d.ts +129 -8
  149. package/lib/typescript/select/types.d.ts.map +1 -1
  150. package/lib/typescript/select/useContentFocus.d.ts +4 -7
  151. package/lib/typescript/select/useContentFocus.d.ts.map +1 -1
  152. package/lib/typescript/select/useListboxNavigation.d.ts +16 -3
  153. package/lib/typescript/select/useListboxNavigation.d.ts.map +1 -1
  154. package/lib/typescript/select/useQueryHighlight.d.ts +21 -0
  155. package/lib/typescript/select/useQueryHighlight.d.ts.map +1 -0
  156. package/lib/typescript/select/useSelectTrigger.d.ts.map +1 -1
  157. package/lib/typescript/select/useSelectTrigger.web.d.ts.map +1 -1
  158. package/lib/typescript/utils/splitContainerProps.d.ts +9 -0
  159. package/lib/typescript/utils/splitContainerProps.d.ts.map +1 -0
  160. package/package.json +3 -3
  161. package/src/index.ts +1 -0
  162. package/src/insight/InsightRoot.tsx +162 -0
  163. package/src/insight/context.tsx +5 -0
  164. package/src/insight/index.ts +11 -0
  165. package/src/insight/types.ts +51 -0
  166. package/src/list-item/ListItemRoot.tsx +1 -14
  167. package/src/select/SelectContent.tsx +89 -54
  168. package/src/select/SelectDismissButton.tsx +14 -0
  169. package/src/select/SelectDismissButton.web.tsx +26 -0
  170. package/src/select/SelectEmpty.tsx +61 -0
  171. package/src/select/SelectItem.tsx +42 -10
  172. package/src/select/SelectListbox.tsx +43 -0
  173. package/src/select/SelectRoot.tsx +184 -12
  174. package/src/select/SelectSearch.tsx +98 -0
  175. package/src/select/SelectSearchStatus.tsx +11 -0
  176. package/src/select/SelectSearchStatus.web.tsx +18 -0
  177. package/src/select/SelectSheetContent.tsx +54 -0
  178. package/src/select/context.tsx +9 -0
  179. package/src/select/filter.ts +12 -0
  180. package/src/select/index.ts +14 -1
  181. package/src/select/popupRole.ts +26 -0
  182. package/src/select/types.ts +142 -9
  183. package/src/select/useContentFocus.ts +12 -31
  184. package/src/select/useListboxNavigation.ts +41 -9
  185. package/src/select/useQueryHighlight.ts +61 -0
  186. package/src/select/useSelectTrigger.ts +5 -1
  187. package/src/select/useSelectTrigger.web.ts +5 -1
  188. package/src/utils/splitContainerProps.ts +16 -0
@@ -0,0 +1,162 @@
1
+ import React, { forwardRef, useMemo, type ComponentRef } from 'react';
2
+ import { Pressable, View, type PressableProps, type ViewProps } from 'react-native';
3
+ import { composeEventHandlers } from '@cdx-ui/utils';
4
+ import { useFocus, useFocusRing } from '@react-native-aria/focus';
5
+ import { useHover, usePress } from '@react-native-aria/interactions';
6
+ import { Slot } from '../slot';
7
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
8
+ import { splitContainerProps } from '../utils/splitContainerProps';
9
+ import { InsightProvider } from './context';
10
+ import type {
11
+ IInsightContextValue,
12
+ IInsightProps,
13
+ IInsightSummaryProps,
14
+ InsightLayout,
15
+ } from './types';
16
+
17
+ export type InsightRootProps = IInsightProps;
18
+
19
+ type InsightRootRuntimeProps = Omit<IInsightSummaryProps, 'layout'> & {
20
+ layout?: InsightLayout;
21
+ };
22
+
23
+ export const InsightRoot = forwardRef<ComponentRef<typeof View>, InsightRootProps>((props, ref) => {
24
+ const {
25
+ layout = 'summary',
26
+ asChild = false,
27
+ children,
28
+ onPress,
29
+ isDisabled = false,
30
+ accessory,
31
+ pressableClassName,
32
+ style,
33
+ role,
34
+ onPressIn: onPressInProp,
35
+ onPressOut: onPressOutProp,
36
+ onHoverIn: onHoverInProp,
37
+ onHoverOut: onHoverOutProp,
38
+ ...restProps
39
+ } = props as InsightRootRuntimeProps;
40
+
41
+ const isPressable = layout === 'summary' && !!onPress;
42
+ const asChildElement = asChild && React.isValidElement(children);
43
+ const isSplit = accessory != null && (isPressable || asChildElement);
44
+
45
+ const pressState = usePress({ isDisabled: !isPressable || isDisabled });
46
+ const isPressed = isPressable && pressState.isPressed;
47
+ const pressProps = pressState.pressProps as Pick<PressableProps, 'onPressIn' | 'onPressOut'>;
48
+ const { hoverProps, isHovered } = useHover();
49
+ const { isFocused, focusProps } = useFocus();
50
+ const { isFocusVisible, focusProps: focusRingProps } = useFocusRing() as {
51
+ isFocusVisible: boolean;
52
+ focusProps: typeof focusProps;
53
+ };
54
+
55
+ const contextValue = useMemo<IInsightContextValue>(
56
+ () => ({ layout, isPressed, isPressable }),
57
+ [layout, isPressed, isPressable],
58
+ );
59
+
60
+ const interactionAttributes = isPressable
61
+ ? dataAttributes({
62
+ active: isPressed,
63
+ hover: isHovered,
64
+ focus: isFocused,
65
+ focusVisible: isFocusVisible,
66
+ disabled: isDisabled,
67
+ })
68
+ : undefined;
69
+
70
+ const {
71
+ onFocus: onFocusProp,
72
+ onBlur: onBlurProp,
73
+ accessibilityState: accessibilityStateProp,
74
+ } = restProps as Pick<PressableProps, 'onFocus' | 'onBlur' | 'accessibilityState'>;
75
+
76
+ const pressRegionProps = (): Record<string, unknown> => ({
77
+ role: role ?? 'button',
78
+ ...(isDisabled && { accessibilityState: { ...accessibilityStateProp, disabled: true } }),
79
+ disabled: isDisabled,
80
+ onPress: isDisabled ? undefined : onPress,
81
+ onPressIn: composeEventHandlers(onPressInProp, pressProps.onPressIn),
82
+ onPressOut: composeEventHandlers(onPressOutProp, pressProps.onPressOut),
83
+ onHoverIn: composeEventHandlers(onHoverInProp, hoverProps.onHoverIn),
84
+ onHoverOut: composeEventHandlers(onHoverOutProp, hoverProps.onHoverOut),
85
+ onFocus: composeEventHandlers(
86
+ composeEventHandlers(onFocusProp, focusProps.onFocus),
87
+ focusRingProps.onFocus,
88
+ ),
89
+ onBlur: composeEventHandlers(
90
+ composeEventHandlers(onBlurProp, focusProps.onBlur),
91
+ focusRingProps.onBlur,
92
+ ),
93
+ });
94
+
95
+ if (isSplit) {
96
+ const { container, region } = splitContainerProps(restProps);
97
+ const regionProps: Record<string, unknown> = {
98
+ ...region,
99
+ ...(role != null && { role }),
100
+ className: pressableClassName,
101
+ ...(isPressable && { ...pressRegionProps(), ...interactionAttributes }),
102
+ };
103
+ const RegionComp = asChildElement ? Slot : isPressable ? Pressable : View;
104
+
105
+ return (
106
+ <InsightProvider value={contextValue}>
107
+ <View
108
+ {...(container as ViewProps)}
109
+ ref={isPressable ? undefined : ref}
110
+ style={style}
111
+ {...mergeDataAttributes(container, interactionAttributes)}
112
+ >
113
+ <RegionComp
114
+ {...(regionProps as PressableProps)}
115
+ ref={isPressable ? (ref as React.Ref<View>) : undefined}
116
+ >
117
+ {children}
118
+ </RegionComp>
119
+ {accessory}
120
+ </View>
121
+ </InsightProvider>
122
+ );
123
+ }
124
+
125
+ if (isPressable) {
126
+ const Comp = asChildElement ? Slot : Pressable;
127
+ return (
128
+ <InsightProvider value={contextValue}>
129
+ <Comp
130
+ {...(restProps as PressableProps)}
131
+ {...(pressRegionProps() as PressableProps)}
132
+ ref={ref as React.Ref<View>}
133
+ style={style}
134
+ {...mergeDataAttributes(restProps, interactionAttributes)}
135
+ >
136
+ {children}
137
+ </Comp>
138
+ </InsightProvider>
139
+ );
140
+ }
141
+
142
+ if (asChildElement) {
143
+ return (
144
+ <InsightProvider value={contextValue}>
145
+ <Slot {...(restProps as ViewProps)} ref={ref as React.Ref<View>} style={style} role={role}>
146
+ {children}
147
+ </Slot>
148
+ </InsightProvider>
149
+ );
150
+ }
151
+
152
+ return (
153
+ <InsightProvider value={contextValue}>
154
+ <View {...(restProps as ViewProps)} ref={ref} style={style} role={role}>
155
+ {children}
156
+ {accessory}
157
+ </View>
158
+ </InsightProvider>
159
+ );
160
+ });
161
+
162
+ InsightRoot.displayName = 'InsightRoot';
@@ -0,0 +1,5 @@
1
+ import { createContext } from '@cdx-ui/utils';
2
+ import type { IInsightContextValue } from './types';
3
+
4
+ export const [InsightProvider, useInsightContext] =
5
+ createContext<IInsightContextValue>('InsightContext');
@@ -0,0 +1,11 @@
1
+ export type {
2
+ IInsightContextValue,
3
+ IInsightDetailProps,
4
+ IInsightPressablePassthrough,
5
+ IInsightProps,
6
+ IInsightSummaryProps,
7
+ InsightLayout,
8
+ } from './types';
9
+
10
+ export { InsightProvider, useInsightContext } from './context';
11
+ export { InsightRoot, type InsightRootProps } from './InsightRoot';
@@ -0,0 +1,51 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { PressableProps, ViewProps } from 'react-native';
3
+
4
+ export type InsightLayout = 'summary' | 'detail';
5
+
6
+ export type IInsightPressablePassthrough = Partial<
7
+ Pick<PressableProps, 'onPressIn' | 'onPressOut' | 'onHoverIn' | 'onHoverOut'>
8
+ >;
9
+
10
+ interface IInsightBaseProps extends ViewProps {
11
+ /** Merge props onto a single child instead of rendering `View` or `Pressable`. */
12
+ asChild?: boolean;
13
+ /**
14
+ * Interactive control that must sit beside the press region, never inside it (the styled
15
+ * layer's close button). On a pressable summary the root renders a non-interactive container
16
+ * holding the press region and the accessory as siblings. The container receives
17
+ * `className`, `style`, and data attributes, and mirrors the region's interaction state as
18
+ * `data-*`; the region receives `ref` and every other prop, so `ref` always targets the
19
+ * focusable press region of a pressable root. A static root renders the accessory after
20
+ * `children` and keeps `ref` on its `View`.
21
+ */
22
+ accessory?: ReactNode;
23
+ /** Class names for the press region when `accessory` splits the root. */
24
+ pressableClassName?: string;
25
+ }
26
+
27
+ export interface IInsightSummaryProps extends IInsightBaseProps, IInsightPressablePassthrough {
28
+ /**
29
+ * Inline card that navigates elsewhere when pressed.
30
+ * @default 'summary'
31
+ */
32
+ layout?: 'summary';
33
+ /** When set, the root renders a press region with `role="button"`. */
34
+ onPress?: PressableProps['onPress'];
35
+ /** Suppresses `onPress` and marks the press region disabled. */
36
+ isDisabled?: boolean;
37
+ }
38
+
39
+ export interface IInsightDetailProps extends IInsightBaseProps {
40
+ /** Static container. It holds its own actions, so it is never pressable. */
41
+ layout: 'detail';
42
+ }
43
+
44
+ /** Discriminated union: `layout` narrows the press props to the summary layout. */
45
+ export type IInsightProps = IInsightSummaryProps | IInsightDetailProps;
46
+
47
+ export interface IInsightContextValue {
48
+ layout: InsightLayout;
49
+ isPressed: boolean;
50
+ isPressable: boolean;
51
+ }
@@ -6,6 +6,7 @@ import { useHover, usePress } from '@react-native-aria/interactions';
6
6
  import type { InteractionState } from '../types';
7
7
  import { Slot } from '../slot';
8
8
  import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
9
+ import { splitContainerProps } from '../utils/splitContainerProps';
9
10
  import { ListItemProvider } from './context';
10
11
  import type { IListItemPressablePassthrough, IListItemProps, ListItemCrossAlign } from './types';
11
12
 
@@ -17,20 +18,6 @@ function rowStyleForCrossAlign(crossAlign: ListItemCrossAlign) {
17
18
  };
18
19
  }
19
20
 
20
- /** Splits the props a split row's container keeps (`className`, data attributes) from the rest. */
21
- function splitContainerProps(props: Record<string, unknown>) {
22
- const container: Record<string, unknown> = {};
23
- const region: Record<string, unknown> = {};
24
- for (const key in props) {
25
- if (key === 'className' || key === 'dataSet' || key.startsWith('data-')) {
26
- container[key] = props[key];
27
- } else {
28
- region[key] = props[key];
29
- }
30
- }
31
- return { container, region };
32
- }
33
-
34
21
  export interface ListItemRootProps extends IListItemProps {}
35
22
 
36
23
  export const ListItemRoot = forwardRef<ComponentRef<typeof View>, ListItemRootProps>(
@@ -1,14 +1,29 @@
1
- import React, { forwardRef, useCallback, useRef, type ComponentRef } from 'react';
1
+ import React, { forwardRef, useCallback, useMemo, useRef, type ComponentRef } from 'react';
2
2
  import { Platform, View } from 'react-native';
3
3
  import { FadeIn, FadeOut } from 'react-native-reanimated';
4
4
  import { composeEventHandlers, mergeRefs } from '@cdx-ui/utils';
5
+ import { FocusScope } from '@react-native-aria/focus';
5
6
  import { OverlayContainer, useAnchoredOverlay, useDismissOverlay, useEscapeKey } from '../overlay';
6
7
  import { Slot } from '../slot';
7
8
  import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
8
- import { useSelectContext } from './context';
9
+ import { SelectContentProvider, useSelectContext } from './context';
10
+ import { getPopupRoleProps } from './popupRole';
11
+ import { SelectDismissButton } from './SelectDismissButton';
9
12
  import type { ISelectContentProps } from './types';
10
13
  import { useContentFocus } from './useContentFocus';
11
14
  import { useListboxNavigation } from './useListboxNavigation';
15
+ import { useQueryHighlight } from './useQueryHighlight';
16
+
17
+ /**
18
+ * Keeps DOM focus in the search input when the pointer goes down elsewhere in the popup, so
19
+ * typing continues after an option is clicked (virtual focus).
20
+ */
21
+ function keepSearchInputFocus(e: { target: unknown; preventDefault: () => void }) {
22
+ const target = e.target as { closest?: (selector: string) => unknown } | null;
23
+ if (target?.closest?.('input, textarea') == null) {
24
+ e.preventDefault();
25
+ }
26
+ }
12
27
 
13
28
  export interface SelectContentProps extends ISelectContentProps {
14
29
  asChild?: boolean;
@@ -27,6 +42,7 @@ export const SelectContent = forwardRef<ComponentRef<typeof View>, SelectContent
27
42
  placement = 'bottom-start',
28
43
  offset = 4,
29
44
  crossOffset = 0,
45
+ dismissAccessibilityLabel = 'Dismiss',
30
46
  ...props
31
47
  },
32
48
  ref,
@@ -38,25 +54,22 @@ export const SelectContent = forwardRef<ComponentRef<typeof View>, SelectContent
38
54
  triggerId,
39
55
  activeValue,
40
56
  setActiveValue,
41
- value,
42
- onValueChange,
57
+ type,
58
+ selectedValues,
59
+ toggleValue,
60
+ selectValueAndClose,
61
+ isItemDisabled,
43
62
  native,
44
63
  triggerRef,
45
- items,
64
+ inputValue,
65
+ visibleItems,
66
+ hasSearch,
67
+ labelId,
68
+ accessibilityLabel,
46
69
  } = useSelectContext();
47
70
 
48
71
  const contentRef = useRef<HTMLElement | null>(null);
49
-
50
- const focusOnMount = useCallback((node: unknown) => {
51
- contentRef.current = node as HTMLElement | null;
52
- if (node && Platform.OS === 'web') {
53
- requestAnimationFrame(() => {
54
- setTimeout(() => {
55
- (node as HTMLElement).focus?.();
56
- }, 100);
57
- });
58
- }
59
- }, []);
72
+ const dismissButtonRef = useRef<HTMLButtonElement | null>(null);
60
73
 
61
74
  const dismiss = useCallback(() => setOpen(false), [setOpen]);
62
75
 
@@ -73,35 +86,42 @@ export const SelectContent = forwardRef<ComponentRef<typeof View>, SelectContent
73
86
  collisionBehavior: 'flip-shift',
74
87
  width: 'anchor',
75
88
  });
76
- const mergedRef = mergeRefs(ref, focusOnMount as unknown as React.Ref<View>, floatingRef);
89
+ const mergedRef = mergeRefs(ref, contentRef as unknown as React.Ref<View>, floatingRef);
77
90
 
78
- useDismissOverlay(overlayOpen, contentRef, [triggerRef], dismiss);
91
+ useDismissOverlay(overlayOpen, contentRef, [triggerRef, dismissButtonRef], dismiss);
79
92
  useEscapeKey(overlayOpen, dismiss);
80
93
 
81
94
  const { handleKeyDown } = useListboxNavigation({
82
- items,
95
+ items: visibleItems,
83
96
  activeValue,
84
97
  setActiveValue,
85
- onSelect: (val) => {
86
- onValueChange(val);
87
- setOpen(false);
88
- },
98
+ type,
99
+ onToggle: toggleValue,
100
+ onSelectAndClose: selectValueAndClose,
101
+ isItemDisabled,
89
102
  onDismiss: dismiss,
103
+ isSearch: hasSearch,
90
104
  });
91
105
 
92
- useContentFocus({
106
+ useContentFocus({ open, triggerRef, setActiveValue });
107
+
108
+ useQueryHighlight({
93
109
  open,
94
- triggerRef,
95
- items,
96
- value,
110
+ inputValue,
111
+ visibleItems,
112
+ selectedValues,
113
+ isItemDisabled,
114
+ activeValue,
97
115
  setActiveValue,
98
116
  });
99
117
 
118
+ const contentContext = useMemo(() => ({ handleKeyDown }), [handleKeyDown]);
119
+
100
120
  if (!overlayOpen) {
101
121
  return null;
102
122
  }
103
123
 
104
- const webProps =
124
+ const platformProps =
105
125
  Platform.OS === 'web'
106
126
  ? {
107
127
  onKeyDown: composeEventHandlers(
@@ -109,38 +129,53 @@ export const SelectContent = forwardRef<ComponentRef<typeof View>, SelectContent
109
129
  handleKeyDown,
110
130
  ),
111
131
  tabIndex: -1,
132
+ 'aria-multiselectable': (!hasSearch && type === 'multiple') || undefined,
133
+ ...(hasSearch ? { onMouseDown: keepSearchInputFocus } : {}),
112
134
  }
113
- : {};
135
+ : { onAccessibilityEscape: composeEventHandlers(props.onAccessibilityEscape, dismiss) };
136
+
137
+ const roleProps = getPopupRoleProps({ hasSearch, labelId, accessibilityLabel, triggerId });
114
138
 
115
139
  const Comp = asChild ? Slot : View;
116
140
 
117
141
  return (
118
- <OverlayContainer
119
- onDismiss={dismiss}
120
- entering={enteringAnimation ?? FadeIn.duration(150)}
121
- exiting={exitingAnimation ?? FadeOut.duration(100)}
122
- >
123
- <Comp
124
- ref={mergedRef}
125
- onLayout={composeEventHandlers(onLayout, onFloatingLayout)}
126
- style={[style, overlayStyle]}
127
- role="listbox"
128
- id={contentId}
129
- aria-labelledby={triggerId}
130
- {...props}
131
- {...mergeDataAttributes(
132
- props,
133
- dataAttributes({
134
- state: 'open',
135
- side: position?.resolvedPlacement.side,
136
- align: position?.resolvedPlacement.align,
137
- }),
138
- )}
139
- {...webProps}
142
+ <SelectContentProvider value={contentContext}>
143
+ <OverlayContainer
144
+ onDismiss={dismiss}
145
+ entering={enteringAnimation ?? FadeIn.duration(150)}
146
+ exiting={exitingAnimation ?? FadeOut.duration(100)}
140
147
  >
141
- {children}
142
- </Comp>
143
- </OverlayContainer>
148
+ {/* With search, autofocus lands on the input; otherwise options are not tabbable, so it
149
+ falls back to the focusable popup. Nested in the React tree, the scope also lets focus
150
+ enter this body-portalled popup from inside a `Drawer`'s contained scope. */}
151
+ <FocusScope autoFocus>
152
+ <Comp
153
+ ref={mergedRef}
154
+ onLayout={composeEventHandlers(onLayout, onFloatingLayout)}
155
+ style={[style, overlayStyle]}
156
+ id={contentId}
157
+ {...roleProps}
158
+ {...props}
159
+ {...mergeDataAttributes(
160
+ props,
161
+ dataAttributes({
162
+ state: 'open',
163
+ side: position?.resolvedPlacement.side,
164
+ align: position?.resolvedPlacement.align,
165
+ }),
166
+ )}
167
+ {...platformProps}
168
+ >
169
+ {children}
170
+ </Comp>
171
+ <SelectDismissButton
172
+ ref={dismissButtonRef}
173
+ onDismiss={dismiss}
174
+ accessibilityLabel={dismissAccessibilityLabel}
175
+ />
176
+ </FocusScope>
177
+ </OverlayContainer>
178
+ </SelectContentProvider>
144
179
  );
145
180
  },
146
181
  );
@@ -0,0 +1,14 @@
1
+ import { forwardRef } from 'react';
2
+
3
+ export interface SelectDismissButtonProps {
4
+ onDismiss: () => void;
5
+ accessibilityLabel: string;
6
+ }
7
+
8
+ /**
9
+ * Web-only. Native screen readers close the popup with the iOS escape gesture
10
+ * (`onAccessibilityEscape` on `SelectContent`) and Android back (`onRequestClose`).
11
+ */
12
+ export const SelectDismissButton = forwardRef<unknown, SelectDismissButtonProps>(() => null);
13
+
14
+ SelectDismissButton.displayName = 'SelectDismissButton';
@@ -0,0 +1,26 @@
1
+ import { forwardRef } from 'react';
2
+ import { VisuallyHidden } from '@react-aria/visually-hidden';
3
+ import type { SelectDismissButtonProps } from './SelectDismissButton';
4
+
5
+ export type { SelectDismissButtonProps };
6
+
7
+ /**
8
+ * Visually hidden close control for screen-reader users, who cannot press Escape or click
9
+ * outside while browsing the popup. Out of the Tab order (Tab already closes the listbox).
10
+ * Rendered as a sibling of the listbox because a listbox may only own options.
11
+ */
12
+ export const SelectDismissButton = forwardRef<HTMLButtonElement, SelectDismissButtonProps>(
13
+ ({ onDismiss, accessibilityLabel }, ref) => (
14
+ <VisuallyHidden>
15
+ <button
16
+ ref={ref}
17
+ type="button"
18
+ tabIndex={-1}
19
+ aria-label={accessibilityLabel}
20
+ onClick={onDismiss}
21
+ />
22
+ </VisuallyHidden>
23
+ ),
24
+ );
25
+
26
+ SelectDismissButton.displayName = 'SelectDismissButton';
@@ -0,0 +1,61 @@
1
+ import {
2
+ Children,
3
+ forwardRef,
4
+ isValidElement,
5
+ useLayoutEffect,
6
+ type ComponentRef,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { View } from 'react-native';
10
+ import { Slot } from '../slot';
11
+ import { useSelectContext } from './context';
12
+ import type { ISelectEmptyProps } from './types';
13
+
14
+ export interface SelectEmptyProps extends ISelectEmptyProps {
15
+ asChild?: boolean;
16
+ className?: string;
17
+ }
18
+
19
+ function getTextContent(node: ReactNode): string {
20
+ return Children.toArray(node)
21
+ .map((child) => {
22
+ if (typeof child === 'string' || typeof child === 'number') {
23
+ return String(child);
24
+ }
25
+ if (isValidElement<{ children?: ReactNode }>(child)) {
26
+ return getTextContent(child.props.children);
27
+ }
28
+ return '';
29
+ })
30
+ .join('');
31
+ }
32
+
33
+ /**
34
+ * Renders only when no option matches. Its text is also what the search live region
35
+ * announces in that case.
36
+ */
37
+ export const SelectEmpty = forwardRef<ComponentRef<typeof View>, SelectEmptyProps>(
38
+ ({ asChild, children, ...props }, ref) => {
39
+ const { visibleItems, setEmptyText } = useSelectContext();
40
+ const text = getTextContent(children);
41
+
42
+ useLayoutEffect(() => {
43
+ setEmptyText(text || undefined);
44
+ return () => setEmptyText(undefined);
45
+ }, [setEmptyText, text]);
46
+
47
+ if (visibleItems.length > 0) {
48
+ return null;
49
+ }
50
+
51
+ const Comp = asChild ? Slot : View;
52
+
53
+ return (
54
+ <Comp ref={ref} {...props}>
55
+ {children}
56
+ </Comp>
57
+ );
58
+ },
59
+ );
60
+
61
+ SelectEmpty.displayName = 'SelectEmpty';
@@ -1,5 +1,12 @@
1
- import { forwardRef, useCallback, useMemo, type ComponentRef, type ReactNode } from 'react';
2
- import { Pressable } from 'react-native';
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ type ComponentRef,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { Platform, Pressable } from 'react-native';
3
10
  import { composeEventHandlers } from '@cdx-ui/utils';
4
11
  import { useFocus, useFocusRing } from '@react-native-aria/focus';
5
12
  import { useHover, usePress } from '@react-native-aria/interactions';
@@ -13,6 +20,12 @@ export interface SelectItemProps extends Omit<ISelectItemProps, 'children'> {
13
20
  children?: ReactNode;
14
21
  }
15
22
 
23
+ /**
24
+ * Options are reached through the highlight (`aria-activedescendant`), not the Tab order, so
25
+ * popup autofocus lands on the search input or the listbox rather than the first option.
26
+ */
27
+ const WEB_OPTION_PROPS = Platform.OS === 'web' ? { tabIndex: -1 as const } : {};
28
+
16
29
  export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemProps>(
17
30
  (
18
31
  {
@@ -30,18 +43,32 @@ export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemP
30
43
  ref,
31
44
  ) => {
32
45
  const {
33
- value: selectedValue,
34
- onValueChange,
35
- setOpen,
46
+ isSelected,
47
+ commitValue,
36
48
  activeValue,
49
+ setActiveValue,
37
50
  contentId,
51
+ items,
38
52
  disabled: contextDisabled,
53
+ registerDisabledItem,
54
+ isItemVisible,
39
55
  } = useSelectContext();
40
56
 
41
- const disabled = contextDisabled || !!disabledProp || !!isDisabledProp;
42
- const checked = selectedValue === itemValue;
57
+ const isDisabledByItem = !!disabledProp || !!isDisabledProp;
58
+ const disabled =
59
+ contextDisabled ||
60
+ isDisabledByItem ||
61
+ items.some((item) => item.value === itemValue && !!item.disabled);
62
+ const checked = isSelected(itemValue);
43
63
  const highlighted = activeValue === itemValue;
44
64
 
65
+ useLayoutEffect(() => {
66
+ if (isDisabledByItem) {
67
+ return registerDisabledItem(itemValue);
68
+ }
69
+ return undefined;
70
+ }, [isDisabledByItem, itemValue, registerDisabledItem]);
71
+
45
72
  const { isFocusVisible, focusProps: focusRingProps } = useFocusRing() as {
46
73
  isFocusVisible: boolean;
47
74
  focusProps: {
@@ -55,10 +82,10 @@ export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemP
55
82
 
56
83
  const handlePress = useCallback(() => {
57
84
  if (!disabled) {
58
- onValueChange(itemValue);
59
- setOpen(false);
85
+ setActiveValue(itemValue);
86
+ commitValue(itemValue);
60
87
  }
61
- }, [disabled, itemValue, onValueChange, setOpen]);
88
+ }, [disabled, itemValue, commitValue, setActiveValue]);
62
89
 
63
90
  const interactionState = useMemo(
64
91
  () => ({
@@ -85,6 +112,10 @@ export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemP
85
112
  ],
86
113
  );
87
114
 
115
+ if (!isItemVisible(itemValue)) {
116
+ return null;
117
+ }
118
+
88
119
  const Comp = asChild ? Slot : Pressable;
89
120
 
90
121
  return (
@@ -96,6 +127,7 @@ export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemP
96
127
  aria-selected={checked}
97
128
  aria-disabled={disabled}
98
129
  disabled={disabled}
130
+ {...WEB_OPTION_PROPS}
99
131
  {...props}
100
132
  {...mergeDataAttributes(
101
133
  props,