@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,281 @@
1
+ import { View, Pressable, Text, useTheme, type ColorTokens, type StyleProp, type ViewStyle, type TextStyle } from "../../style/index.js";
2
+ import * as s from "./tabs.styles.js";
3
+ import { type Variant } from "./tabs.styles.js";
4
+
5
+ // Shared Tabs shell. The structure (the three looks, their layout, the active
6
+ // selection, the optional count badge), the accessibility, the look precedence,
7
+ // and the press handlers live here once; a platform file supplies only its skin
8
+ // (the native row/trigger shape, selected-tab treatment, indicator, label color,
9
+ // press feedback) and calls createTabs.
10
+ //
11
+ // Tabs are a horizontal row of pressable triggers above panel content, with the
12
+ // active trigger emphasized so the current view is unmistakable.
13
+ //
14
+ // Three looks, picked by boolean prop (first match wins):
15
+ // - underline (default): each trigger is muted text; the active one gets the
16
+ // foreground/brand color and an indicator beneath it. The native shape
17
+ // differs per OS: web/Android draw an underline rule (Android = a 3px brand
18
+ // `primary` bar with muted inactive labels); iOS draws a gray segmented
19
+ // track with a raised white pill on the selected tab (no underline).
20
+ // - `pills`: the row is a muted track; the active trigger is an elevated/tonal
21
+ // background pill while the rest sit flat and muted.
22
+ // - `vertical`: the triggers stack into a left-aligned column rail; the active
23
+ // one is filled with an accent/tonal background while the rest sit flat and
24
+ // muted. Use it as a settings-style side rail.
25
+ //
26
+ // Orthogonal layout modifier:
27
+ // - `block`: triggers share the row equally (each flex-1) and the labels
28
+ // center, so the group spans the full available width. Omit for triggers
29
+ // that hug their labels at the leading edge.
30
+ //
31
+ // Each tab may carry an optional count badge (the `{ label, badge }` item
32
+ // shape), rendered as a small secondary pill after the label.
33
+ //
34
+ // The active underline is drawn as an explicit indicator View under the trigger
35
+ // rather than as a bottom border in markup (mirroring how ButtonGroup hand-rolls
36
+ // its hairline divider).
37
+
38
+ // The platform-varying surface. Everything color/shape-bearing the three looks
39
+ // need lives here, built from the active tokens (so each follows light/dark/glass).
40
+ export interface TabsSkin {
41
+ /** iOS/web dim the trigger on press; Android uses a ripple instead (null). */
42
+ pressedOpacity: number | null;
43
+ /** Android ripple over a pressed trigger; null on iOS/web. */
44
+ ripple: ((t: ColorTokens) => { color: string; borderless: boolean }) | null;
45
+
46
+ // --- underline ---
47
+ underlineRow: (t: ColorTokens) => ViewStyle;
48
+ underlineTrigger: (t: ColorTokens, selected: boolean) => ViewStyle;
49
+ underlineIndicator: (t: ColorTokens, selected: boolean) => ViewStyle;
50
+ underlineLabel: (t: ColorTokens, selected: boolean) => TextStyle;
51
+
52
+ // --- pills ---
53
+ pillsRow: (t: ColorTokens) => ViewStyle;
54
+ pillsTrigger: (t: ColorTokens, selected: boolean) => ViewStyle;
55
+ pillsFill: (t: ColorTokens, selected: boolean) => ViewStyle;
56
+ pillsLabel: (t: ColorTokens, selected: boolean) => TextStyle;
57
+
58
+ // --- vertical ---
59
+ verticalTrigger: (t: ColorTokens, selected: boolean) => ViewStyle;
60
+ verticalFill: (t: ColorTokens, selected: boolean) => ViewStyle;
61
+ verticalLabel: (t: ColorTokens, selected: boolean) => TextStyle;
62
+
63
+ // --- count badge ---
64
+ countBadgeBox: (t: ColorTokens) => ViewStyle;
65
+ countBadgeLabel: (t: ColorTokens, muted: boolean) => TextStyle;
66
+ }
67
+
68
+ /** A tab is either a bare label or a label paired with a count badge. */
69
+ export type TabItem = string | { label: string; badge?: string };
70
+
71
+ export interface TabsProps {
72
+ /** Triggers, left to right. Strings, or `{ label, badge }` for a count. */
73
+ tabs?: TabItem[];
74
+ /** Index of the active trigger. */
75
+ active?: number;
76
+ /** Called with the pressed trigger's index. */
77
+ onChange?: (index: number) => void;
78
+
79
+ // Look (pick one; default is the underline look). Precedence when more than
80
+ // one is passed: pills, then vertical, then underline.
81
+ pills?: boolean;
82
+ vertical?: boolean;
83
+ underline?: boolean;
84
+
85
+ // Layout: equal full-width triggers vs. leading-aligned hugging triggers.
86
+ block?: boolean;
87
+
88
+ disabled?: boolean;
89
+ /** Escape hatch for layout/positioning composition (mainly width). */
90
+ style?: StyleProp<ViewStyle>;
91
+ }
92
+
93
+ // Variant precedence when more than one is passed: first match wins.
94
+ function variantOf(p: TabsProps): Variant {
95
+ if (p.pills) return "pills";
96
+ if (p.vertical) return "vertical";
97
+ if (p.underline) return "underline";
98
+ return "underline";
99
+ }
100
+
101
+ const DEFAULT_TABS: TabItem[] = ["General", "Security", "Notifications", "Billing"];
102
+
103
+ function labelOf(item: TabItem): string {
104
+ return typeof item === "string" ? item : item.label;
105
+ }
106
+
107
+ function badgeOf(item: TabItem): string | undefined {
108
+ return typeof item === "string" ? undefined : item.badge;
109
+ }
110
+
111
+ // vertical: flex-col items-stretch gap-1; width w-full (block) or w-[180px].
112
+ function verticalRail(block: boolean): ViewStyle {
113
+ return {
114
+ flexDirection: "column",
115
+ alignItems: "stretch",
116
+ gap: 4,
117
+ width: block ? "100%" : 180,
118
+ };
119
+ }
120
+
121
+ /** Build a Tabs component from a platform skin. */
122
+ export function createTabs(skin: TabsSkin) {
123
+ const ripple = skin.ripple;
124
+
125
+ // A small secondary count pill shown after a trigger label.
126
+ function CountBadge({ children, muted }: { children: string; muted: boolean }) {
127
+ const { tokens } = useTheme();
128
+ return (
129
+ <View style={skin.countBadgeBox(tokens)}>
130
+ <Text style={skin.countBadgeLabel(tokens, muted)}>{children}</Text>
131
+ </View>
132
+ );
133
+ }
134
+
135
+ interface TriggerProps {
136
+ label: string;
137
+ badge?: string;
138
+ selected: boolean;
139
+ variant: Variant;
140
+ block?: boolean;
141
+ disabled?: boolean;
142
+ onPress?: () => void;
143
+ }
144
+
145
+ function Trigger({ label, badge, selected, variant, block, disabled, onPress }: TriggerProps) {
146
+ const { tokens } = useTheme();
147
+
148
+ if (variant === "vertical") {
149
+ // Vertical rail: a full-width, left-aligned row; the active item is filled
150
+ // with an accent/tonal background.
151
+ const container: StyleProp<ViewStyle> = [
152
+ skin.verticalTrigger(tokens, selected),
153
+ skin.verticalFill(tokens, selected),
154
+ disabled ? s.disabledDim : null,
155
+ ];
156
+ return (
157
+ <Pressable
158
+ onPress={onPress}
159
+ disabled={disabled}
160
+ android_ripple={ripple ? ripple(tokens) : undefined}
161
+ accessibilityRole="tab"
162
+ accessibilityState={{ selected, disabled: !!disabled }}
163
+ style={({ pressed }) => [container, skin.pressedOpacity != null && pressed ? { opacity: skin.pressedOpacity } : null]}
164
+ >
165
+ <Text style={skin.verticalLabel(tokens, selected)}>{label}</Text>
166
+ {badge != null ? <CountBadge muted={!selected}>{badge}</CountBadge> : null}
167
+ </Pressable>
168
+ );
169
+ }
170
+
171
+ if (variant === "pills") {
172
+ const container: StyleProp<ViewStyle> = [
173
+ skin.pillsTrigger(tokens, selected),
174
+ block ? s.flex1 : null,
175
+ skin.pillsFill(tokens, selected),
176
+ disabled ? s.disabledDim : null,
177
+ ];
178
+ return (
179
+ <Pressable
180
+ onPress={onPress}
181
+ disabled={disabled}
182
+ android_ripple={ripple ? ripple(tokens) : undefined}
183
+ accessibilityRole="tab"
184
+ accessibilityState={{ selected, disabled: !!disabled }}
185
+ style={({ pressed }) => [container, skin.pressedOpacity != null && pressed ? { opacity: skin.pressedOpacity } : null]}
186
+ >
187
+ <Text style={skin.pillsLabel(tokens, selected)}>{label}</Text>
188
+ {badge != null ? <CountBadge muted={!selected}>{badge}</CountBadge> : null}
189
+ </Pressable>
190
+ );
191
+ }
192
+
193
+ // Underline: the active trigger gets the emphasized label and an indicator
194
+ // drawn as an explicit sliver pinned to the trigger's bottom edge (iOS draws
195
+ // a raised pill instead, supplied through underlineTrigger).
196
+ const container: StyleProp<ViewStyle> = [
197
+ skin.underlineTrigger(tokens, selected),
198
+ block ? s.flex1 : null,
199
+ disabled ? s.disabledDim : null,
200
+ ];
201
+ return (
202
+ <Pressable
203
+ onPress={onPress}
204
+ disabled={disabled}
205
+ android_ripple={ripple ? ripple(tokens) : undefined}
206
+ accessibilityRole="tab"
207
+ accessibilityState={{ selected, disabled: !!disabled }}
208
+ style={({ pressed }) => [container, skin.pressedOpacity != null && pressed ? { opacity: skin.pressedOpacity } : null]}
209
+ >
210
+ <Text style={skin.underlineLabel(tokens, selected)}>{label}</Text>
211
+ {badge != null ? <CountBadge muted={!selected}>{badge}</CountBadge> : null}
212
+ <View style={skin.underlineIndicator(tokens, selected)} />
213
+ </Pressable>
214
+ );
215
+ }
216
+
217
+ return function Tabs(props: TabsProps) {
218
+ const { tabs = DEFAULT_TABS, active = 0, onChange, disabled, style } = props;
219
+ const variant = variantOf(props);
220
+ const { tokens } = useTheme();
221
+
222
+ if (variant === "vertical") {
223
+ // A left-aligned column rail of stacked triggers; width hugs its content
224
+ // unless `block` stretches it to fill the available column.
225
+ return (
226
+ <View style={[verticalRail(!!props.block), style]}>
227
+ {tabs.map((item, i) => (
228
+ <Trigger
229
+ key={`${labelOf(item)}-${i}`}
230
+ label={labelOf(item)}
231
+ badge={badgeOf(item)}
232
+ selected={i === active}
233
+ variant="vertical"
234
+ block={props.block}
235
+ disabled={disabled}
236
+ onPress={() => onChange?.(i)}
237
+ />
238
+ ))}
239
+ </View>
240
+ );
241
+ }
242
+
243
+ if (variant === "pills") {
244
+ return (
245
+ <View style={[skin.pillsRow(tokens), s.blockWidth(!!props.block), style]}>
246
+ {tabs.map((item, i) => (
247
+ <Trigger
248
+ key={`${labelOf(item)}-${i}`}
249
+ label={labelOf(item)}
250
+ badge={badgeOf(item)}
251
+ selected={i === active}
252
+ variant="pills"
253
+ block={props.block}
254
+ disabled={disabled}
255
+ onPress={() => onChange?.(i)}
256
+ />
257
+ ))}
258
+ </View>
259
+ );
260
+ }
261
+
262
+ // Underline: the row sits on a hairline bottom border (web/Android) or a gray
263
+ // segmented track (iOS).
264
+ return (
265
+ <View style={[skin.underlineRow(tokens), s.blockWidth(!!props.block), style]}>
266
+ {tabs.map((item, i) => (
267
+ <Trigger
268
+ key={`${labelOf(item)}-${i}`}
269
+ label={labelOf(item)}
270
+ badge={badgeOf(item)}
271
+ selected={i === active}
272
+ variant="underline"
273
+ block={props.block}
274
+ disabled={disabled}
275
+ onPress={() => onChange?.(i)}
276
+ />
277
+ ))}
278
+ </View>
279
+ );
280
+ };
281
+ }
@@ -0,0 +1,398 @@
1
+ import { type ViewStyle, type TextStyle } from "react-native";
2
+ import { type ColorTokens, shadow, alpha } from "../../style/index.js";
3
+ import { type TabsSkin } from "./tabs.shared.js";
4
+
5
+ // Co-located Tabs skins, one per platform. The shell resolves the look axis
6
+ // (underline / pills / vertical), the selection/block/disabled state, and the
7
+ // badges; the skin supplies only the native SHAPE, sizing, label weight, fill,
8
+ // indicator, and press feedback. The BRAND survives on every platform (the
9
+ // indigo `primary` token and the semantic tokens, never a platform default), so
10
+ // each follows light/dark and the glass surface.
11
+ //
12
+ // iOS (iOS 27 / Liquid Glass segmented control): the default underline look
13
+ // becomes a CAPSULE gray track (radius 9999, muted fill, 3px inset); the
14
+ // SELECTED tab is a raised white/elevated CAPSULE pill (radius 9999, small
15
+ // shadow); labels ~13pt; no underline rule. Both labels stay on-foreground
16
+ // (the white pill is the selected affordance, not a brand fill), mirroring
17
+ // the button-group iOS 27 segmented treatment. Press = dim.
18
+ // Android (M3 underline tabs): no container; each tab is text with a 3px brand
19
+ // `primary` indicator bar under the active tab; inactive labels read in
20
+ // `muted-foreground`; title-case ~14sp; press = android_ripple.
21
+ // Web: the established Canvas look (underline rule / muted pill track / accent
22
+ // rail), lifted verbatim from the original file.
23
+
24
+ export type Variant = "underline" | "pills" | "vertical";
25
+
26
+ // --- shared layout fragments (color-free; identical across platforms) --------
27
+
28
+ // flex-1: an equal-flex trigger in block mode (shares the row width).
29
+ export const flex1: ViewStyle = { flexGrow: 1, flexShrink: 1, flexBasis: "0%" };
30
+
31
+ // opacity-50: the dimmed disabled look the component applies per trigger.
32
+ export const disabledDim: ViewStyle = { opacity: 0.5 };
33
+
34
+ // w-full vs self-start: in block mode the row fills the available width so
35
+ // equal-flex triggers stretch it; otherwise the row hugs its triggers.
36
+ export function blockWidth(block: boolean): ViewStyle {
37
+ return block ? { width: "100%" } : { alignSelf: "flex-start" };
38
+ }
39
+
40
+ // =============================================================================
41
+ // Web: the established Canvas look (lifted verbatim from the original file).
42
+ // =============================================================================
43
+
44
+ export const webSkin: TabsSkin = {
45
+ // The selected underline tab carries no track ripple; press dims the trigger.
46
+ pressedOpacity: 0.9,
47
+ ripple: null,
48
+
49
+ // --- underline ---
50
+ underlineRow(tokens) {
51
+ return {
52
+ flexDirection: "row",
53
+ alignItems: "center",
54
+ borderBottomWidth: 1,
55
+ borderColor: tokens.border,
56
+ };
57
+ },
58
+ underlineTrigger() {
59
+ return {
60
+ flexDirection: "row",
61
+ alignItems: "center",
62
+ justifyContent: "center",
63
+ gap: 6,
64
+ paddingHorizontal: 16,
65
+ paddingVertical: 10,
66
+ };
67
+ },
68
+ // A 2px primary rule drawn as an explicit sliver pinned to the trigger's
69
+ // bottom edge (absolute bottom-0 left-0 right-0 h-0.5 rounded-full).
70
+ underlineIndicator(tokens, selected) {
71
+ return {
72
+ position: "absolute",
73
+ bottom: 0,
74
+ left: 0,
75
+ right: 0,
76
+ height: 2,
77
+ borderRadius: 9999,
78
+ backgroundColor: selected ? tokens.primary : "transparent",
79
+ };
80
+ },
81
+ underlineLabel(tokens, selected) {
82
+ return { fontSize: 14, lineHeight: 20, fontWeight: "500", color: selected ? tokens.foreground : tokens["muted-foreground"] };
83
+ },
84
+
85
+ // --- pills ---
86
+ pillsRow(tokens) {
87
+ return {
88
+ flexDirection: "row",
89
+ alignItems: "center",
90
+ gap: 4,
91
+ alignSelf: "flex-start",
92
+ borderRadius: 8,
93
+ backgroundColor: tokens.muted,
94
+ padding: 4,
95
+ };
96
+ },
97
+ pillsTrigger() {
98
+ return {
99
+ flexDirection: "row",
100
+ alignItems: "center",
101
+ justifyContent: "center",
102
+ gap: 6,
103
+ borderRadius: 6,
104
+ paddingHorizontal: 12,
105
+ paddingVertical: 6,
106
+ };
107
+ },
108
+ pillsFill(tokens, selected) {
109
+ return selected
110
+ ? { backgroundColor: tokens.background, ...shadow("sm") }
111
+ : { backgroundColor: "transparent" };
112
+ },
113
+ pillsLabel(tokens, selected) {
114
+ return { fontSize: 14, lineHeight: 20, fontWeight: "500", color: selected ? tokens.foreground : tokens["muted-foreground"] };
115
+ },
116
+
117
+ // --- vertical ---
118
+ verticalTrigger() {
119
+ return {
120
+ width: "100%",
121
+ flexDirection: "row",
122
+ alignItems: "center",
123
+ gap: 6,
124
+ borderRadius: 6,
125
+ paddingHorizontal: 12,
126
+ paddingVertical: 8,
127
+ };
128
+ },
129
+ verticalFill(tokens, selected) {
130
+ return { backgroundColor: selected ? tokens.accent : "transparent" };
131
+ },
132
+ verticalLabel(tokens, selected) {
133
+ return { fontSize: 14, lineHeight: 20, fontWeight: "500", color: selected ? tokens["accent-foreground"] : tokens["muted-foreground"] };
134
+ },
135
+
136
+ // --- count badge ---
137
+ countBadgeBox(tokens) {
138
+ return {
139
+ flexDirection: "row",
140
+ alignItems: "center",
141
+ alignSelf: "flex-start",
142
+ borderRadius: 6,
143
+ borderWidth: 1,
144
+ borderColor: "transparent",
145
+ backgroundColor: tokens.secondary,
146
+ paddingHorizontal: 6,
147
+ paddingVertical: 2,
148
+ };
149
+ },
150
+ countBadgeLabel(tokens, muted) {
151
+ return { fontSize: 12, lineHeight: 16, fontWeight: "500", color: muted ? tokens["muted-foreground"] : tokens["secondary-foreground"] };
152
+ },
153
+ };
154
+
155
+ // =============================================================================
156
+ // iOS (iOS 27 / Liquid Glass segmented control): the in-page tab strip is a
157
+ // CAPSULE segmented control (mirroring button-group's iOS 27 treatment). A
158
+ // capsule gray track (radius 9999) holds raised white CAPSULE pills (radius
159
+ // 9999); the pills look stays a segmented track too; vertical stays a left
160
+ // rail. Press = opacity dim.
161
+ // =============================================================================
162
+
163
+ const IOS_PILL_SHADOW: ViewStyle = {
164
+ shadowColor: "#000000",
165
+ shadowOffset: { width: 0, height: 1 },
166
+ shadowOpacity: 0.18,
167
+ shadowRadius: 2,
168
+ elevation: 2,
169
+ };
170
+
171
+ export const iosSkin: TabsSkin = {
172
+ pressedOpacity: 0.8, // HIG: dim on press
173
+ ripple: null,
174
+
175
+ // --- underline -> capsule segmented control (gray track + raised pill) ---
176
+ underlineRow(tokens) {
177
+ // The capsule gray track (radius 9999, muted fill, 3px inset).
178
+ return {
179
+ flexDirection: "row",
180
+ alignItems: "center",
181
+ gap: 0,
182
+ padding: 3,
183
+ borderRadius: 9999,
184
+ backgroundColor: tokens.muted,
185
+ };
186
+ },
187
+ underlineTrigger(tokens, selected) {
188
+ // Each tab is an independent capsule pill (radius 9999) inside the track;
189
+ // the selected one is raised white/elevated.
190
+ return {
191
+ flexDirection: "row",
192
+ alignItems: "center",
193
+ justifyContent: "center",
194
+ gap: 6,
195
+ borderRadius: 9999,
196
+ paddingHorizontal: 14,
197
+ paddingVertical: 7,
198
+ ...(selected ? { ...IOS_PILL_SHADOW, backgroundColor: tokens.background } : { backgroundColor: "transparent" }),
199
+ };
200
+ },
201
+ // No underline rule on iOS: the raised capsule pill is the selected affordance.
202
+ underlineIndicator() {
203
+ return { display: "none" };
204
+ },
205
+ underlineLabel(tokens, selected) {
206
+ // ~13pt SF label; selected reads slightly heavier. Both stay on-foreground
207
+ // (the white pill is the selected affordance, not a brand fill).
208
+ return { fontSize: 13, lineHeight: 18, fontWeight: selected ? "600" : "500", color: tokens.foreground };
209
+ },
210
+
211
+ // --- pills (capsule segmented track, same gray-track + raised pill) ---
212
+ pillsRow(tokens) {
213
+ return {
214
+ flexDirection: "row",
215
+ alignItems: "center",
216
+ gap: 0,
217
+ alignSelf: "flex-start",
218
+ borderRadius: 9999,
219
+ backgroundColor: tokens.muted,
220
+ padding: 3,
221
+ };
222
+ },
223
+ pillsTrigger() {
224
+ return {
225
+ flexDirection: "row",
226
+ alignItems: "center",
227
+ justifyContent: "center",
228
+ gap: 6,
229
+ borderRadius: 9999,
230
+ paddingHorizontal: 14,
231
+ paddingVertical: 7,
232
+ };
233
+ },
234
+ pillsFill(tokens, selected) {
235
+ return selected
236
+ ? { ...IOS_PILL_SHADOW, backgroundColor: tokens.background }
237
+ : { backgroundColor: "transparent" };
238
+ },
239
+ pillsLabel(tokens, selected) {
240
+ return { fontSize: 13, lineHeight: 18, fontWeight: selected ? "600" : "500", color: tokens.foreground };
241
+ },
242
+
243
+ // --- vertical (HIG grouped rail; active item is an accent-filled row) ---
244
+ verticalTrigger() {
245
+ return {
246
+ width: "100%",
247
+ flexDirection: "row",
248
+ alignItems: "center",
249
+ gap: 6,
250
+ borderRadius: 8,
251
+ paddingHorizontal: 12,
252
+ paddingVertical: 9,
253
+ };
254
+ },
255
+ verticalFill(tokens, selected) {
256
+ return { backgroundColor: selected ? tokens.accent : "transparent" };
257
+ },
258
+ verticalLabel(tokens, selected) {
259
+ return { fontSize: 15, lineHeight: 20, fontWeight: selected ? "600" : "400", color: selected ? tokens["accent-foreground"] : tokens.foreground };
260
+ },
261
+
262
+ // --- count badge ---
263
+ countBadgeBox(tokens) {
264
+ return {
265
+ flexDirection: "row",
266
+ alignItems: "center",
267
+ alignSelf: "flex-start",
268
+ borderRadius: 9999,
269
+ borderWidth: 0,
270
+ borderColor: "transparent",
271
+ backgroundColor: tokens.secondary,
272
+ paddingHorizontal: 7,
273
+ paddingVertical: 1,
274
+ };
275
+ },
276
+ countBadgeLabel(tokens, muted) {
277
+ return { fontSize: 12, lineHeight: 16, fontWeight: "600", color: muted ? tokens["muted-foreground"] : tokens["secondary-foreground"] };
278
+ },
279
+ };
280
+
281
+ // =============================================================================
282
+ // Android (Material 3): underline tabs. No container; each tab is text with a
283
+ // 3px brand `primary` indicator bar under the active tab; inactive labels read
284
+ // muted; press = android_ripple.
285
+ // =============================================================================
286
+
287
+ export const androidSkin: TabsSkin = {
288
+ pressedOpacity: null, // Android uses a ripple instead
289
+ ripple: (tokens) => ({ color: alpha(tokens.primary, 0.12), borderless: false }),
290
+
291
+ // --- underline (M3 primary tabs) ---
292
+ underlineRow(tokens) {
293
+ // M3 tabs sit on a hairline divider; no track fill.
294
+ return {
295
+ flexDirection: "row",
296
+ alignItems: "stretch",
297
+ borderBottomWidth: 1,
298
+ borderColor: tokens.border,
299
+ };
300
+ },
301
+ underlineTrigger() {
302
+ // Taller M3 tab target; the indicator hugs the bottom edge.
303
+ return {
304
+ flexDirection: "row",
305
+ alignItems: "center",
306
+ justifyContent: "center",
307
+ gap: 6,
308
+ paddingHorizontal: 16,
309
+ paddingVertical: 12,
310
+ };
311
+ },
312
+ // M3 indicator: a 3px brand `primary` bar with a slight top rounding under
313
+ // the active tab.
314
+ underlineIndicator(tokens, selected) {
315
+ return {
316
+ position: "absolute",
317
+ bottom: 0,
318
+ left: 0,
319
+ right: 0,
320
+ height: 3,
321
+ borderTopLeftRadius: 3,
322
+ borderTopRightRadius: 3,
323
+ backgroundColor: selected ? tokens.primary : "transparent",
324
+ };
325
+ },
326
+ underlineLabel(tokens, selected) {
327
+ // M3 titleSmall ~14sp; active label carries the brand indigo, inactive muted.
328
+ return { fontSize: 14, lineHeight: 20, fontWeight: "500", color: selected ? tokens.primary : tokens["muted-foreground"] };
329
+ },
330
+
331
+ // --- pills (M3 keeps the muted-track + tonal selected fill) ---
332
+ pillsRow(tokens) {
333
+ return {
334
+ flexDirection: "row",
335
+ alignItems: "center",
336
+ gap: 4,
337
+ alignSelf: "flex-start",
338
+ borderRadius: 9999,
339
+ backgroundColor: tokens.muted,
340
+ padding: 4,
341
+ };
342
+ },
343
+ pillsTrigger() {
344
+ return {
345
+ flexDirection: "row",
346
+ alignItems: "center",
347
+ justifyContent: "center",
348
+ gap: 6,
349
+ borderRadius: 9999,
350
+ paddingHorizontal: 14,
351
+ paddingVertical: 7,
352
+ };
353
+ },
354
+ pillsFill(tokens, selected) {
355
+ // Tonal selected fill (secondaryContainer ~ alpha(primary, .12)).
356
+ return selected ? { backgroundColor: alpha(tokens.primary, 0.12) } : { backgroundColor: "transparent" };
357
+ },
358
+ pillsLabel(tokens, selected) {
359
+ return { fontSize: 14, lineHeight: 20, fontWeight: "500", color: selected ? tokens.primary : tokens["muted-foreground"] };
360
+ },
361
+
362
+ // --- vertical (M3 navigation rail row; active item is a tonal pill) ---
363
+ verticalTrigger() {
364
+ return {
365
+ width: "100%",
366
+ flexDirection: "row",
367
+ alignItems: "center",
368
+ gap: 6,
369
+ borderRadius: 9999,
370
+ paddingHorizontal: 14,
371
+ paddingVertical: 9,
372
+ };
373
+ },
374
+ verticalFill(tokens, selected) {
375
+ return { backgroundColor: selected ? alpha(tokens.primary, 0.12) : "transparent" };
376
+ },
377
+ verticalLabel(tokens, selected) {
378
+ return { fontSize: 14, lineHeight: 20, fontWeight: "500", color: selected ? tokens.primary : tokens["muted-foreground"] };
379
+ },
380
+
381
+ // --- count badge ---
382
+ countBadgeBox(tokens) {
383
+ return {
384
+ flexDirection: "row",
385
+ alignItems: "center",
386
+ alignSelf: "flex-start",
387
+ borderRadius: 9999,
388
+ borderWidth: 0,
389
+ borderColor: "transparent",
390
+ backgroundColor: tokens.secondary,
391
+ paddingHorizontal: 7,
392
+ paddingVertical: 1,
393
+ };
394
+ },
395
+ countBadgeLabel(tokens, muted) {
396
+ return { fontSize: 12, lineHeight: 16, fontWeight: "500", color: muted ? tokens["muted-foreground"] : tokens["secondary-foreground"] };
397
+ },
398
+ };
@@ -0,0 +1,6 @@
1
+ import { createTabs } from "./tabs.shared.js";
2
+ import { webSkin } from "./tabs.styles.js";
3
+
4
+ // Web Tabs (the base; Metro falls back to it on native, web bundlers resolve it).
5
+ export const Tabs = createTabs(webSkin);
6
+ export type { TabsProps, TabItem } from "./tabs.shared.js";