@ankhorage/surface 0.1.4 → 0.1.6

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 (292) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +23 -184
  3. package/dist/components/badge/Badge.js.map +1 -1
  4. package/dist/components/badge/index.js.map +1 -1
  5. package/dist/components/badge/types.js.map +1 -1
  6. package/dist/components/button/Button.js.map +1 -1
  7. package/dist/components/button/index.js.map +1 -1
  8. package/dist/components/button/types.js.map +1 -1
  9. package/dist/components/card/Card.js.map +1 -1
  10. package/dist/components/card/index.js.map +1 -1
  11. package/dist/components/card/types.js.map +1 -1
  12. package/dist/components/checkbox/Checkbox.js.map +1 -1
  13. package/dist/components/checkbox/index.js.map +1 -1
  14. package/dist/components/checkbox/types.js.map +1 -1
  15. package/dist/components/drawer/Drawer.js.map +1 -1
  16. package/dist/components/drawer/index.js.map +1 -1
  17. package/dist/components/drawer/types.js.map +1 -1
  18. package/dist/components/field/Field.js.map +1 -1
  19. package/dist/components/field/index.js.map +1 -1
  20. package/dist/components/field/types.js.map +1 -1
  21. package/dist/components/helper-text/HelperText.js.map +1 -1
  22. package/dist/components/helper-text/index.js.map +1 -1
  23. package/dist/components/helper-text/types.js.map +1 -1
  24. package/dist/components/icon-button/IconButton.js.map +1 -1
  25. package/dist/components/icon-button/index.js.map +1 -1
  26. package/dist/components/icon-button/types.js.map +1 -1
  27. package/dist/components/label/Label.js.map +1 -1
  28. package/dist/components/label/index.js.map +1 -1
  29. package/dist/components/label/types.js.map +1 -1
  30. package/dist/components/list-item/ListItem.js.map +1 -1
  31. package/dist/components/list-item/index.js.map +1 -1
  32. package/dist/components/list-item/types.js.map +1 -1
  33. package/dist/components/menu/Menu.js.map +1 -1
  34. package/dist/components/menu/index.js.map +1 -1
  35. package/dist/components/menu/navigation.js.map +1 -1
  36. package/dist/components/menu/types.js.map +1 -1
  37. package/dist/components/modal/Modal.js.map +1 -1
  38. package/dist/components/modal/index.js.map +1 -1
  39. package/dist/components/modal/types.js.map +1 -1
  40. package/dist/components/radio/Radio.js.map +1 -1
  41. package/dist/components/radio/index.js.map +1 -1
  42. package/dist/components/radio/types.js.map +1 -1
  43. package/dist/components/switch/Switch.js.map +1 -1
  44. package/dist/components/switch/index.js.map +1 -1
  45. package/dist/components/switch/types.js.map +1 -1
  46. package/dist/components/tabs/Tab.js.map +1 -1
  47. package/dist/components/tabs/TabList.js.map +1 -1
  48. package/dist/components/tabs/TabPanel.js.map +1 -1
  49. package/dist/components/tabs/Tabs.js.map +1 -1
  50. package/dist/components/tabs/a11y.js.map +1 -1
  51. package/dist/components/tabs/context.js.map +1 -1
  52. package/dist/components/tabs/index.js.map +1 -1
  53. package/dist/components/tabs/navigation.js.map +1 -1
  54. package/dist/components/tabs/types.js.map +1 -1
  55. package/dist/components/text-input/TextInput.js.map +1 -1
  56. package/dist/components/text-input/index.js.map +1 -1
  57. package/dist/components/text-input/types.js.map +1 -1
  58. package/dist/components/textarea/Textarea.js.map +1 -1
  59. package/dist/components/textarea/index.js.map +1 -1
  60. package/dist/components/textarea/types.js.map +1 -1
  61. package/dist/components/toast/Toast.js.map +1 -1
  62. package/dist/components/toast/ToastProvider.js.map +1 -1
  63. package/dist/components/toast/index.js.map +1 -1
  64. package/dist/components/toast/types.js.map +1 -1
  65. package/dist/components/tooltip/Tooltip.js.map +1 -1
  66. package/dist/components/tooltip/index.js.map +1 -1
  67. package/dist/components/tooltip/types.js.map +1 -1
  68. package/dist/context/FontContext.js.map +1 -1
  69. package/dist/context/TranslationContext.js.map +1 -1
  70. package/dist/core/responsive/ResponsiveProvider.js.map +1 -1
  71. package/dist/core/responsive/breakpoints.js.map +1 -1
  72. package/dist/core/responsive/getBreakpointFromWidth.js.map +1 -1
  73. package/dist/core/responsive/index.js.map +1 -1
  74. package/dist/core/responsive/resolve.js.map +1 -1
  75. package/dist/core/responsive/types.js.map +1 -1
  76. package/dist/core/responsive/useBreakpoint.js.map +1 -1
  77. package/dist/examples/DocsExamples.js.map +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal/focus/FocusScope.js.map +1 -1
  80. package/dist/internal/focus/useFocusManager.js.map +1 -1
  81. package/dist/internal/overlay/OverlayProvider.js.map +1 -1
  82. package/dist/internal/overlay/Portal.js.map +1 -1
  83. package/dist/internal/overlay/useOverlayStack.js.map +1 -1
  84. package/dist/internal/resolvers/index.js.map +1 -1
  85. package/dist/internal/resolvers/resolveControlSize.js.map +1 -1
  86. package/dist/internal/resolvers/resolveFieldPresentation.js.map +1 -1
  87. package/dist/internal/resolvers/resolveFieldState.js.map +1 -1
  88. package/dist/internal/resolvers/resolveFocusRingStyles.js.map +1 -1
  89. package/dist/internal/resolvers/resolveIconSize.js.map +1 -1
  90. package/dist/internal/resolvers/resolveIndicatorSize.js.map +1 -1
  91. package/dist/internal/resolvers/resolveInteractiveColors.js.map +1 -1
  92. package/dist/internal/resolvers/resolveInteractiveState.js.map +1 -1
  93. package/dist/internal/resolvers/resolveOverlayAnimation.js.map +1 -1
  94. package/dist/internal/resolvers/resolveOverlayZIndex.js.map +1 -1
  95. package/dist/internal/resolvers/resolveSelectionControlBehavior.js.map +1 -1
  96. package/dist/internal/resolvers/resolveSelectionControlColors.js.map +1 -1
  97. package/dist/internal/resolvers/resolveTextColor.js.map +1 -1
  98. package/dist/internal/resolvers/resolveTextStyles.js.map +1 -1
  99. package/dist/internal/resolvers/resolveTone.js.map +1 -1
  100. package/dist/internal/useControllableState.js.map +1 -1
  101. package/dist/layout/Box.js.map +1 -1
  102. package/dist/layout/Center.js.map +1 -1
  103. package/dist/layout/Container.js.map +1 -1
  104. package/dist/layout/Divider.js.map +1 -1
  105. package/dist/layout/Grid.js.map +1 -1
  106. package/dist/layout/Inline.js.map +1 -1
  107. package/dist/layout/Show.js.map +1 -1
  108. package/dist/layout/Spacer.js.map +1 -1
  109. package/dist/layout/Stack.js.map +1 -1
  110. package/dist/layout/Surface.js.map +1 -1
  111. package/dist/layout/Template.js.map +1 -1
  112. package/dist/layout/helpers.js.map +1 -1
  113. package/dist/layout/index.js.map +1 -1
  114. package/dist/primitives/button-base/ButtonBase.js.map +1 -1
  115. package/dist/primitives/button-base/index.js.map +1 -1
  116. package/dist/primitives/button-base/types.js.map +1 -1
  117. package/dist/primitives/heading/Heading.js.map +1 -1
  118. package/dist/primitives/heading/index.js.map +1 -1
  119. package/dist/primitives/heading/resolveHeadingStyle.js.map +1 -1
  120. package/dist/primitives/heading/types.js.map +1 -1
  121. package/dist/primitives/icon/Icon.js.map +1 -1
  122. package/dist/primitives/icon/index.js.map +1 -1
  123. package/dist/primitives/icon/resolveExpoIconComponent.js.map +1 -1
  124. package/dist/primitives/text/Text.js.map +1 -1
  125. package/dist/primitives/text/index.js.map +1 -1
  126. package/dist/primitives/text/types.js.map +1 -1
  127. package/dist/theme/ThemeContext.js.map +1 -1
  128. package/dist/theme/colorEngine.js.map +1 -1
  129. package/dist/theme/createTheme.js.map +1 -1
  130. package/dist/theme/index.js.map +1 -1
  131. package/dist/theme/resolveToken.js.map +1 -1
  132. package/dist/theme/types.js.map +1 -1
  133. package/dist/utils/deepEqual.js.map +1 -1
  134. package/dist/utils/deepMerge.js.map +1 -1
  135. package/package.json +4 -1
  136. package/src/components/badge/Badge.tsx +47 -0
  137. package/src/components/badge/index.ts +2 -0
  138. package/src/components/badge/types.ts +13 -0
  139. package/src/components/button/Button.tsx +104 -0
  140. package/src/components/button/index.ts +2 -0
  141. package/src/components/button/types.ts +26 -0
  142. package/src/components/card/Card.tsx +81 -0
  143. package/src/components/card/index.ts +2 -0
  144. package/src/components/card/types.ts +11 -0
  145. package/src/components/checkbox/Checkbox.tsx +111 -0
  146. package/src/components/checkbox/index.ts +2 -0
  147. package/src/components/checkbox/types.ts +19 -0
  148. package/src/components/drawer/Drawer.tsx +92 -0
  149. package/src/components/drawer/index.ts +2 -0
  150. package/src/components/drawer/types.ts +10 -0
  151. package/src/components/field/Field.tsx +43 -0
  152. package/src/components/field/index.ts +2 -0
  153. package/src/components/field/types.ts +13 -0
  154. package/src/components/helper-text/HelperText.tsx +12 -0
  155. package/src/components/helper-text/index.ts +2 -0
  156. package/src/components/helper-text/types.ts +9 -0
  157. package/src/components/icon-button/IconButton.tsx +60 -0
  158. package/src/components/icon-button/index.ts +2 -0
  159. package/src/components/icon-button/types.ts +19 -0
  160. package/src/components/label/Label.tsx +17 -0
  161. package/src/components/label/index.ts +2 -0
  162. package/src/components/label/types.ts +10 -0
  163. package/src/components/list-item/ListItem.tsx +72 -0
  164. package/src/components/list-item/index.ts +2 -0
  165. package/src/components/list-item/types.ts +11 -0
  166. package/src/components/menu/Menu.tsx +180 -0
  167. package/src/components/menu/index.ts +2 -0
  168. package/src/components/menu/navigation.test.ts +21 -0
  169. package/src/components/menu/navigation.ts +34 -0
  170. package/src/components/menu/types.ts +16 -0
  171. package/src/components/modal/Modal.tsx +87 -0
  172. package/src/components/modal/index.ts +2 -0
  173. package/src/components/modal/types.ts +9 -0
  174. package/src/components/radio/Radio.tsx +116 -0
  175. package/src/components/radio/index.ts +2 -0
  176. package/src/components/radio/types.ts +19 -0
  177. package/src/components/switch/Switch.tsx +116 -0
  178. package/src/components/switch/index.ts +2 -0
  179. package/src/components/switch/types.ts +19 -0
  180. package/src/components/tabs/Tab.tsx +82 -0
  181. package/src/components/tabs/TabList.tsx +51 -0
  182. package/src/components/tabs/TabPanel.tsx +29 -0
  183. package/src/components/tabs/Tabs.tsx +67 -0
  184. package/src/components/tabs/a11y.test.ts +15 -0
  185. package/src/components/tabs/a11y.ts +15 -0
  186. package/src/components/tabs/context.tsx +31 -0
  187. package/src/components/tabs/index.ts +5 -0
  188. package/src/components/tabs/navigation.test.ts +21 -0
  189. package/src/components/tabs/navigation.ts +32 -0
  190. package/src/components/tabs/types.ts +27 -0
  191. package/src/components/text-input/TextInput.tsx +116 -0
  192. package/src/components/text-input/index.ts +2 -0
  193. package/src/components/text-input/types.ts +32 -0
  194. package/src/components/textarea/Textarea.tsx +15 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/types.ts +5 -0
  197. package/src/components/toast/Toast.tsx +54 -0
  198. package/src/components/toast/ToastProvider.tsx +114 -0
  199. package/src/components/toast/index.ts +3 -0
  200. package/src/components/toast/types.ts +16 -0
  201. package/src/components/tooltip/Tooltip.tsx +109 -0
  202. package/src/components/tooltip/index.ts +2 -0
  203. package/src/components/tooltip/types.ts +9 -0
  204. package/src/context/FontContext.tsx +59 -0
  205. package/src/context/TranslationContext.tsx +54 -0
  206. package/src/core/responsive/ResponsiveProvider.tsx +31 -0
  207. package/src/core/responsive/breakpoints.ts +9 -0
  208. package/src/core/responsive/getBreakpointFromWidth.test.ts +15 -0
  209. package/src/core/responsive/getBreakpointFromWidth.ts +10 -0
  210. package/src/core/responsive/index.ts +6 -0
  211. package/src/core/responsive/resolve.test.ts +25 -0
  212. package/src/core/responsive/resolve.ts +24 -0
  213. package/src/core/responsive/types.ts +10 -0
  214. package/src/core/responsive/useBreakpoint.ts +9 -0
  215. package/src/examples/DocsExamples.tsx +116 -0
  216. package/src/index.test.ts +64 -0
  217. package/src/index.ts +55 -0
  218. package/src/internal/focus/FocusScope.tsx +66 -0
  219. package/src/internal/focus/useFocusManager.test.ts +44 -0
  220. package/src/internal/focus/useFocusManager.ts +142 -0
  221. package/src/internal/overlay/OverlayProvider.tsx +74 -0
  222. package/src/internal/overlay/Portal.tsx +38 -0
  223. package/src/internal/overlay/useOverlayStack.test.ts +31 -0
  224. package/src/internal/overlay/useOverlayStack.ts +61 -0
  225. package/src/internal/resolvers/index.ts +15 -0
  226. package/src/internal/resolvers/resolveControlSize.test.ts +25 -0
  227. package/src/internal/resolvers/resolveControlSize.ts +45 -0
  228. package/src/internal/resolvers/resolveFieldPresentation.test.ts +31 -0
  229. package/src/internal/resolvers/resolveFieldPresentation.ts +30 -0
  230. package/src/internal/resolvers/resolveFieldState.test.ts +22 -0
  231. package/src/internal/resolvers/resolveFieldState.ts +36 -0
  232. package/src/internal/resolvers/resolveFocusRingStyles.ts +14 -0
  233. package/src/internal/resolvers/resolveIconSize.ts +6 -0
  234. package/src/internal/resolvers/resolveIndicatorSize.test.ts +19 -0
  235. package/src/internal/resolvers/resolveIndicatorSize.ts +47 -0
  236. package/src/internal/resolvers/resolveInteractiveColors.test.ts +57 -0
  237. package/src/internal/resolvers/resolveInteractiveColors.ts +134 -0
  238. package/src/internal/resolvers/resolveInteractiveState.test.ts +14 -0
  239. package/src/internal/resolvers/resolveInteractiveState.ts +15 -0
  240. package/src/internal/resolvers/resolveOverlayAnimation.test.ts +15 -0
  241. package/src/internal/resolvers/resolveOverlayAnimation.ts +24 -0
  242. package/src/internal/resolvers/resolveOverlayZIndex.test.ts +15 -0
  243. package/src/internal/resolvers/resolveOverlayZIndex.ts +13 -0
  244. package/src/internal/resolvers/resolveSelectionControlBehavior.test.ts +52 -0
  245. package/src/internal/resolvers/resolveSelectionControlBehavior.ts +23 -0
  246. package/src/internal/resolvers/resolveSelectionControlColors.test.ts +44 -0
  247. package/src/internal/resolvers/resolveSelectionControlColors.ts +81 -0
  248. package/src/internal/resolvers/resolveTextColor.test.ts +23 -0
  249. package/src/internal/resolvers/resolveTextColor.ts +40 -0
  250. package/src/internal/resolvers/resolveTextStyles.test.ts +27 -0
  251. package/src/internal/resolvers/resolveTextStyles.ts +95 -0
  252. package/src/internal/resolvers/resolveTone.ts +19 -0
  253. package/src/internal/useControllableState.ts +28 -0
  254. package/src/layout/Box.tsx +79 -0
  255. package/src/layout/Center.tsx +22 -0
  256. package/src/layout/Container.tsx +43 -0
  257. package/src/layout/Divider.tsx +26 -0
  258. package/src/layout/Grid.tsx +83 -0
  259. package/src/layout/Inline.tsx +9 -0
  260. package/src/layout/Show.tsx +15 -0
  261. package/src/layout/Spacer.tsx +22 -0
  262. package/src/layout/Stack.tsx +67 -0
  263. package/src/layout/Surface.tsx +70 -0
  264. package/src/layout/Template.tsx +85 -0
  265. package/src/layout/helpers.test.ts +71 -0
  266. package/src/layout/helpers.ts +208 -0
  267. package/src/layout/index.ts +22 -0
  268. package/src/primitives/button-base/ButtonBase.tsx +81 -0
  269. package/src/primitives/button-base/index.ts +2 -0
  270. package/src/primitives/button-base/types.ts +16 -0
  271. package/src/primitives/heading/Heading.tsx +60 -0
  272. package/src/primitives/heading/index.ts +2 -0
  273. package/src/primitives/heading/resolveHeadingStyle.test.ts +31 -0
  274. package/src/primitives/heading/resolveHeadingStyle.ts +17 -0
  275. package/src/primitives/heading/types.ts +13 -0
  276. package/src/primitives/icon/Icon.tsx +40 -0
  277. package/src/primitives/icon/index.ts +2 -0
  278. package/src/primitives/icon/resolveExpoIconComponent.test.ts +29 -0
  279. package/src/primitives/icon/resolveExpoIconComponent.ts +20 -0
  280. package/src/primitives/text/Text.tsx +66 -0
  281. package/src/primitives/text/index.ts +2 -0
  282. package/src/primitives/text/types.ts +18 -0
  283. package/src/theme/ThemeContext.tsx +95 -0
  284. package/src/theme/colorEngine.test.ts +114 -0
  285. package/src/theme/colorEngine.ts +480 -0
  286. package/src/theme/createTheme.ts +121 -0
  287. package/src/theme/index.ts +5 -0
  288. package/src/theme/resolveToken.ts +32 -0
  289. package/src/theme/types.ts +188 -0
  290. package/src/utils/deepEqual.ts +34 -0
  291. package/src/utils/deepMerge.test.ts +117 -0
  292. package/src/utils/deepMerge.ts +29 -0
@@ -0,0 +1,134 @@
1
+ import type { AnkhTheme } from '../../theme/types';
2
+ import type { FieldState } from './resolveFieldState';
3
+ import type { InteractionState } from './resolveInteractiveState';
4
+ import { type ComponentTone, resolveTone } from './resolveTone';
5
+
6
+ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'soft';
7
+
8
+ export interface ResolvedInteractiveColors {
9
+ backgroundColor: string;
10
+ borderColor: string;
11
+ contentColor: string;
12
+ opacity?: number;
13
+ }
14
+
15
+ export function resolveButtonColors(
16
+ theme: AnkhTheme,
17
+ {
18
+ variant,
19
+ tone,
20
+ state,
21
+ }: {
22
+ variant: ButtonVariant;
23
+ tone: ComponentTone;
24
+ state: InteractionState;
25
+ },
26
+ ): ResolvedInteractiveColors {
27
+ if (state.disabled) {
28
+ return {
29
+ backgroundColor: theme.semantics.neutral.surface,
30
+ borderColor: theme.semantics.neutral.divider,
31
+ contentColor: theme.semantics.content.muted,
32
+ opacity: 0.72,
33
+ };
34
+ }
35
+
36
+ const semanticTone = resolveTone(theme, tone);
37
+
38
+ switch (variant) {
39
+ case 'outline':
40
+ return {
41
+ backgroundColor: state.pressed
42
+ ? semanticTone.softActive
43
+ : state.hovered
44
+ ? semanticTone.softHover
45
+ : 'transparent',
46
+ borderColor: semanticTone.outline,
47
+ contentColor: semanticTone.base,
48
+ };
49
+ case 'ghost':
50
+ return {
51
+ backgroundColor: state.pressed
52
+ ? semanticTone.softActive
53
+ : state.hovered
54
+ ? semanticTone.softHover
55
+ : 'transparent',
56
+ borderColor: 'transparent',
57
+ contentColor: semanticTone.base,
58
+ };
59
+ case 'soft':
60
+ return {
61
+ backgroundColor: state.pressed
62
+ ? semanticTone.softActive
63
+ : state.hovered
64
+ ? semanticTone.softHover
65
+ : semanticTone.softBg,
66
+ borderColor: 'transparent',
67
+ contentColor: semanticTone.base,
68
+ };
69
+ case 'solid':
70
+ default:
71
+ return {
72
+ backgroundColor: state.pressed
73
+ ? semanticTone.strong
74
+ : state.hovered
75
+ ? semanticTone.hover
76
+ : semanticTone.base,
77
+ borderColor: semanticTone.base,
78
+ contentColor: semanticTone.onSolidText,
79
+ };
80
+ }
81
+ }
82
+
83
+ export function resolveInputColors(
84
+ theme: AnkhTheme,
85
+ fieldState: FieldState,
86
+ ): ResolvedInteractiveColors & { placeholderColor: string } {
87
+ if (fieldState.disabled) {
88
+ return {
89
+ backgroundColor: theme.semantics.surface.subtle,
90
+ borderColor: theme.semantics.border.default,
91
+ contentColor: theme.semantics.content.muted,
92
+ placeholderColor: theme.semantics.content.subtle,
93
+ opacity: 0.72,
94
+ };
95
+ }
96
+
97
+ if (fieldState.invalid) {
98
+ return {
99
+ backgroundColor: fieldState.readOnly
100
+ ? theme.semantics.surface.subtle
101
+ : theme.semantics.surface.default,
102
+ borderColor: fieldState.focused
103
+ ? theme.semantics.danger.base
104
+ : theme.semantics.danger.outline,
105
+ contentColor: theme.semantics.content.default,
106
+ placeholderColor: theme.semantics.content.muted,
107
+ };
108
+ }
109
+
110
+ if (fieldState.focused) {
111
+ return {
112
+ backgroundColor: theme.semantics.surface.default,
113
+ borderColor: theme.semantics.border.focus,
114
+ contentColor: theme.semantics.content.default,
115
+ placeholderColor: theme.semantics.content.muted,
116
+ };
117
+ }
118
+
119
+ if (fieldState.readOnly) {
120
+ return {
121
+ backgroundColor: theme.semantics.surface.subtle,
122
+ borderColor: theme.semantics.border.default,
123
+ contentColor: theme.semantics.content.default,
124
+ placeholderColor: theme.semantics.content.muted,
125
+ };
126
+ }
127
+
128
+ return {
129
+ backgroundColor: theme.semantics.surface.default,
130
+ borderColor: theme.semantics.border.default,
131
+ contentColor: theme.semantics.content.default,
132
+ placeholderColor: theme.semantics.content.muted,
133
+ };
134
+ }
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { resolveInteractiveState } from './resolveInteractiveState';
4
+
5
+ describe('resolveInteractiveState', () => {
6
+ it('freezes the interaction state shape to booleans', () => {
7
+ expect(resolveInteractiveState({ hovered: true, pressed: 1 as never })).toEqual({
8
+ pressed: true,
9
+ hovered: true,
10
+ focused: false,
11
+ disabled: false,
12
+ });
13
+ });
14
+ });
@@ -0,0 +1,15 @@
1
+ export interface InteractionState {
2
+ pressed: boolean;
3
+ hovered: boolean;
4
+ focused: boolean;
5
+ disabled: boolean;
6
+ }
7
+
8
+ export function resolveInteractiveState(input: Partial<InteractionState>): InteractionState {
9
+ return {
10
+ pressed: Boolean(input.pressed),
11
+ hovered: Boolean(input.hovered),
12
+ focused: Boolean(input.focused),
13
+ disabled: Boolean(input.disabled),
14
+ };
15
+ }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { resolveOverlayAnimation } from './resolveOverlayAnimation';
4
+
5
+ describe('resolveOverlayAnimation', () => {
6
+ it('returns stable transition values for each overlay layer', () => {
7
+ const modal = resolveOverlayAnimation('modal');
8
+ const drawer = resolveOverlayAnimation('drawer');
9
+ const tooltip = resolveOverlayAnimation('tooltip');
10
+
11
+ expect(modal.backdropOpacity).toBeGreaterThan(drawer.backdropOpacity);
12
+ expect(drawer.offset).toBeGreaterThan(tooltip.offset);
13
+ expect(tooltip.enterDuration).toBeLessThan(modal.enterDuration);
14
+ });
15
+ });
@@ -0,0 +1,24 @@
1
+ import type { OverlayLayer } from './resolveOverlayZIndex';
2
+
3
+ export interface OverlayAnimationValues {
4
+ enterDuration: number;
5
+ exitDuration: number;
6
+ backdropOpacity: number;
7
+ offset: number;
8
+ }
9
+
10
+ export function resolveOverlayAnimation(layer: OverlayLayer): OverlayAnimationValues {
11
+ switch (layer) {
12
+ case 'drawer':
13
+ return { enterDuration: 180, exitDuration: 140, backdropOpacity: 0.26, offset: 24 };
14
+ case 'tooltip':
15
+ return { enterDuration: 90, exitDuration: 90, backdropOpacity: 0, offset: 8 };
16
+ case 'toast':
17
+ return { enterDuration: 140, exitDuration: 120, backdropOpacity: 0, offset: 12 };
18
+ case 'menu':
19
+ return { enterDuration: 110, exitDuration: 110, backdropOpacity: 0.04, offset: 6 };
20
+ case 'modal':
21
+ default:
22
+ return { enterDuration: 160, exitDuration: 140, backdropOpacity: 0.32, offset: 12 };
23
+ }
24
+ }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { resolveOverlayZIndex } from './resolveOverlayZIndex';
4
+
5
+ describe('resolveOverlayZIndex', () => {
6
+ it('assigns higher base layers to later overlay types', () => {
7
+ expect(resolveOverlayZIndex('modal')).toBeLessThan(resolveOverlayZIndex('menu'));
8
+ expect(resolveOverlayZIndex('menu')).toBeLessThan(resolveOverlayZIndex('tooltip'));
9
+ expect(resolveOverlayZIndex('tooltip')).toBeLessThan(resolveOverlayZIndex('toast'));
10
+ });
11
+
12
+ it('increments the z-index within the same layer stack', () => {
13
+ expect(resolveOverlayZIndex('modal', 1)).toBe(resolveOverlayZIndex('modal', 0) + 1);
14
+ });
15
+ });
@@ -0,0 +1,13 @@
1
+ export type OverlayLayer = 'modal' | 'drawer' | 'menu' | 'tooltip' | 'toast';
2
+
3
+ const BASE_Z_INDEX: Record<OverlayLayer, number> = {
4
+ modal: 1000,
5
+ drawer: 1100,
6
+ menu: 1200,
7
+ tooltip: 1300,
8
+ toast: 1400,
9
+ };
10
+
11
+ export function resolveOverlayZIndex(layer: OverlayLayer, stackIndex = 0): number {
12
+ return BASE_Z_INDEX[layer] + stackIndex;
13
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { resolveSelectionControlNextChecked } from './resolveSelectionControlBehavior';
4
+
5
+ describe('resolveSelectionControlNextChecked', () => {
6
+ it('toggles checkbox and switch values when interactive', () => {
7
+ expect(
8
+ resolveSelectionControlNextChecked({
9
+ checked: false,
10
+ kind: 'checkbox',
11
+ }),
12
+ ).toBe(true);
13
+ expect(
14
+ resolveSelectionControlNextChecked({
15
+ checked: true,
16
+ kind: 'switch',
17
+ }),
18
+ ).toBe(false);
19
+ });
20
+
21
+ it('allows radio selection once but not repeat activation', () => {
22
+ expect(
23
+ resolveSelectionControlNextChecked({
24
+ checked: false,
25
+ kind: 'radio',
26
+ }),
27
+ ).toBe(true);
28
+ expect(
29
+ resolveSelectionControlNextChecked({
30
+ checked: true,
31
+ kind: 'radio',
32
+ }),
33
+ ).toBeNull();
34
+ });
35
+
36
+ it('keeps read-only and disabled controls focusable but non-mutating', () => {
37
+ expect(
38
+ resolveSelectionControlNextChecked({
39
+ checked: false,
40
+ kind: 'checkbox',
41
+ readOnly: true,
42
+ }),
43
+ ).toBeNull();
44
+ expect(
45
+ resolveSelectionControlNextChecked({
46
+ checked: false,
47
+ disabled: true,
48
+ kind: 'switch',
49
+ }),
50
+ ).toBeNull();
51
+ });
52
+ });
@@ -0,0 +1,23 @@
1
+ export type SelectionControlKind = 'checkbox' | 'radio' | 'switch';
2
+
3
+ export function resolveSelectionControlNextChecked({
4
+ kind,
5
+ checked,
6
+ disabled = false,
7
+ readOnly = false,
8
+ }: {
9
+ kind: SelectionControlKind;
10
+ checked: boolean;
11
+ disabled?: boolean;
12
+ readOnly?: boolean;
13
+ }): boolean | null {
14
+ if (disabled || readOnly) {
15
+ return null;
16
+ }
17
+
18
+ if (kind === 'radio') {
19
+ return checked ? null : true;
20
+ }
21
+
22
+ return !checked;
23
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { createTheme } from '../../theme/createTheme';
4
+ import { resolveFieldState } from './resolveFieldState';
5
+ import { resolveSelectionControlColors } from './resolveSelectionControlColors';
6
+
7
+ describe('resolveSelectionControlColors', () => {
8
+ it('maps checked controls through the selected tone semantics', () => {
9
+ const theme = createTheme();
10
+ const colors = resolveSelectionControlColors(theme, {
11
+ checked: true,
12
+ fieldState: resolveFieldState({ focused: true }),
13
+ tone: 'primary',
14
+ });
15
+
16
+ expect(colors.borderColor).toBe(theme.semantics.action.primary.base);
17
+ expect(colors.indicatorColor).toBe(theme.semantics.action.primary.onSolidText);
18
+ expect(colors.labelColor).toBe(theme.semantics.content.default);
19
+ });
20
+
21
+ it('maps invalid unchecked controls through danger/border semantics', () => {
22
+ const theme = createTheme();
23
+ const colors = resolveSelectionControlColors(theme, {
24
+ checked: false,
25
+ fieldState: resolveFieldState({ invalid: true }),
26
+ tone: 'primary',
27
+ });
28
+
29
+ expect(colors.borderColor).toBe(theme.semantics.danger.outline);
30
+ expect(colors.trackColor).toBe(theme.semantics.danger.outline);
31
+ });
32
+
33
+ it('mutes disabled controls regardless of tone', () => {
34
+ const theme = createTheme();
35
+ const colors = resolveSelectionControlColors(theme, {
36
+ checked: true,
37
+ fieldState: resolveFieldState({ disabled: true }),
38
+ tone: 'danger',
39
+ });
40
+
41
+ expect(colors.labelColor).toBe(theme.semantics.content.muted);
42
+ expect(colors.opacity).toBe(0.72);
43
+ });
44
+ });
@@ -0,0 +1,81 @@
1
+ import type { AnkhTheme } from '../../theme/types';
2
+ import type { FieldState } from './resolveFieldState';
3
+ import { type ComponentTone, resolveTone } from './resolveTone';
4
+
5
+ export interface ResolvedSelectionControlColors {
6
+ backgroundColor: string;
7
+ borderColor: string;
8
+ indicatorColor: string;
9
+ labelColor: string;
10
+ trackColor: string;
11
+ thumbColor: string;
12
+ opacity?: number;
13
+ }
14
+
15
+ export function resolveSelectionControlColors(
16
+ theme: AnkhTheme,
17
+ {
18
+ tone = 'primary',
19
+ fieldState,
20
+ checked,
21
+ hovered = false,
22
+ pressed = false,
23
+ }: {
24
+ tone?: ComponentTone;
25
+ fieldState: FieldState;
26
+ checked: boolean;
27
+ hovered?: boolean;
28
+ pressed?: boolean;
29
+ },
30
+ ): ResolvedSelectionControlColors {
31
+ const semanticTone = resolveTone(theme, fieldState.invalid ? 'danger' : tone);
32
+ const isMuted = fieldState.disabled;
33
+ const isInteractiveReadOnly = fieldState.readOnly && !fieldState.disabled;
34
+
35
+ if (isMuted) {
36
+ return {
37
+ backgroundColor: checked
38
+ ? theme.semantics.neutral.surfaceActive
39
+ : theme.semantics.surface.subtle,
40
+ borderColor: theme.semantics.border.default,
41
+ indicatorColor: checked ? theme.semantics.content.muted : 'transparent',
42
+ labelColor: theme.semantics.content.muted,
43
+ trackColor: checked ? theme.semantics.neutral.surfaceActive : theme.semantics.neutral.border,
44
+ thumbColor: theme.semantics.surface.default,
45
+ opacity: 0.72,
46
+ };
47
+ }
48
+
49
+ const uncheckedBorderColor = fieldState.invalid
50
+ ? theme.semantics.danger.outline
51
+ : fieldState.focused
52
+ ? theme.semantics.border.focus
53
+ : theme.semantics.border.strong;
54
+ const uncheckedBackgroundColor = pressed
55
+ ? theme.semantics.neutral.surfaceActive
56
+ : hovered
57
+ ? theme.semantics.neutral.surfaceHover
58
+ : theme.semantics.surface.default;
59
+ const checkedBackgroundColor = pressed
60
+ ? semanticTone.strong
61
+ : hovered
62
+ ? semanticTone.hover
63
+ : semanticTone.base;
64
+ const checkedTrackColor = pressed
65
+ ? semanticTone.strong
66
+ : hovered
67
+ ? semanticTone.hover
68
+ : semanticTone.base;
69
+
70
+ return {
71
+ backgroundColor: checked ? checkedBackgroundColor : uncheckedBackgroundColor,
72
+ borderColor: checked ? semanticTone.base : uncheckedBorderColor,
73
+ indicatorColor: checked ? semanticTone.onSolidText : 'transparent',
74
+ labelColor: isInteractiveReadOnly
75
+ ? theme.semantics.content.muted
76
+ : theme.semantics.content.default,
77
+ trackColor: checked ? checkedTrackColor : uncheckedBorderColor,
78
+ thumbColor: checked ? semanticTone.onSolidText : theme.semantics.surface.default,
79
+ opacity: isInteractiveReadOnly ? 0.88 : 1,
80
+ };
81
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { createTheme } from '../../theme/createTheme';
4
+ import { resolveTextColor } from './resolveTextColor';
5
+
6
+ describe('resolveTextColor', () => {
7
+ it('maps semantic text tones through content aliases', () => {
8
+ const theme = createTheme();
9
+
10
+ expect(resolveTextColor(theme, 'default')).toBe(theme.semantics.content.default);
11
+ expect(resolveTextColor(theme, 'muted')).toBe(theme.semantics.content.muted);
12
+ expect(resolveTextColor(theme, 'subtle')).toBe(theme.semantics.content.subtle);
13
+ expect(resolveTextColor(theme, 'inverse')).toBe(theme.semantics.content.inverse);
14
+ });
15
+
16
+ it('supports semantic status tones for shared field and messaging copy', () => {
17
+ const theme = createTheme();
18
+
19
+ expect(resolveTextColor(theme, 'danger')).toBe(theme.semantics.danger.base);
20
+ expect(resolveTextColor(theme, 'success')).toBe(theme.semantics.success.base);
21
+ expect(resolveTextColor(theme, 'warning')).toBe(theme.semantics.warning.base);
22
+ });
23
+ });
@@ -0,0 +1,40 @@
1
+ import { resolveToken } from '../../theme/resolveToken';
2
+ import type { AnkhTheme } from '../../theme/types';
3
+
4
+ export type TextTone =
5
+ | 'default'
6
+ | 'muted'
7
+ | 'subtle'
8
+ | 'inverse'
9
+ | 'danger'
10
+ | 'success'
11
+ | 'warning';
12
+ export type TextColorValue = keyof AnkhTheme['colors'] | string;
13
+
14
+ export function resolveTextColor(
15
+ theme: AnkhTheme,
16
+ tone: TextTone = 'default',
17
+ color?: TextColorValue,
18
+ ): string {
19
+ if (color) {
20
+ return resolveToken(theme.colors, color);
21
+ }
22
+
23
+ switch (tone) {
24
+ case 'muted':
25
+ return theme.semantics.content.muted;
26
+ case 'subtle':
27
+ return theme.semantics.content.subtle;
28
+ case 'inverse':
29
+ return theme.semantics.content.inverse;
30
+ case 'danger':
31
+ return theme.semantics.danger.base;
32
+ case 'success':
33
+ return theme.semantics.success.base;
34
+ case 'warning':
35
+ return theme.semantics.warning.base;
36
+ case 'default':
37
+ default:
38
+ return theme.semantics.content.default;
39
+ }
40
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { createTheme } from '../../theme/createTheme';
4
+ import { resolveTextStyles } from './resolveTextStyles';
5
+
6
+ describe('resolveTextStyles', () => {
7
+ it('maps body and label variants to theme-driven typography', () => {
8
+ const theme = createTheme();
9
+
10
+ const body = resolveTextStyles(theme, { variant: 'body' });
11
+ const label = resolveTextStyles(theme, { variant: 'label' });
12
+
13
+ expect(body.fontSize).toBe(theme.typography.sizes.m);
14
+ expect(body.lineHeight).toBe(24);
15
+ expect(label.fontSize).toBe(theme.typography.sizes.s);
16
+ expect(label.fontWeight).toBe(theme.typography.weights.medium);
17
+ });
18
+
19
+ it('resolves heading levels through the shared text resolver', () => {
20
+ const theme = createTheme(undefined, 'light', 'space grotesk');
21
+ const style = resolveTextStyles(theme, { level: 1 });
22
+
23
+ expect(style.fontSize).toBe(theme.typography.headings[1].size);
24
+ expect(style.fontWeight).toBe(theme.typography.weights.bold);
25
+ expect(style.fontFamily).toBe('SpaceGrotesk_700Regular');
26
+ });
27
+ });
@@ -0,0 +1,95 @@
1
+ import type { TextStyle } from 'react-native';
2
+
3
+ import type { AnkhTheme, FontWeight } from '../../theme/types';
4
+
5
+ export type TextVariant = 'body' | 'bodySmall' | 'caption' | 'label' | 'mono';
6
+ export type TextWeight = keyof AnkhTheme['typography']['weights'] | FontWeight;
7
+
8
+ interface VariantStyle {
9
+ fontSize: number;
10
+ lineHeight: number;
11
+ weight: TextWeight;
12
+ }
13
+
14
+ export interface ResolveTextStyleOptions {
15
+ variant?: TextVariant;
16
+ align?: TextStyle['textAlign'];
17
+ weight?: TextWeight;
18
+ italic?: boolean;
19
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
20
+ }
21
+
22
+ function resolveWeight(theme: AnkhTheme, value: TextWeight | undefined): FontWeight {
23
+ if (!value) return theme.typography.weights.regular;
24
+ if (value in theme.typography.weights) {
25
+ return theme.typography.weights[value as keyof AnkhTheme['typography']['weights']];
26
+ }
27
+ return value as FontWeight;
28
+ }
29
+
30
+ function getVariantStyle(theme: AnkhTheme, variant: TextVariant): VariantStyle {
31
+ switch (variant) {
32
+ case 'bodySmall':
33
+ return {
34
+ fontSize: theme.typography.sizes.s,
35
+ lineHeight: 20,
36
+ weight: 'regular',
37
+ };
38
+ case 'caption':
39
+ return {
40
+ fontSize: theme.typography.sizes.xs,
41
+ lineHeight: 16,
42
+ weight: 'regular',
43
+ };
44
+ case 'label':
45
+ return {
46
+ fontSize: theme.typography.sizes.s,
47
+ lineHeight: 18,
48
+ weight: 'medium',
49
+ };
50
+ case 'mono':
51
+ return {
52
+ fontSize: theme.typography.sizes.s,
53
+ lineHeight: 20,
54
+ weight: 'regular',
55
+ };
56
+ case 'body':
57
+ default:
58
+ return {
59
+ fontSize: theme.typography.sizes.m,
60
+ lineHeight: 24,
61
+ weight: 'regular',
62
+ };
63
+ }
64
+ }
65
+
66
+ export function resolveTextStyles(
67
+ theme: AnkhTheme,
68
+ options: ResolveTextStyleOptions = {},
69
+ ): TextStyle {
70
+ const { level, variant = 'body', align, weight, italic = false } = options;
71
+ const headingTypography = level ? theme.typography.headings[level] : null;
72
+ const variantStyle = headingTypography
73
+ ? {
74
+ fontSize: headingTypography.size,
75
+ lineHeight: headingTypography.lineHeight,
76
+ weight: headingTypography.weight,
77
+ }
78
+ : getVariantStyle(theme, variant);
79
+
80
+ const resolvedWeight = resolveWeight(theme, weight ?? variantStyle.weight);
81
+ const isMono = !level && variant === 'mono';
82
+ const fontFamily = isMono
83
+ ? 'monospace'
84
+ : theme.typography.fonts[italic ? 'italic' : 'normal'][resolvedWeight];
85
+
86
+ return {
87
+ fontSize: variantStyle.fontSize,
88
+ lineHeight: variantStyle.lineHeight,
89
+ fontWeight: resolvedWeight,
90
+ fontFamily,
91
+ fontStyle: italic ? 'italic' : 'normal',
92
+ textAlign: align,
93
+ elevation: 0,
94
+ };
95
+ }
@@ -0,0 +1,19 @@
1
+ import type { AnkhTheme, RoleSemantics } from '../../theme/types';
2
+
3
+ export type ComponentTone = 'primary' | 'neutral' | 'danger' | 'success' | 'warning';
4
+
5
+ export function resolveTone(theme: AnkhTheme, tone: ComponentTone = 'primary'): RoleSemantics {
6
+ switch (tone) {
7
+ case 'neutral':
8
+ return theme.semantics.action.neutral;
9
+ case 'danger':
10
+ return theme.semantics.action.danger;
11
+ case 'success':
12
+ return theme.semantics.success;
13
+ case 'warning':
14
+ return theme.semantics.warning;
15
+ case 'primary':
16
+ default:
17
+ return theme.semantics.action.primary;
18
+ }
19
+ }
@@ -0,0 +1,28 @@
1
+ import React from 'react';
2
+
3
+ export function useControllableState<T>({
4
+ value,
5
+ defaultValue,
6
+ onChange,
7
+ }: {
8
+ value: T | undefined;
9
+ defaultValue: T;
10
+ onChange?: ((nextValue: T) => void) | undefined;
11
+ }) {
12
+ const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
13
+ const isControlled = value !== undefined;
14
+ const currentValue = isControlled ? value : uncontrolledValue;
15
+
16
+ const setValue = React.useCallback(
17
+ (nextValue: T) => {
18
+ if (!isControlled) {
19
+ setUncontrolledValue(nextValue);
20
+ }
21
+
22
+ onChange?.(nextValue);
23
+ },
24
+ [isControlled, onChange],
25
+ );
26
+
27
+ return [currentValue, setValue] as const;
28
+ }