@olympusoss/canvas 3.2.1 → 5.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 (302) hide show
  1. package/README.md +75 -65
  2. package/package.json +11 -5
  3. package/src/atoms/avatar/avatar.md +185 -0
  4. package/src/atoms/avatar/avatar.styles.ts +48 -0
  5. package/src/atoms/avatar/avatar.tsx +99 -0
  6. package/src/atoms/badge/badge.md +237 -0
  7. package/src/atoms/badge/badge.styles.ts +79 -0
  8. package/src/atoms/badge/badge.tsx +86 -0
  9. package/src/atoms/breadcrumb/breadcrumb.md +233 -0
  10. package/src/atoms/breadcrumb/breadcrumb.styles.ts +40 -0
  11. package/src/atoms/breadcrumb/breadcrumb.tsx +130 -0
  12. package/src/atoms/button/button.android.tsx +6 -0
  13. package/src/atoms/button/button.ios.tsx +6 -0
  14. package/src/atoms/button/button.md +184 -0
  15. package/src/atoms/button/button.shared.tsx +79 -0
  16. package/src/atoms/button/button.styles.ts +152 -0
  17. package/src/atoms/button/button.tsx +6 -0
  18. package/src/atoms/button-group/button-group.android.tsx +6 -0
  19. package/src/atoms/button-group/button-group.ios.tsx +6 -0
  20. package/src/atoms/button-group/button-group.md +120 -0
  21. package/src/atoms/button-group/button-group.shared.tsx +398 -0
  22. package/src/atoms/button-group/button-group.styles.ts +483 -0
  23. package/src/atoms/button-group/button-group.tsx +6 -0
  24. package/src/atoms/checkbox/checkbox.android.tsx +6 -0
  25. package/src/atoms/checkbox/checkbox.ios.tsx +6 -0
  26. package/src/atoms/checkbox/checkbox.md +150 -0
  27. package/src/atoms/checkbox/checkbox.shared.tsx +103 -0
  28. package/src/atoms/checkbox/checkbox.styles.ts +106 -0
  29. package/src/atoms/checkbox/checkbox.tsx +6 -0
  30. package/src/atoms/combobox/combobox.android.tsx +6 -0
  31. package/src/atoms/combobox/combobox.ios.tsx +6 -0
  32. package/src/atoms/combobox/combobox.md +213 -0
  33. package/src/atoms/combobox/combobox.shared.tsx +160 -0
  34. package/src/atoms/combobox/combobox.styles.ts +270 -0
  35. package/src/atoms/combobox/combobox.tsx +6 -0
  36. package/src/atoms/divider/divider.md +140 -0
  37. package/src/atoms/divider/divider.styles.ts +35 -0
  38. package/src/atoms/divider/divider.tsx +67 -0
  39. package/src/atoms/dropdown/dropdown.android.tsx +6 -0
  40. package/src/atoms/dropdown/dropdown.ios.tsx +6 -0
  41. package/src/atoms/dropdown/dropdown.md +221 -0
  42. package/src/atoms/dropdown/dropdown.shared.tsx +190 -0
  43. package/src/atoms/dropdown/dropdown.styles.ts +233 -0
  44. package/src/atoms/dropdown/dropdown.tsx +6 -0
  45. package/src/atoms/icon/icon.md +131 -0
  46. package/src/atoms/icon/icon.styles.ts +30 -0
  47. package/src/atoms/icon/icon.tsx +328 -0
  48. package/src/atoms/index.ts +24 -0
  49. package/src/atoms/input/input.android.tsx +6 -0
  50. package/src/atoms/input/input.ios.tsx +6 -0
  51. package/src/atoms/input/input.md +118 -0
  52. package/src/atoms/input/input.shared.tsx +203 -0
  53. package/src/atoms/input/input.styles.ts +286 -0
  54. package/src/atoms/input/input.tsx +6 -0
  55. package/src/atoms/kbd/kbd.md +91 -0
  56. package/src/atoms/kbd/kbd.styles.ts +33 -0
  57. package/src/atoms/kbd/kbd.tsx +27 -0
  58. package/src/atoms/listbox/listbox.md +177 -0
  59. package/src/atoms/listbox/listbox.styles.ts +60 -0
  60. package/src/atoms/listbox/listbox.tsx +113 -0
  61. package/src/atoms/pagination/pagination.android.tsx +6 -0
  62. package/src/atoms/pagination/pagination.ios.tsx +6 -0
  63. package/src/atoms/pagination/pagination.md +133 -0
  64. package/src/atoms/pagination/pagination.shared.tsx +289 -0
  65. package/src/atoms/pagination/pagination.styles.ts +245 -0
  66. package/src/atoms/pagination/pagination.tsx +6 -0
  67. package/src/atoms/popover/popover.android.tsx +8 -0
  68. package/src/atoms/popover/popover.ios.tsx +6 -0
  69. package/src/atoms/popover/popover.md +87 -0
  70. package/src/atoms/popover/popover.shared.tsx +124 -0
  71. package/src/atoms/popover/popover.styles.ts +144 -0
  72. package/src/atoms/popover/popover.tsx +6 -0
  73. package/src/atoms/radio/radio.android.tsx +6 -0
  74. package/src/atoms/radio/radio.ios.tsx +6 -0
  75. package/src/atoms/radio/radio.md +173 -0
  76. package/src/atoms/radio/radio.shared.tsx +98 -0
  77. package/src/atoms/radio/radio.styles.ts +109 -0
  78. package/src/atoms/radio/radio.tsx +6 -0
  79. package/src/atoms/select/select.android.tsx +6 -0
  80. package/src/atoms/select/select.ios.tsx +6 -0
  81. package/src/atoms/select/select.md +156 -0
  82. package/src/atoms/select/select.shared.tsx +143 -0
  83. package/src/atoms/select/select.styles.ts +310 -0
  84. package/src/atoms/select/select.tsx +6 -0
  85. package/src/atoms/skeleton/skeleton.md +135 -0
  86. package/src/atoms/skeleton/skeleton.styles.ts +117 -0
  87. package/src/atoms/skeleton/skeleton.tsx +145 -0
  88. package/src/atoms/spinner/spinner.android.tsx +7 -0
  89. package/src/atoms/spinner/spinner.ios.tsx +7 -0
  90. package/src/atoms/spinner/spinner.md +94 -0
  91. package/src/atoms/spinner/spinner.shared.tsx +92 -0
  92. package/src/atoms/spinner/spinner.styles.tsx +115 -0
  93. package/src/atoms/spinner/spinner.tsx +7 -0
  94. package/src/atoms/switch/switch.android.tsx +6 -0
  95. package/src/atoms/switch/switch.ios.tsx +6 -0
  96. package/src/atoms/switch/switch.md +91 -0
  97. package/src/atoms/switch/switch.shared.tsx +97 -0
  98. package/src/atoms/switch/switch.styles.ts +79 -0
  99. package/src/atoms/switch/switch.tsx +6 -0
  100. package/src/atoms/textarea/textarea.android.tsx +6 -0
  101. package/src/atoms/textarea/textarea.ios.tsx +6 -0
  102. package/src/atoms/textarea/textarea.md +140 -0
  103. package/src/atoms/textarea/textarea.shared.tsx +74 -0
  104. package/src/atoms/textarea/textarea.styles.ts +116 -0
  105. package/src/atoms/textarea/textarea.tsx +6 -0
  106. package/src/atoms/tooltip/tooltip.android.tsx +6 -0
  107. package/src/atoms/tooltip/tooltip.ios.tsx +7 -0
  108. package/src/atoms/tooltip/tooltip.md +122 -0
  109. package/src/atoms/tooltip/tooltip.shared.tsx +113 -0
  110. package/src/atoms/tooltip/tooltip.styles.ts +113 -0
  111. package/src/atoms/tooltip/tooltip.tsx +6 -0
  112. package/src/atoms/typography/typography.md +330 -0
  113. package/src/atoms/typography/typography.styles.ts +95 -0
  114. package/src/atoms/typography/typography.tsx +76 -0
  115. package/src/index.ts +12 -2
  116. package/src/molecules/action-panels/action-panels.md +133 -0
  117. package/src/molecules/action-panels/action-panels.styles.ts +39 -0
  118. package/src/molecules/action-panels/action-panels.tsx +113 -0
  119. package/src/molecules/alert/alert.md +119 -0
  120. package/src/molecules/alert/alert.styles.ts +88 -0
  121. package/src/molecules/alert/alert.tsx +74 -0
  122. package/src/molecules/alert-dialog/alert-dialog.android.tsx +6 -0
  123. package/src/molecules/alert-dialog/alert-dialog.ios.tsx +6 -0
  124. package/src/molecules/alert-dialog/alert-dialog.md +177 -0
  125. package/src/molecules/alert-dialog/alert-dialog.shared.tsx +187 -0
  126. package/src/molecules/alert-dialog/alert-dialog.styles.ts +248 -0
  127. package/src/molecules/alert-dialog/alert-dialog.tsx +6 -0
  128. package/src/molecules/card/card.md +190 -0
  129. package/src/molecules/card/card.styles.ts +67 -0
  130. package/src/molecules/card/card.tsx +176 -0
  131. package/src/molecules/code-block/code-block.md +159 -0
  132. package/src/molecules/code-block/code-block.styles.ts +167 -0
  133. package/src/molecules/code-block/code-block.tsx +176 -0
  134. package/src/molecules/description-lists/description-lists.md +129 -0
  135. package/src/molecules/description-lists/description-lists.styles.ts +102 -0
  136. package/src/molecules/description-lists/description-lists.tsx +133 -0
  137. package/src/molecules/empty-state/empty-state.md +218 -0
  138. package/src/molecules/empty-state/empty-state.styles.ts +63 -0
  139. package/src/molecules/empty-state/empty-state.tsx +77 -0
  140. package/src/molecules/feeds/feeds.md +102 -0
  141. package/src/molecules/feeds/feeds.styles.ts +120 -0
  142. package/src/molecules/feeds/feeds.tsx +167 -0
  143. package/src/molecules/field/field.md +117 -0
  144. package/src/molecules/field/field.styles.ts +85 -0
  145. package/src/molecules/field/field.tsx +175 -0
  146. package/src/molecules/fieldset/fieldset.md +141 -0
  147. package/src/molecules/fieldset/fieldset.styles.ts +79 -0
  148. package/src/molecules/fieldset/fieldset.tsx +182 -0
  149. package/src/molecules/form/form.md +137 -0
  150. package/src/molecules/form/form.styles.ts +39 -0
  151. package/src/molecules/form/form.tsx +246 -0
  152. package/src/molecules/grid-lists/grid-lists.md +114 -0
  153. package/src/molecules/grid-lists/grid-lists.styles.ts +79 -0
  154. package/src/molecules/grid-lists/grid-lists.tsx +157 -0
  155. package/src/molecules/index.ts +16 -0
  156. package/src/molecules/media-objects/media-objects.md +87 -0
  157. package/src/molecules/media-objects/media-objects.styles.ts +94 -0
  158. package/src/molecules/media-objects/media-objects.tsx +128 -0
  159. package/src/molecules/stacked-lists/stacked-lists.md +116 -0
  160. package/src/molecules/stacked-lists/stacked-lists.styles.ts +111 -0
  161. package/src/molecules/stacked-lists/stacked-lists.tsx +195 -0
  162. package/src/molecules/stats/stats.md +166 -0
  163. package/src/molecules/stats/stats.styles.ts +91 -0
  164. package/src/molecules/stats/stats.tsx +88 -0
  165. package/src/organisms/calendar/calendar.android.tsx +6 -0
  166. package/src/organisms/calendar/calendar.ios.tsx +6 -0
  167. package/src/organisms/calendar/calendar.md +114 -0
  168. package/src/organisms/calendar/calendar.shared.tsx +146 -0
  169. package/src/organisms/calendar/calendar.styles.ts +315 -0
  170. package/src/organisms/calendar/calendar.tsx +6 -0
  171. package/src/organisms/charts/charts.md +326 -0
  172. package/src/organisms/charts/charts.styles.ts +135 -0
  173. package/src/organisms/charts/charts.tsx +124 -0
  174. package/src/organisms/command/command.md +117 -0
  175. package/src/organisms/command/command.styles.ts +179 -0
  176. package/src/organisms/command/command.tsx +164 -0
  177. package/src/organisms/data-table/data-table.md +182 -0
  178. package/src/organisms/data-table/data-table.styles.ts +103 -0
  179. package/src/organisms/data-table/data-table.tsx +105 -0
  180. package/src/organisms/dialog/dialog.android.tsx +6 -0
  181. package/src/organisms/dialog/dialog.ios.tsx +6 -0
  182. package/src/organisms/dialog/dialog.md +271 -0
  183. package/src/organisms/dialog/dialog.shared.tsx +230 -0
  184. package/src/organisms/dialog/dialog.styles.ts +272 -0
  185. package/src/organisms/dialog/dialog.tsx +6 -0
  186. package/src/organisms/filter-panel/filter-panel.md +116 -0
  187. package/src/organisms/filter-panel/filter-panel.styles.ts +83 -0
  188. package/src/organisms/filter-panel/filter-panel.tsx +91 -0
  189. package/src/organisms/index.ts +13 -0
  190. package/src/organisms/navbars/navbars.android.tsx +6 -0
  191. package/src/organisms/navbars/navbars.ios.tsx +6 -0
  192. package/src/organisms/navbars/navbars.md +144 -0
  193. package/src/organisms/navbars/navbars.shared.tsx +137 -0
  194. package/src/organisms/navbars/navbars.styles.ts +251 -0
  195. package/src/organisms/navbars/navbars.tsx +6 -0
  196. package/src/organisms/overlays/overlays.android.tsx +6 -0
  197. package/src/organisms/overlays/overlays.ios.tsx +6 -0
  198. package/src/organisms/overlays/overlays.md +123 -0
  199. package/src/organisms/overlays/overlays.shared.tsx +175 -0
  200. package/src/organisms/overlays/overlays.styles.ts +309 -0
  201. package/src/organisms/overlays/overlays.tsx +6 -0
  202. package/src/organisms/row-menu/row-menu.android.tsx +6 -0
  203. package/src/organisms/row-menu/row-menu.ios.tsx +6 -0
  204. package/src/organisms/row-menu/row-menu.md +102 -0
  205. package/src/organisms/row-menu/row-menu.shared.tsx +105 -0
  206. package/src/organisms/row-menu/row-menu.styles.ts +262 -0
  207. package/src/organisms/row-menu/row-menu.tsx +6 -0
  208. package/src/organisms/sidebar/sidebar.android.tsx +6 -0
  209. package/src/organisms/sidebar/sidebar.ios.tsx +6 -0
  210. package/src/organisms/sidebar/sidebar.md +188 -0
  211. package/src/organisms/sidebar/sidebar.shared.tsx +167 -0
  212. package/src/organisms/sidebar/sidebar.styles.ts +262 -0
  213. package/src/organisms/sidebar/sidebar.tsx +6 -0
  214. package/src/organisms/stepper/stepper.android.tsx +6 -0
  215. package/src/organisms/stepper/stepper.ios.tsx +6 -0
  216. package/src/organisms/stepper/stepper.md +150 -0
  217. package/src/organisms/stepper/stepper.shared.tsx +158 -0
  218. package/src/organisms/stepper/stepper.styles.ts +280 -0
  219. package/src/organisms/stepper/stepper.tsx +6 -0
  220. package/src/organisms/tabs/tabs.android.tsx +6 -0
  221. package/src/organisms/tabs/tabs.ios.tsx +6 -0
  222. package/src/organisms/tabs/tabs.md +127 -0
  223. package/src/organisms/tabs/tabs.shared.tsx +281 -0
  224. package/src/organisms/tabs/tabs.styles.ts +398 -0
  225. package/src/organisms/tabs/tabs.tsx +6 -0
  226. package/src/style/color.ts +17 -0
  227. package/src/style/index.ts +14 -0
  228. package/src/style/primitives.ts +26 -0
  229. package/src/style/responsive.ts +45 -0
  230. package/src/style/shadow.ts +21 -0
  231. package/src/style/theme.tsx +56 -0
  232. package/src/style/tokens.ts +487 -0
  233. package/styles/canvas.css +127 -74
  234. package/tsconfig.json +4 -2
  235. package/src/cn.ts +0 -3
  236. package/styles/atoms/avatar.css +0 -22
  237. package/styles/atoms/badge.css +0 -83
  238. package/styles/atoms/breadcrumb.css +0 -35
  239. package/styles/atoms/button-group.css +0 -23
  240. package/styles/atoms/button.css +0 -107
  241. package/styles/atoms/checkbox.css +0 -55
  242. package/styles/atoms/combobox.css +0 -76
  243. package/styles/atoms/dropdown.css +0 -54
  244. package/styles/atoms/icon.css +0 -8
  245. package/styles/atoms/input-group.css +0 -45
  246. package/styles/atoms/input.css +0 -56
  247. package/styles/atoms/kbd.css +0 -15
  248. package/styles/atoms/pagination.css +0 -48
  249. package/styles/atoms/popover.css +0 -14
  250. package/styles/atoms/radio.css +0 -28
  251. package/styles/atoms/select.css +0 -57
  252. package/styles/atoms/separator.css +0 -32
  253. package/styles/atoms/skeleton.css +0 -32
  254. package/styles/atoms/spinner.css +0 -26
  255. package/styles/atoms/switch.css +0 -45
  256. package/styles/atoms/textarea.css +0 -31
  257. package/styles/atoms/tooltip.css +0 -53
  258. package/styles/atoms/typography.css +0 -105
  259. package/styles/base.css +0 -17
  260. package/styles/molecules/alert.css +0 -66
  261. package/styles/molecules/card.css +0 -58
  262. package/styles/molecules/code-block.css +0 -18
  263. package/styles/molecules/empty-state.css +0 -17
  264. package/styles/molecules/field.css +0 -27
  265. package/styles/molecules/form.css +0 -27
  266. package/styles/molecules/page-header.css +0 -52
  267. package/styles/molecules/section-card.css +0 -49
  268. package/styles/molecules/stat-card.css +0 -71
  269. package/styles/molecules/toast.css +0 -95
  270. package/styles/organisms/app-shell.css +0 -46
  271. package/styles/organisms/calendar.css +0 -73
  272. package/styles/organisms/command.css +0 -95
  273. package/styles/organisms/data-table.css +0 -142
  274. package/styles/organisms/dialog.css +0 -72
  275. package/styles/organisms/filter-panel.css +0 -58
  276. package/styles/organisms/row-menu.css +0 -69
  277. package/styles/organisms/sheet.css +0 -70
  278. package/styles/organisms/sidebar.css +0 -146
  279. package/styles/organisms/stepper.css +0 -63
  280. package/styles/organisms/tabs.css +0 -40
  281. package/styles/organisms/topbar.css +0 -24
  282. package/styles/patterns/backdrops.css +0 -35
  283. package/styles/patterns/density.css +0 -66
  284. package/styles/patterns/focus.css +0 -22
  285. package/styles/patterns/glass.css +0 -85
  286. package/styles/patterns/high-contrast.css +0 -70
  287. package/styles/patterns/reduced-motion.css +0 -12
  288. package/styles/patterns/scrollbar.css +0 -10
  289. package/styles/reset.css +0 -89
  290. package/styles/tokens/colors.css +0 -108
  291. package/styles/tokens/motion.css +0 -33
  292. package/styles/tokens/radius.css +0 -10
  293. package/styles/tokens/shadows.css +0 -35
  294. package/styles/tokens/spacing.css +0 -19
  295. package/styles/tokens/typography.css +0 -6
  296. package/styles/tokens/z-index.css +0 -12
  297. package/styles/utilities/display.css +0 -66
  298. package/styles/utilities/flexbox.css +0 -240
  299. package/styles/utilities/gap.css +0 -288
  300. package/styles/utilities/grid.css +0 -138
  301. package/styles/utilities/position.css +0 -78
  302. package/styles/utilities/sizing.css +0 -138
@@ -0,0 +1,103 @@
1
+ import { type ReactNode } from "react";
2
+ import { type GestureResponderEvent } from "react-native";
3
+ import { View, Pressable, Text, useTheme, type ColorTokens, type StyleProp, type ViewStyle, type TextStyle } from "../../style/index.js";
4
+
5
+ // Shared Checkbox shell. Uses React Native's primitives DIRECTLY and reads the
6
+ // active brand tokens via useTheme, so colors follow light/dark and the glass
7
+ // surface. The shared structure (the box + glyph + label row, the size precedence,
8
+ // accessibility, onChange/onValueChange, indeterminate) lives here once; a platform
9
+ // file supplies only its skin (box shape/sizing/border, glyph color, press feedback)
10
+ // and calls createCheckbox. iOS has no native checkbox, so every skin is hand-drawn
11
+ // from the brand tokens — no platform default color ever leaks in.
12
+
13
+ export interface CheckboxProps {
14
+ children?: ReactNode;
15
+ /** Controlled checked state. The component renders exactly this value. */
16
+ checked?: boolean;
17
+ /**
18
+ * Mixed state: some-but-not-all selected. Shown as a dash, not a tick.
19
+ * Takes visual precedence over `checked`.
20
+ */
21
+ indeterminate?: boolean;
22
+ /** Fired with the next checked value when the row is pressed. */
23
+ onChange?: (next: boolean) => void;
24
+ /** Alias of onChange, for parity with RN's value-style callbacks. */
25
+ onValueChange?: (next: boolean) => void;
26
+ // Size (pick one; default is the base box).
27
+ small?: boolean;
28
+ large?: boolean;
29
+ // State.
30
+ disabled?: boolean;
31
+ /** Escape hatch for layout/positioning composition. */
32
+ style?: StyleProp<ViewStyle>;
33
+ }
34
+
35
+ export type Size = "small" | "base" | "large";
36
+
37
+ // Size precedence when more than one is passed: first match wins.
38
+ function sizeOf(p: CheckboxProps): Size {
39
+ if (p.large) return "large";
40
+ if (p.small) return "small";
41
+ return "base";
42
+ }
43
+
44
+ // The only thing a platform skin owns: the box, glyph, and label styles for a given
45
+ // state and size, plus the press/disabled feedback. Everything else is the shell.
46
+ export interface CheckboxSkin {
47
+ /** The square box. `filled` = checked or indeterminate. */
48
+ box: (tokens: ColorTokens, filled: boolean, size: Size) => ViewStyle;
49
+ /** The check / dash glyph inside a filled box. */
50
+ glyph: (tokens: ColorTokens, size: Size) => TextStyle;
51
+ /** The label text to the right of the box. */
52
+ label: (tokens: ColorTokens, size: Size) => TextStyle;
53
+ /** Opacity applied to the row when disabled. */
54
+ disabledOpacity: number;
55
+ /** iOS/web dim the row on press; Android uses a ripple instead (null). */
56
+ pressedOpacity: number | null;
57
+ /** Android ripple over the box; null on iOS/web. */
58
+ ripple: ((tokens: ColorTokens) => { color: string; borderless: boolean; radius?: number }) | null;
59
+ }
60
+
61
+ // The row: box + optional label, top-aligned so a multi-line label hangs from the box.
62
+ const ROW: ViewStyle = { flexDirection: "row", alignItems: "flex-start", gap: 8 };
63
+
64
+ /** Build a Checkbox component from a platform skin. */
65
+ export function createCheckbox(skin: CheckboxSkin) {
66
+ return function Checkbox(props: CheckboxProps) {
67
+ const { children, checked, indeterminate, onChange, onValueChange, disabled, style } = props;
68
+ const size = sizeOf(props);
69
+ const { tokens } = useTheme();
70
+ // Indeterminate reads as "selected-ish": fill the box like a checked state.
71
+ const filled = indeterminate || !!checked;
72
+ const glyph = indeterminate ? "–" : "✓"; // en dash : check mark
73
+
74
+ const handlePress = (_event: GestureResponderEvent) => {
75
+ const next = !checked;
76
+ onChange?.(next);
77
+ onValueChange?.(next);
78
+ };
79
+
80
+ const ripple = skin.ripple ? skin.ripple(tokens) : undefined;
81
+
82
+ return (
83
+ <Pressable
84
+ onPress={handlePress}
85
+ disabled={disabled}
86
+ accessibilityRole="checkbox"
87
+ accessibilityState={{ checked: indeterminate ? "mixed" : !!checked, disabled: !!disabled }}
88
+ android_ripple={ripple}
89
+ style={({ pressed }) => [
90
+ ROW,
91
+ disabled ? { opacity: skin.disabledOpacity } : null,
92
+ skin.pressedOpacity != null && pressed ? { opacity: skin.pressedOpacity } : null,
93
+ style,
94
+ ]}
95
+ >
96
+ <View style={skin.box(tokens, filled, size)}>
97
+ {filled ? <Text style={skin.glyph(tokens, size)}>{glyph}</Text> : null}
98
+ </View>
99
+ {children != null ? <Text style={skin.label(tokens, size)}>{children}</Text> : null}
100
+ </Pressable>
101
+ );
102
+ };
103
+ }
@@ -0,0 +1,106 @@
1
+ import { type ViewStyle, type TextStyle } from "react-native";
2
+ import { type ColorTokens } from "../../style/index.js";
3
+ import { type CheckboxSkin, type Size } from "./checkbox.shared.js";
4
+
5
+ // Co-located Checkbox skins, one per platform, all driven by the brand tokens
6
+ // (passed in from useTheme so they follow light/dark and the glass surface). The
7
+ // BRAND survives on every platform (the filled box is always the indigo `primary`,
8
+ // never a platform default), and only the native SHAPE, sizing, border weight, and
9
+ // press feedback change per OS:
10
+ // iOS (HIG): no native checkbox; the de-facto rounded square (~5 radius), a
11
+ // hairline 1px border when empty, brand fill + white check when checked, the
12
+ // control nudged to ~20pt; press = opacity dim (~0.8).
13
+ // Android (Material 3): an 18dp square with a 2dp corner radius and a 2dp outline
14
+ // when empty, brand fill + white check when checked; press = android_ripple over
15
+ // a 40dp state layer; disabled opacity 0.38.
16
+ // Web: the established Canvas look (the current checkbox, lifted verbatim) —
17
+ // 14/16/20px box per size, 3 radius, 1px border, brand fill + foreground check.
18
+
19
+ // Box dimensions per size.
20
+ const WEB_BOX: Record<Size, number> = { small: 14, base: 16, large: 20 };
21
+ const IOS_BOX: Record<Size, number> = { small: 18, base: 20, large: 24 };
22
+ const ANDROID_BOX: Record<Size, number> = { small: 18, base: 18, large: 20 };
23
+
24
+ // Glyph (check / dash) type per box family. The check sits inside the box, so the
25
+ // font tracks the box size; `lineHeight: fontSize` keeps it optically centered.
26
+ function glyphType(fontSize: number): TextStyle {
27
+ return { fontSize, lineHeight: fontSize };
28
+ }
29
+ const WEB_GLYPH: Record<Size, number> = { small: 12, base: 12, large: 14 };
30
+ const IOS_GLYPH: Record<Size, number> = { small: 13, base: 14, large: 17 };
31
+ const ANDROID_GLYPH: Record<Size, number> = { small: 13, base: 13, large: 15 };
32
+
33
+ // Label type per size (shared across platforms; the label is brand type, not a
34
+ // platform face). Matches the original Canvas label scale.
35
+ const LABEL_TYPE: Record<Size, TextStyle> = {
36
+ small: { fontSize: 12, lineHeight: 16 }, // text-xs
37
+ base: { fontSize: 14, lineHeight: 20 }, // text-sm
38
+ large: { fontSize: 16, lineHeight: 24 }, // text-base
39
+ };
40
+ function label(tokens: ColorTokens, size: Size): TextStyle {
41
+ return { fontWeight: "500", color: tokens.foreground, ...LABEL_TYPE[size] };
42
+ }
43
+
44
+ // Box base: a square, centered, nudged down (`marginTop: 2`) to align with the
45
+ // label's first line. Per-platform radius/border weight is layered on by each skin.
46
+ function boxBase(box: number): ViewStyle {
47
+ return {
48
+ marginTop: 2,
49
+ flexShrink: 0,
50
+ alignItems: "center",
51
+ justifyContent: "center",
52
+ width: box,
53
+ height: box,
54
+ };
55
+ }
56
+
57
+ // ---------- Web: the established Canvas look ----------
58
+ export const webSkin: CheckboxSkin = {
59
+ box: (t, filled, size) => ({
60
+ ...boxBase(WEB_BOX[size]),
61
+ borderRadius: 3,
62
+ borderWidth: 1,
63
+ ...(filled
64
+ ? { borderColor: t.primary, backgroundColor: t.primary }
65
+ : { borderColor: t.input, backgroundColor: "transparent" }),
66
+ }),
67
+ glyph: (t, size) => ({ fontWeight: "500", color: t["primary-foreground"], ...glyphType(WEB_GLYPH[size]) }),
68
+ label,
69
+ disabledOpacity: 0.5,
70
+ pressedOpacity: 0.9,
71
+ ripple: null,
72
+ };
73
+
74
+ // ---------- iOS (HIG): rounded square, hairline border, dim on press ----------
75
+ export const iosSkin: CheckboxSkin = {
76
+ box: (t, filled, size) => ({
77
+ ...boxBase(IOS_BOX[size]),
78
+ borderRadius: size === "small" ? 4 : size === "large" ? 6 : 5,
79
+ borderWidth: 1, // hairline when empty; the fill hides it when checked
80
+ ...(filled
81
+ ? { borderColor: t.primary, backgroundColor: t.primary }
82
+ : { borderColor: t.input, backgroundColor: "transparent" }),
83
+ }),
84
+ glyph: (_t, size) => ({ fontWeight: "600", color: "#ffffff", ...glyphType(IOS_GLYPH[size]) }),
85
+ label,
86
+ disabledOpacity: 0.5,
87
+ pressedOpacity: 0.8,
88
+ ripple: null,
89
+ };
90
+
91
+ // ---------- Android (Material 3): 18dp square, 2dp radius/outline, ripple ----------
92
+ export const androidSkin: CheckboxSkin = {
93
+ box: (t, filled, size) => ({
94
+ ...boxBase(ANDROID_BOX[size]),
95
+ borderRadius: 2,
96
+ borderWidth: 2,
97
+ ...(filled
98
+ ? { borderColor: t.primary, backgroundColor: t.primary }
99
+ : { borderColor: t["muted-foreground"], backgroundColor: "transparent" }),
100
+ }),
101
+ glyph: (_t, size) => ({ fontWeight: "700", color: "#ffffff", ...glyphType(ANDROID_GLYPH[size]) }),
102
+ label,
103
+ disabledOpacity: 0.38, // M3 disabled opacity
104
+ pressedOpacity: null, // Android uses a ripple instead
105
+ ripple: (t) => ({ color: t.primary, borderless: true, radius: 20 }), // 40dp state layer
106
+ };
@@ -0,0 +1,6 @@
1
+ import { createCheckbox } from "./checkbox.shared.js";
2
+ import { webSkin } from "./checkbox.styles.js";
3
+
4
+ // Web Checkbox (the base; Metro falls back to it on native, web bundlers resolve it).
5
+ export const Checkbox = createCheckbox(webSkin);
6
+ export type { CheckboxProps } from "./checkbox.shared.js";
@@ -0,0 +1,6 @@
1
+ import { createCombobox } from "./combobox.shared.js";
2
+ import { androidSkin } from "./combobox.styles.js";
3
+
4
+ // Material 3 Combobox. Metro resolves this file on Android; the docs import it for preview.
5
+ export const Combobox = createCombobox(androidSkin);
6
+ export type { ComboboxProps } from "./combobox.shared.js";
@@ -0,0 +1,6 @@
1
+ import { createCombobox } from "./combobox.shared.js";
2
+ import { iosSkin } from "./combobox.styles.js";
3
+
4
+ // iOS (HIG) Combobox. Metro resolves this file on iOS; the docs import it for preview.
5
+ export const Combobox = createCombobox(iosSkin);
6
+ export type { ComboboxProps } from "./combobox.shared.js";
@@ -0,0 +1,213 @@
1
+ # Comboboxes
2
+
3
+ Text input + dropdown: searchable single-select.
4
+
5
+ ## Usage
6
+
7
+ ```tsx
8
+ <Combobox
9
+ options={[
10
+ "Ada Lovelace",
11
+ "Grace Hopper",
12
+ "Kira Tanaka",
13
+ "Liang Bao",
14
+ "Marcus Allen",
15
+ "Noor Park",
16
+ "Rachel Chen"
17
+ ]}
18
+ label="Assigned to"
19
+ placeholder="Search a person…"
20
+ style={{ maxWidth: 300 }}
21
+ />
22
+ ```
23
+
24
+ ## Variants
25
+
26
+ ### With helper text
27
+
28
+ ```tsx
29
+ <Combobox
30
+ options={[
31
+ "Ada Lovelace",
32
+ "Grace Hopper",
33
+ "Kira Tanaka",
34
+ "Liang Bao",
35
+ "Marcus Allen",
36
+ "Noor Park",
37
+ "Rachel Chen"
38
+ ]}
39
+ label="Assigned to"
40
+ helperText="The person responsible for this account."
41
+ placeholder="Search a person…"
42
+ style={{ maxWidth: 300 }}
43
+ />
44
+ ```
45
+
46
+ ### Disabled
47
+
48
+ ```tsx
49
+ <Combobox
50
+ options={[
51
+ "Ada Lovelace",
52
+ "Grace Hopper",
53
+ "Kira Tanaka",
54
+ "Liang Bao",
55
+ "Marcus Allen",
56
+ "Noor Park",
57
+ "Rachel Chen"
58
+ ]}
59
+ label="Assigned to"
60
+ placeholder="Search a person…"
61
+ disabled
62
+ style={{ maxWidth: 300 }}
63
+ />
64
+ ```
65
+
66
+ ## Do & Don't
67
+
68
+ ### When to use
69
+
70
+ **Do** — A plain select for short, fixed lists; reserve the combobox for long, searchable ones.
71
+
72
+ ```tsx
73
+ <Select label="Size" options={["Small", "Medium", "Large"]} open placeholder="Select a size" style={{ maxWidth: 280 }} />
74
+ ```
75
+
76
+ **Don't** — Type or click: a search field for three fixed options is overhead with nothing to filter.
77
+
78
+ ```tsx
79
+ <Combobox label="Size" options={["Small", "Medium", "Large"]} open placeholder="Search…" style={{ maxWidth: 280 }} />
80
+ ```
81
+
82
+ ### Filtering
83
+
84
+ **Do** — Type a few letters: the list narrows as you go, so a long list stays usable.
85
+
86
+ ```tsx
87
+ <Combobox label="Assigned to" options={[
88
+ "Wade Cooper",
89
+ "Arlene Mccoy",
90
+ "Devon Webb",
91
+ "Tom Cook",
92
+ "Tanya Fox",
93
+ "Hellen Schmidt"
94
+ ]} query="co" open style={{ maxWidth: 280 }} />
95
+ ```
96
+
97
+ **Don't** — Try typing: a search box that ignores input is just a dropdown wearing a costume.
98
+
99
+ ```tsx
100
+ <View style={{ position: "relative", width: "100%", maxWidth: 280 }}>
101
+ <Text style={{ marginBottom: 6, fontWeight: "500", color: tokens.foreground, fontSize: 14, lineHeight: 20 }}>Assigned to</Text>
102
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", borderRadius: 6, borderWidth: 1, borderColor: tokens.input, backgroundColor: tokens.background, paddingHorizontal: 12, height: 36 }}>
103
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens.foreground }}>co</Text>
104
+ <Text style={{ color: tokens["muted-foreground"], fontSize: 14, lineHeight: 20 }}>▾</Text>
105
+ </View>
106
+ <View style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4, maxHeight: 240, borderRadius: 6, borderWidth: 1, borderColor: tokens.border, backgroundColor: tokens.popover, padding: 4, ...shadow("lg") }}>
107
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
108
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
109
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Wade Cooper</Text>
110
+ </View>
111
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
112
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
113
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Arlene Mccoy</Text>
114
+ </View>
115
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
116
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
117
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Devon Webb</Text>
118
+ </View>
119
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
120
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
121
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Tom Cook</Text>
122
+ </View>
123
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
124
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
125
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Tanya Fox</Text>
126
+ </View>
127
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
128
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
129
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Hellen Schmidt</Text>
130
+ </View>
131
+ </View>
132
+ </View>
133
+ ```
134
+
135
+ ### Selection
136
+
137
+ **Do** — Click an option: it fills the input and stays marked as selected.
138
+
139
+ ```tsx
140
+ <Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb", "Tom Cook"]} value="Devon Webb" open style={{ maxWidth: 280 }} />
141
+ ```
142
+
143
+ **Don't** — Click an option: it flashes but the field stays empty, so you can't tell what you picked.
144
+
145
+ ```tsx
146
+ <View style={{ position: "relative", width: "100%", maxWidth: 280 }}>
147
+ <Text style={{ marginBottom: 6, fontWeight: "500", color: tokens.foreground, fontSize: 14, lineHeight: 20 }}>Assigned to</Text>
148
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", borderRadius: 6, borderWidth: 1, borderColor: tokens.input, backgroundColor: tokens.background, paddingHorizontal: 12, height: 36 }}>
149
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["muted-foreground"] }}>Pick a person…</Text>
150
+ <Text style={{ color: tokens["muted-foreground"], fontSize: 14, lineHeight: 20 }}>▾</Text>
151
+ </View>
152
+ <View style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4, maxHeight: 240, borderRadius: 6, borderWidth: 1, borderColor: tokens.border, backgroundColor: tokens.popover, padding: 4, ...shadow("lg") }}>
153
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
154
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
155
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Wade Cooper</Text>
156
+ </View>
157
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
158
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
159
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Arlene Mccoy</Text>
160
+ </View>
161
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
162
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
163
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Devon Webb</Text>
164
+ </View>
165
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
166
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
167
+ <Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Tom Cook</Text>
168
+ </View>
169
+ </View>
170
+ </View>
171
+ ```
172
+
173
+ ### With label
174
+
175
+ **Do** — A persistent label keeps the field named after a selection has filled the input.
176
+
177
+ ```tsx
178
+ <Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} value="Devon Webb" open style={{ maxWidth: 280 }} />
179
+ ```
180
+
181
+ **Don't** — Once a value replaces the placeholder, an unlabeled field has nothing left to name it.
182
+
183
+ ```tsx
184
+ <Combobox options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} value="Devon Webb" open style={{ maxWidth: 280 }} />
185
+ ```
186
+
187
+ ### With helper text
188
+
189
+ **Do** — A short placeholder plus persistent helper text keeps the rule visible while you type.
190
+
191
+ ```tsx
192
+ <Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} open placeholder="Search a person…" helperText="Deactivated users are hidden from the list." style={{ maxWidth: 280 }} />
193
+ ```
194
+
195
+ **Don't** — Type a letter: guidance crammed into the placeholder vanishes the moment you start.
196
+
197
+ ```tsx
198
+ <Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} open placeholder="Pick an active teammate; deactivated users are hidden" style={{ maxWidth: 280 }} />
199
+ ```
200
+
201
+ ### Disabled
202
+
203
+ **Do** — Show the locked value and say why it's fixed, so disabled reads as a settled choice.
204
+
205
+ ```tsx
206
+ <Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} value="Devon Webb" disabled helperText="Set by the project owner and can't be changed here." style={{ maxWidth: 280 }} />
207
+ ```
208
+
209
+ **Don't** — An empty, dimmed field with no value reads as broken, not as intentionally locked.
210
+
211
+ ```tsx
212
+ <Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} disabled placeholder="Search a person…" style={{ maxWidth: 280 }} />
213
+ ```
@@ -0,0 +1,160 @@
1
+ import { useState } from "react";
2
+ import { View, Pressable, Text, useTheme, type StyleProp, type ViewStyle } from "../../style/index.js";
3
+ import { wrapper, wrapperLifted } from "./combobox.styles.js";
4
+ import { type ComboboxSkin, type Size } from "./combobox.styles.js";
5
+
6
+ // Shared Combobox shell. A Combobox is a searchable single-select: it mirrors
7
+ // Select's structure (a field plus an open option list) and adds text filtering
8
+ // — the field shows the typed query, and the list narrows to options matching
9
+ // that query as you type.
10
+ //
11
+ // The structure (the field, the open/close state machine, the query filtering,
12
+ // the highlighted selected/active option, the helper text), the public
13
+ // boolean-prop API, the size precedence, accessibility, and handlers all live
14
+ // here once. A platform file supplies only its skin (field shape, fill,
15
+ // border/underline, popover elevation, row layout, press feedback) and calls
16
+ // createCombobox.
17
+ //
18
+ // Like Select, the open state is rendered inline (the docs render it this way;
19
+ // there is no portal/Modal). `open` defaults to true so the floating list is
20
+ // visible. The selected option carries a leading "✓" and an accent surface; an
21
+ // empty filtered list shows a muted "No results" row.
22
+
23
+ export interface ComboboxProps {
24
+ /** The text typed into the field. Filters the option list when set. */
25
+ query?: string;
26
+ /** The full list of selectable option labels. */
27
+ options?: string[];
28
+ /** The currently selected option label, marked with a check in the list. */
29
+ value?: string;
30
+ /** Prompt shown in the field when there is no query or value. */
31
+ placeholder?: string;
32
+ /**
33
+ * Whether the option list is open. Defaults to true so the open state is
34
+ * visible inline (the docs render it this way; there is no portal/Modal).
35
+ */
36
+ open?: boolean;
37
+ /** Fired when the open state changes (field tap, select). */
38
+ onOpenChange?: (open: boolean) => void;
39
+ /** Optional stacked field label rendered above the field. */
40
+ label?: string;
41
+ /** Optional muted helper line rendered below the option list. */
42
+ helperText?: string;
43
+ /** Dims the control and blocks interaction. */
44
+ disabled?: boolean;
45
+ /** Called with the chosen option label when a row is pressed. */
46
+ onSelect?: (option: string) => void;
47
+ // Size (pick one; default is the medium field, matching Input's h-9).
48
+ small?: boolean;
49
+ large?: boolean;
50
+ /** Escape hatch for layout/positioning composition (mainly width). */
51
+ style?: StyleProp<ViewStyle>;
52
+ }
53
+
54
+ // First match wins when more than one size flag is passed.
55
+ function sizeOf(p: ComboboxProps): Size {
56
+ if (p.small) return "small";
57
+ if (p.large) return "large";
58
+ return "default";
59
+ }
60
+
61
+ /** Build a Combobox component from a platform skin. */
62
+ export function createCombobox(skin: ComboboxSkin) {
63
+ return function Combobox(props: ComboboxProps) {
64
+ const {
65
+ query,
66
+ options = [],
67
+ value,
68
+ label,
69
+ helperText,
70
+ placeholder = "Search…",
71
+ open: openProp,
72
+ onOpenChange,
73
+ disabled,
74
+ onSelect,
75
+ style,
76
+ } = props;
77
+ const size = sizeOf(props);
78
+ const { tokens } = useTheme();
79
+ // Uncontrolled by default: the field opens/closes the list, a select closes it.
80
+ const [internalOpen, setInternalOpen] = useState(false);
81
+ const open = openProp ?? internalOpen;
82
+ const setOpen = (next: boolean) => {
83
+ if (openProp === undefined) setInternalOpen(next);
84
+ onOpenChange?.(next);
85
+ };
86
+
87
+ // What the field shows: the typed query, then the selected value, else the
88
+ // placeholder. The first two read as foreground text; the placeholder is muted.
89
+ const hasQuery = query != null && query !== "";
90
+ const hasValue = value != null && value !== "";
91
+ const fieldText = hasQuery ? query : hasValue ? value : placeholder;
92
+ const fieldMuted = !hasQuery && !hasValue;
93
+
94
+ // Filter the list by the query (case-insensitive). With no query, show all.
95
+ const q = hasQuery ? (query as string).toLowerCase() : "";
96
+ const matches = hasQuery
97
+ ? options.filter((o) => o.toLowerCase().includes(q))
98
+ : options;
99
+
100
+ const ripple = skin.ripple ? skin.ripple(tokens) : undefined;
101
+
102
+ return (
103
+ <View style={[wrapper, open ? wrapperLifted : null, style]}>
104
+ {label != null && label !== "" ? (
105
+ <Text style={skin.label(tokens, size)}>{label}</Text>
106
+ ) : null}
107
+ <Pressable
108
+ style={({ pressed }) => [
109
+ skin.field(tokens, size, open),
110
+ skin.pressedOpacity != null && pressed ? { opacity: skin.pressedOpacity } : null,
111
+ disabled ? { opacity: skin.disabledOpacity } : null,
112
+ ]}
113
+ disabled={disabled}
114
+ onPress={() => setOpen(!open)}
115
+ android_ripple={ripple}
116
+ accessibilityRole="button"
117
+ >
118
+ <Text style={skin.fieldText(tokens, size, fieldMuted)}>{fieldText}</Text>
119
+ <Text style={skin.chevron(tokens, size)}>▾</Text>
120
+ </Pressable>
121
+
122
+ {open ? (
123
+ <View style={skin.popover(tokens)}>
124
+ {matches.length === 0 ? (
125
+ <View style={skin.emptyRow}>
126
+ <Text style={skin.emptyText(tokens, size)}>No results</Text>
127
+ </View>
128
+ ) : (
129
+ matches.map((option) => {
130
+ const selected = option === value;
131
+ return (
132
+ <Pressable
133
+ key={option}
134
+ style={({ pressed }) => [
135
+ skin.row,
136
+ selected || pressed ? skin.rowAccent(tokens) : null,
137
+ ]}
138
+ onPress={() => {
139
+ onSelect?.(option);
140
+ setOpen(false);
141
+ }}
142
+ android_ripple={ripple}
143
+ accessibilityRole="button"
144
+ >
145
+ <Text style={skin.check(tokens, size)}>{selected ? "✓" : " "}</Text>
146
+ <Text style={skin.optionText(tokens, size)}>{option}</Text>
147
+ </Pressable>
148
+ );
149
+ })
150
+ )}
151
+ </View>
152
+ ) : null}
153
+
154
+ {helperText != null && helperText !== "" ? (
155
+ <Text style={skin.helper(tokens)}>{helperText}</Text>
156
+ ) : null}
157
+ </View>
158
+ );
159
+ };
160
+ }