@delacour/react-native-ui 0.1.0-alpha.3

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 (273) hide show
  1. package/README.md +113 -0
  2. package/package.json +125 -0
  3. package/src/components/accordion/AGENTS.md +192 -0
  4. package/src/components/accordion/accordion-content.tsx +125 -0
  5. package/src/components/accordion/accordion-description.tsx +17 -0
  6. package/src/components/accordion/accordion-indicator.tsx +104 -0
  7. package/src/components/accordion/accordion-item.tsx +78 -0
  8. package/src/components/accordion/accordion-title.tsx +18 -0
  9. package/src/components/accordion/accordion-trigger.tsx +148 -0
  10. package/src/components/accordion/accordion.context.tsx +153 -0
  11. package/src/components/accordion/accordion.tsx +253 -0
  12. package/src/components/accordion/accordion.types.ts +11 -0
  13. package/src/components/accordion/accordion.variants.test.ts +435 -0
  14. package/src/components/accordion/accordion.variants.ts +358 -0
  15. package/src/components/accordion/index.ts +45 -0
  16. package/src/components/badge/AGENTS.md +83 -0
  17. package/src/components/badge/badge-close-button.tsx +48 -0
  18. package/src/components/badge/badge-end-content.tsx +15 -0
  19. package/src/components/badge/badge-label.tsx +24 -0
  20. package/src/components/badge/badge-start-content.tsx +16 -0
  21. package/src/components/badge/badge.context.tsx +64 -0
  22. package/src/components/badge/badge.tsx +192 -0
  23. package/src/components/badge/badge.types.ts +10 -0
  24. package/src/components/badge/badge.variants.test.ts +269 -0
  25. package/src/components/badge/badge.variants.ts +238 -0
  26. package/src/components/badge/index.ts +17 -0
  27. package/src/components/bottom-sheet/AGENTS.md +232 -0
  28. package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
  29. package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
  30. package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
  31. package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
  32. package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
  33. package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
  34. package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
  35. package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
  36. package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
  37. package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
  38. package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
  39. package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
  40. package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
  41. package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
  42. package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
  43. package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
  44. package/src/components/bottom-sheet/index.ts +40 -0
  45. package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
  46. package/src/components/button/AGENTS.md +217 -0
  47. package/src/components/button/button-end-content.tsx +15 -0
  48. package/src/components/button/button-group-separator.tsx +37 -0
  49. package/src/components/button/button-group-text.tsx +101 -0
  50. package/src/components/button/button-group.tsx +158 -0
  51. package/src/components/button/button-label.tsx +24 -0
  52. package/src/components/button/button-start-content.tsx +15 -0
  53. package/src/components/button/button.context.tsx +187 -0
  54. package/src/components/button/button.tsx +273 -0
  55. package/src/components/button/button.types.ts +10 -0
  56. package/src/components/button/button.variants.test.ts +811 -0
  57. package/src/components/button/button.variants.ts +480 -0
  58. package/src/components/button/index.ts +50 -0
  59. package/src/components/chart/AGENTS.md +210 -0
  60. package/src/components/chart/chart-area.tsx +93 -0
  61. package/src/components/chart/chart-bar.tsx +103 -0
  62. package/src/components/chart/chart-bars.tsx +75 -0
  63. package/src/components/chart/chart-candlestick.tsx +60 -0
  64. package/src/components/chart/chart-grid.tsx +33 -0
  65. package/src/components/chart/chart-legend.tsx +41 -0
  66. package/src/components/chart/chart-line.tsx +50 -0
  67. package/src/components/chart/chart-scatter.tsx +35 -0
  68. package/src/components/chart/chart-tooltip-dot.tsx +70 -0
  69. package/src/components/chart/chart-tooltip-x.tsx +121 -0
  70. package/src/components/chart/chart-tooltip-y.tsx +55 -0
  71. package/src/components/chart/chart-tooltip.tsx +133 -0
  72. package/src/components/chart/chart-x-axis.tsx +27 -0
  73. package/src/components/chart/chart-y-axis.tsx +21 -0
  74. package/src/components/chart/chart.context.tsx +127 -0
  75. package/src/components/chart/chart.tsx +448 -0
  76. package/src/components/chart/chart.types.ts +108 -0
  77. package/src/components/chart/chart.variants.test.ts +893 -0
  78. package/src/components/chart/chart.variants.ts +726 -0
  79. package/src/components/chart/index.ts +103 -0
  80. package/src/components/chart/pie-chart-center.tsx +40 -0
  81. package/src/components/chart/pie-chart-label.tsx +56 -0
  82. package/src/components/chart/pie-chart-slice.tsx +45 -0
  83. package/src/components/chart/pie-chart-tooltip.tsx +85 -0
  84. package/src/components/chart/pie-chart.context.tsx +77 -0
  85. package/src/components/chart/pie-chart.tsx +247 -0
  86. package/src/components/chart/use-chart-font.ts +25 -0
  87. package/src/components/chart/use-chart-palette.ts +38 -0
  88. package/src/components/checkbox/AGENTS.md +185 -0
  89. package/src/components/checkbox/checkbox-box.tsx +152 -0
  90. package/src/components/checkbox/checkbox-group.tsx +107 -0
  91. package/src/components/checkbox/checkbox-label.tsx +34 -0
  92. package/src/components/checkbox/checkbox.context.tsx +136 -0
  93. package/src/components/checkbox/checkbox.tsx +258 -0
  94. package/src/components/checkbox/checkbox.types.ts +14 -0
  95. package/src/components/checkbox/checkbox.variants.test.ts +634 -0
  96. package/src/components/checkbox/checkbox.variants.ts +484 -0
  97. package/src/components/checkbox/index.ts +42 -0
  98. package/src/components/field/AGENTS.md +100 -0
  99. package/src/components/field/field-content.tsx +24 -0
  100. package/src/components/field/field-description.tsx +29 -0
  101. package/src/components/field/field-error.tsx +61 -0
  102. package/src/components/field/field-group.tsx +22 -0
  103. package/src/components/field/field-label.tsx +33 -0
  104. package/src/components/field/field-legend.tsx +34 -0
  105. package/src/components/field/field-separator.tsx +81 -0
  106. package/src/components/field/field-set.tsx +22 -0
  107. package/src/components/field/field.context.tsx +78 -0
  108. package/src/components/field/field.tsx +144 -0
  109. package/src/components/field/field.types.ts +15 -0
  110. package/src/components/field/field.variants.test.ts +216 -0
  111. package/src/components/field/field.variants.ts +136 -0
  112. package/src/components/field/index.ts +21 -0
  113. package/src/components/icon/AGENTS.md +104 -0
  114. package/src/components/icon/icon.context.tsx +29 -0
  115. package/src/components/icon/icon.tsx +92 -0
  116. package/src/components/icon/icon.variants.test.ts +111 -0
  117. package/src/components/icon/icon.variants.ts +79 -0
  118. package/src/components/icon/index.ts +11 -0
  119. package/src/components/input/AGENTS.md +126 -0
  120. package/src/components/input/index.ts +27 -0
  121. package/src/components/input/input-group-decorator.tsx +84 -0
  122. package/src/components/input/input-group-prefix.tsx +16 -0
  123. package/src/components/input/input-group-suffix.tsx +15 -0
  124. package/src/components/input/input-group.tsx +140 -0
  125. package/src/components/input/input.context.tsx +87 -0
  126. package/src/components/input/input.tsx +184 -0
  127. package/src/components/input/input.types.ts +10 -0
  128. package/src/components/input/input.variants.test.ts +369 -0
  129. package/src/components/input/input.variants.ts +329 -0
  130. package/src/components/list-group/AGENTS.md +67 -0
  131. package/src/components/list-group/index.ts +20 -0
  132. package/src/components/list-group/list-group-item-content.tsx +10 -0
  133. package/src/components/list-group/list-group-item-description.tsx +12 -0
  134. package/src/components/list-group/list-group-item-prefix.tsx +30 -0
  135. package/src/components/list-group/list-group-item-suffix.tsx +55 -0
  136. package/src/components/list-group/list-group-item-title.tsx +17 -0
  137. package/src/components/list-group/list-group-item.tsx +87 -0
  138. package/src/components/list-group/list-group.context.tsx +65 -0
  139. package/src/components/list-group/list-group.tsx +116 -0
  140. package/src/components/list-group/list-group.types.ts +14 -0
  141. package/src/components/list-group/list-group.variants.test.ts +223 -0
  142. package/src/components/list-group/list-group.variants.ts +107 -0
  143. package/src/components/pressable/AGENTS.md +64 -0
  144. package/src/components/pressable/index.ts +9 -0
  145. package/src/components/pressable/pressable.tsx +256 -0
  146. package/src/components/pressable/pressable.variants.test.ts +99 -0
  147. package/src/components/pressable/pressable.variants.ts +63 -0
  148. package/src/components/provider/AGENTS.md +90 -0
  149. package/src/components/provider/index.ts +1 -0
  150. package/src/components/provider/provider.tsx +105 -0
  151. package/src/components/radio/AGENTS.md +247 -0
  152. package/src/components/radio/index.ts +35 -0
  153. package/src/components/radio/radio-group.tsx +126 -0
  154. package/src/components/radio/radio-indicator.tsx +91 -0
  155. package/src/components/radio/radio-label.tsx +39 -0
  156. package/src/components/radio/radio.context.tsx +132 -0
  157. package/src/components/radio/radio.tsx +215 -0
  158. package/src/components/radio/radio.variants.test.ts +581 -0
  159. package/src/components/radio/radio.variants.ts +272 -0
  160. package/src/components/screen/AGENTS.md +287 -0
  161. package/src/components/screen/index.ts +85 -0
  162. package/src/components/screen/screen-chat-list.tsx +485 -0
  163. package/src/components/screen/screen-content.tsx +69 -0
  164. package/src/components/screen/screen-debug.ts +43 -0
  165. package/src/components/screen/screen-error.tsx +61 -0
  166. package/src/components/screen/screen-flat-list.tsx +88 -0
  167. package/src/components/screen/screen-footer-background.tsx +60 -0
  168. package/src/components/screen/screen-footer.tsx +170 -0
  169. package/src/components/screen/screen-header.tsx +36 -0
  170. package/src/components/screen/screen-legend-list.tsx +105 -0
  171. package/src/components/screen/screen-list-component.tsx +23 -0
  172. package/src/components/screen/screen-loading.tsx +50 -0
  173. package/src/components/screen/screen-navbar-back-button.tsx +79 -0
  174. package/src/components/screen/screen-navbar-background.tsx +45 -0
  175. package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
  176. package/src/components/screen/screen-navbar-title.tsx +31 -0
  177. package/src/components/screen/screen-navbar.tsx +154 -0
  178. package/src/components/screen/screen-root.tsx +38 -0
  179. package/src/components/screen/screen-scroll-area.tsx +116 -0
  180. package/src/components/screen/screen-scroll-shadow.tsx +186 -0
  181. package/src/components/screen/screen-section-list.tsx +81 -0
  182. package/src/components/screen/screen-view.tsx +59 -0
  183. package/src/components/screen/screen.context.tsx +167 -0
  184. package/src/components/screen/screen.tsx +114 -0
  185. package/src/components/screen/screen.types.ts +66 -0
  186. package/src/components/screen/screen.variants.test.ts +602 -0
  187. package/src/components/screen/screen.variants.ts +475 -0
  188. package/src/components/screen/use-screen-scroll-insets.ts +218 -0
  189. package/src/components/separator/AGENTS.md +30 -0
  190. package/src/components/separator/index.ts +8 -0
  191. package/src/components/separator/separator.tsx +85 -0
  192. package/src/components/slider/AGENTS.md +274 -0
  193. package/src/components/slider/index.ts +48 -0
  194. package/src/components/slider/slider-fill.tsx +68 -0
  195. package/src/components/slider/slider-output.tsx +55 -0
  196. package/src/components/slider/slider-thumb.tsx +161 -0
  197. package/src/components/slider/slider-track.tsx +233 -0
  198. package/src/components/slider/slider.context.tsx +118 -0
  199. package/src/components/slider/slider.tsx +321 -0
  200. package/src/components/slider/slider.types.ts +26 -0
  201. package/src/components/slider/slider.variants.test.ts +804 -0
  202. package/src/components/slider/slider.variants.ts +607 -0
  203. package/src/components/spinner/AGENTS.md +73 -0
  204. package/src/components/spinner/index.ts +17 -0
  205. package/src/components/spinner/spinner-arc.tsx +90 -0
  206. package/src/components/spinner/spinner-content.tsx +63 -0
  207. package/src/components/spinner/spinner.context.tsx +52 -0
  208. package/src/components/spinner/spinner.tsx +128 -0
  209. package/src/components/spinner/spinner.variants.test.ts +273 -0
  210. package/src/components/spinner/spinner.variants.ts +187 -0
  211. package/src/components/switch/AGENTS.md +213 -0
  212. package/src/components/switch/index.ts +42 -0
  213. package/src/components/switch/switch-content.tsx +111 -0
  214. package/src/components/switch/switch-end-content.tsx +18 -0
  215. package/src/components/switch/switch-start-content.tsx +20 -0
  216. package/src/components/switch/switch-thumb.tsx +102 -0
  217. package/src/components/switch/switch.context.tsx +79 -0
  218. package/src/components/switch/switch.tsx +423 -0
  219. package/src/components/switch/switch.types.ts +14 -0
  220. package/src/components/switch/switch.variants.test.ts +570 -0
  221. package/src/components/switch/switch.variants.ts +511 -0
  222. package/src/components/tabs/AGENTS.md +286 -0
  223. package/src/components/tabs/index.ts +69 -0
  224. package/src/components/tabs/tabs-content.tsx +65 -0
  225. package/src/components/tabs/tabs-indicator.tsx +97 -0
  226. package/src/components/tabs/tabs-label.tsx +62 -0
  227. package/src/components/tabs/tabs-list.tsx +139 -0
  228. package/src/components/tabs/tabs-pager.tsx +59 -0
  229. package/src/components/tabs/tabs-scroll-view.tsx +171 -0
  230. package/src/components/tabs/tabs-separator.tsx +73 -0
  231. package/src/components/tabs/tabs-trigger.tsx +210 -0
  232. package/src/components/tabs/tabs.context.tsx +294 -0
  233. package/src/components/tabs/tabs.tsx +435 -0
  234. package/src/components/tabs/tabs.types.ts +13 -0
  235. package/src/components/tabs/tabs.variants.test.ts +1020 -0
  236. package/src/components/tabs/tabs.variants.ts +672 -0
  237. package/src/components/text/AGENTS.md +95 -0
  238. package/src/components/text/index.ts +25 -0
  239. package/src/components/text/text.context.tsx +60 -0
  240. package/src/components/text/text.tsx +251 -0
  241. package/src/components/text/text.variants.test.ts +422 -0
  242. package/src/components/text/text.variants.ts +282 -0
  243. package/src/display-name.test.ts +145 -0
  244. package/src/docs.test.ts +98 -0
  245. package/src/expo/navigation-theme.tsx +58 -0
  246. package/src/hooks/use-controllable-state.ts +45 -0
  247. package/src/hooks/use-keyboard-state-sync.tsx +147 -0
  248. package/src/hooks/use-navigation-theme.ts +78 -0
  249. package/src/hooks/use-theme-color.ts +44 -0
  250. package/src/icons/central.ts +1 -0
  251. package/src/lib/cn.test.ts +136 -0
  252. package/src/lib/cn.ts +29 -0
  253. package/src/lib/color.test.ts +80 -0
  254. package/src/lib/color.ts +79 -0
  255. package/src/lib/compose-refs.test.ts +64 -0
  256. package/src/lib/compose-refs.ts +37 -0
  257. package/src/lib/keyboard-animation.test.ts +33 -0
  258. package/src/lib/keyboard-animation.ts +28 -0
  259. package/src/lib/merge-props.test.ts +82 -0
  260. package/src/lib/merge-props.ts +47 -0
  261. package/src/lib/navigation-theme.test.ts +49 -0
  262. package/src/lib/navigation-theme.ts +53 -0
  263. package/src/lib/slot.tsx +45 -0
  264. package/src/lib/tv.ts +20 -0
  265. package/src/styles/base.css +2 -0
  266. package/src/styles/geometry.test.ts +117 -0
  267. package/src/styles/index.css +3 -0
  268. package/src/styles/theme-tokens.test.ts +252 -0
  269. package/src/styles/theme.css +447 -0
  270. package/src/styles/tokens.css +132 -0
  271. package/src/styles/tokens.test.ts +181 -0
  272. package/src/styles/tokens.ts +86 -0
  273. package/src/uniwind-env.d.ts +1 -0
@@ -0,0 +1,435 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { declarationCount } from "../../styles/theme-tokens.test";
5
+ import { ICON_SIZES } from "../icon/icon.variants";
6
+ import {
7
+ ACCORDION_CONTENT_FADE,
8
+ ACCORDION_DEFAULT_SIZE,
9
+ ACCORDION_DEFAULT_VARIANT,
10
+ ACCORDION_FOREGROUND_TOKEN,
11
+ ACCORDION_GLYPH_STEP,
12
+ ACCORDION_INDICATOR_ROTATION,
13
+ ACCORDION_INDICATOR_TOKEN,
14
+ ACCORDION_SELECTION_MODES,
15
+ ACCORDION_SIZES,
16
+ ACCORDION_SPRING,
17
+ ACCORDION_UNMEASURED,
18
+ ACCORDION_VARIANTS,
19
+ accordionVariants,
20
+ isItemExpanded,
21
+ resolveAccordionItemAxes,
22
+ toExpandedList,
23
+ toggleExpandedValue,
24
+ } from "./accordion.variants";
25
+
26
+ const TOKENS_CSS = readFileSync(join(import.meta.dirname, "../../styles/tokens.css"), "utf-8");
27
+
28
+ /** The minimum a touch target may be, in points. Apple's number, and Android's. */
29
+ const MINIMUM_TARGET = 44;
30
+
31
+ /** Tailwind's spacing step, in points — `min-h-12` is twelve of them. */
32
+ const SPACING_STEP = 4;
33
+
34
+ /** A `--spacing-*` token's value in points, read from `tokens.css`. */
35
+ function spacingPx(token: string): number {
36
+ const match = TOKENS_CSS.match(new RegExp(`--spacing-${token}:\\s*(\\d+)px`));
37
+ if (!match) throw new Error(`tokens.css declares no --spacing-${token}`);
38
+ return Number(match[1]);
39
+ }
40
+
41
+ /**
42
+ * A slot's class string, with `tv`'s empty-slot `undefined` flattened.
43
+ *
44
+ * A slot that emits nothing returns `undefined` rather than `""` — correct, but
45
+ * it makes a bare `not.toMatch()` throw rather than pass.
46
+ */
47
+ function cls(value: string | undefined): string {
48
+ return value ?? "";
49
+ }
50
+
51
+ /** The points a `px-4` / `min-h-14` / `mx-3` utility resolves to. */
52
+ function utilityPx(value: string, prefix: string): number {
53
+ const match = value.match(new RegExp(`\\b${prefix}-(\\d+(?:\\.\\d+)?)\\b`));
54
+ if (!match) throw new Error(`no \`${prefix}-*\` in "${value}"`);
55
+ return Number(match[1]) * SPACING_STEP;
56
+ }
57
+
58
+ /** Every slot for one size, at the default variant, enabled. */
59
+ function slotsFor(size: (typeof ACCORDION_SIZES)[number]) {
60
+ return accordionVariants({ isDisabled: false, size, variant: ACCORDION_DEFAULT_VARIANT });
61
+ }
62
+
63
+ /** Every slot a `View` wears, for one size. A `Text` slot carries a treatment on purpose. */
64
+ function viewSlots(size: (typeof ACCORDION_SIZES)[number]): string[] {
65
+ const slots = slotsFor(size);
66
+ return [
67
+ cls(slots.root()),
68
+ cls(slots.item()),
69
+ cls(slots.trigger()),
70
+ cls(slots.triggerContent()),
71
+ cls(slots.indicator()),
72
+ cls(slots.content()),
73
+ cls(slots.contentInner()),
74
+ ];
75
+ }
76
+
77
+ describe("toggleExpandedValue — single", () => {
78
+ test("tapping a closed item opens it and closes the other", () => {
79
+ expect(toggleExpandedValue({ expanded: ["a"], isCollapsible: true, selectionMode: "single", value: "b" })).toEqual([
80
+ "b",
81
+ ]);
82
+ });
83
+
84
+ test("tapping the open one closes it while collapsible", () => {
85
+ expect(toggleExpandedValue({ expanded: ["a"], isCollapsible: true, selectionMode: "single", value: "a" })).toEqual(
86
+ []
87
+ );
88
+ });
89
+
90
+ test("tapping the open one is refused while not collapsible", () => {
91
+ // Refused, and refused by *identity*: the root skips a transition that
92
+ // returned its own input, so a rejected tap never re-renders and never
93
+ // reports an `onValueChange` for a change that did not happen.
94
+ const expanded = ["a"];
95
+ expect(toggleExpandedValue({ expanded, isCollapsible: false, selectionMode: "single", value: "a" })).toBe(expanded);
96
+ });
97
+
98
+ test("tapping a closed one still opens it while not collapsible", () => {
99
+ expect(toggleExpandedValue({ expanded: ["a"], isCollapsible: false, selectionMode: "single", value: "b" })).toEqual(
100
+ ["b"]
101
+ );
102
+ });
103
+ });
104
+
105
+ describe("toggleExpandedValue — multiple", () => {
106
+ test("tapping a closed item appends it, leaving the rest open", () => {
107
+ expect(
108
+ toggleExpandedValue({ expanded: ["a"], isCollapsible: true, selectionMode: "multiple", value: "b" })
109
+ ).toEqual(["a", "b"]);
110
+ });
111
+
112
+ test("tapping an open item removes it", () => {
113
+ expect(
114
+ toggleExpandedValue({ expanded: ["a", "b"], isCollapsible: true, selectionMode: "multiple", value: "a" })
115
+ ).toEqual(["b"]);
116
+ });
117
+
118
+ test("an item still closes while not collapsible, as long as another stays open", () => {
119
+ // The one this component exists to get right. `isCollapsible` bounds the
120
+ // *set*, never a single item — a multiple accordion where nothing can ever
121
+ // be closed again is add-only, not "keep one open".
122
+ expect(
123
+ toggleExpandedValue({ expanded: ["a", "b"], isCollapsible: false, selectionMode: "multiple", value: "a" })
124
+ ).toEqual(["b"]);
125
+ });
126
+
127
+ test("the last open item is refused while not collapsible", () => {
128
+ const expanded = ["a"];
129
+ expect(toggleExpandedValue({ expanded, isCollapsible: false, selectionMode: "multiple", value: "a" })).toBe(
130
+ expanded
131
+ );
132
+ });
133
+
134
+ test("opening from empty works while not collapsible", () => {
135
+ expect(toggleExpandedValue({ expanded: [], isCollapsible: false, selectionMode: "multiple", value: "a" })).toEqual([
136
+ "a",
137
+ ]);
138
+ });
139
+ });
140
+
141
+ describe("toggleExpandedValue — invariants", () => {
142
+ test("it never mutates its input", () => {
143
+ for (const selectionMode of ACCORDION_SELECTION_MODES) {
144
+ for (const isCollapsible of [true, false]) {
145
+ const expanded = ["a", "b"];
146
+ toggleExpandedValue({ expanded, isCollapsible, selectionMode, value: "a" });
147
+ toggleExpandedValue({ expanded, isCollapsible, selectionMode, value: "c" });
148
+ expect(expanded).toEqual(["a", "b"]);
149
+ }
150
+ }
151
+ });
152
+
153
+ test("a real change is always a new array", () => {
154
+ // React bails out of a re-render on an unchanged reference, so a mutation
155
+ // would flip the state while leaving the screen alone. `toggleCheckedValue`
156
+ // carries the same rule.
157
+ for (const selectionMode of ACCORDION_SELECTION_MODES) {
158
+ const expanded = ["a"];
159
+ const next = toggleExpandedValue({ expanded, isCollapsible: true, selectionMode, value: "b" });
160
+ expect(next).not.toBe(expanded);
161
+ }
162
+ });
163
+
164
+ test("an item is never listed twice", () => {
165
+ for (const selectionMode of ACCORDION_SELECTION_MODES) {
166
+ for (const isCollapsible of [true, false]) {
167
+ const next = toggleExpandedValue({ expanded: ["a"], isCollapsible, selectionMode, value: "a" });
168
+ const again = toggleExpandedValue({ expanded: next, isCollapsible, selectionMode, value: "a" });
169
+ expect(new Set(again).size).toBe(again.length);
170
+ }
171
+ }
172
+ });
173
+ });
174
+
175
+ describe("isItemExpanded", () => {
176
+ test("it reads membership, in either mode", () => {
177
+ expect(isItemExpanded(["a", "b"], "b")).toBe(true);
178
+ expect(isItemExpanded(["a"], "b")).toBe(false);
179
+ expect(isItemExpanded([], "a")).toBe(false);
180
+ });
181
+ });
182
+
183
+ describe("toExpandedList", () => {
184
+ test("it normalises every shape a caller can pass to one list", () => {
185
+ // The root holds one internal `string[]` whichever mode is in play, because
186
+ // `useControllableState` cannot be called conditionally.
187
+ expect(toExpandedList("a")).toEqual(["a"]);
188
+ expect(toExpandedList(["a", "b"])).toEqual(["a", "b"]);
189
+ expect(toExpandedList(null)).toEqual([]);
190
+ expect(toExpandedList(undefined)).toEqual([]);
191
+ });
192
+
193
+ test("it copies rather than aliasing the caller's array", () => {
194
+ const value = ["a"];
195
+ expect(toExpandedList(value)).not.toBe(value);
196
+ });
197
+ });
198
+
199
+ describe("resolveAccordionItemAxes", () => {
200
+ test("the item's own answer wins, then the accordion's, then off", () => {
201
+ expect(resolveAccordionItemAxes({}).isDisabled).toBe(false);
202
+ expect(resolveAccordionItemAxes({ root: { isDisabled: true } }).isDisabled).toBe(true);
203
+ expect(resolveAccordionItemAxes({ own: { isDisabled: true }, root: { isDisabled: false } }).isDisabled).toBe(true);
204
+ });
205
+
206
+ test("an explicit false opts one item out of a disabled accordion", () => {
207
+ // `??` throughout and never `||`, so `false` is a value rather than an
208
+ // absence — the rule `pressedScale` already follows.
209
+ expect(resolveAccordionItemAxes({ own: { isDisabled: false }, root: { isDisabled: true } }).isDisabled).toBe(false);
210
+ });
211
+ });
212
+
213
+ describe("accordionVariants defaults", () => {
214
+ test("the tv's defaults are the constants the component falls back to", () => {
215
+ // A drift between the two is an accordion that renders at one size and
216
+ // reports another, which nothing on screen would reveal.
217
+ expect(cls(accordionVariants({}).trigger())).toBe(cls(slotsFor(ACCORDION_DEFAULT_SIZE).trigger()));
218
+ expect(cls(accordionVariants({}).root())).toBe(
219
+ cls(accordionVariants({ size: ACCORDION_DEFAULT_SIZE, variant: ACCORDION_DEFAULT_VARIANT }).root())
220
+ );
221
+ expect(ACCORDION_SIZES).toContain(ACCORDION_DEFAULT_SIZE);
222
+ expect(ACCORDION_VARIANTS).toContain(ACCORDION_DEFAULT_VARIANT);
223
+ });
224
+ });
225
+
226
+ describe("accordionVariants geometry", () => {
227
+ test("the divider is inset to the trigger's own padding at every size", () => {
228
+ // As a pair, which is what `ListGroup` pins for the same reason: a retune of
229
+ // the row's padding that left the divider behind is a line that no longer
230
+ // lines up with anything, at one size only.
231
+ for (const size of ACCORDION_SIZES) {
232
+ const slots = slotsFor(size);
233
+ expect(utilityPx(cls(slots.divider()), "mx")).toBe(utilityPx(cls(slots.trigger()), "px"));
234
+ }
235
+ });
236
+
237
+ test("the content is inset to the trigger's padding too", () => {
238
+ // A panel whose text started at a different margin than the title above it
239
+ // reads as two columns rather than one disclosure.
240
+ for (const size of ACCORDION_SIZES) {
241
+ const slots = slotsFor(size);
242
+ expect(utilityPx(cls(slots.contentInner()), "px")).toBe(utilityPx(cls(slots.trigger()), "px"));
243
+ }
244
+ });
245
+
246
+ test("a trigger is a real touch target at every size", () => {
247
+ for (const size of ACCORDION_SIZES) {
248
+ expect(utilityPx(cls(slotsFor(size).trigger()), "min-h")).toBeGreaterThanOrEqual(MINIMUM_TARGET);
249
+ }
250
+ });
251
+
252
+ test("the size ladder ascends", () => {
253
+ const heights = ACCORDION_SIZES.map((size) => utilityPx(cls(slotsFor(size).trigger()), "min-h"));
254
+ expect(heights).toEqual([...heights].sort((a, b) => a - b));
255
+ expect(new Set(heights).size).toBe(heights.length);
256
+ });
257
+
258
+ test("every glyph step is a size the icon scale has", () => {
259
+ // A glyph in an accordion is a mark on the shared scale, like every other
260
+ // glyph in the library — not a private number.
261
+ for (const size of ACCORDION_SIZES) {
262
+ const step = ACCORDION_GLYPH_STEP[size];
263
+ expect(ICON_SIZES).toContain(step);
264
+ expect(cls(slotsFor(size).glyph())).toBe(`size-icon-${step}`);
265
+ expect(spacingPx(`icon-${step}`)).toBeGreaterThan(0);
266
+ }
267
+ });
268
+ });
269
+
270
+ describe("accordionVariants slots", () => {
271
+ test("no slot a View wears carries a text treatment", () => {
272
+ // A React Native View does not cascade colour to a Text descendant, so the
273
+ // treatment lives on the title and description slots. Rule 1.
274
+ for (const size of ACCORDION_SIZES) {
275
+ for (const value of viewSlots(size)) {
276
+ expect(value).not.toMatch(/\b(text|font)-/);
277
+ }
278
+ }
279
+ });
280
+
281
+ test("the title and description carry the treatment instead", () => {
282
+ for (const size of ACCORDION_SIZES) {
283
+ const slots = slotsFor(size);
284
+ expect(cls(slots.title())).toMatch(/\btext-/);
285
+ expect(cls(slots.description())).toMatch(/\btext-/);
286
+ }
287
+ });
288
+
289
+ test("the text ladder ascends with the size", () => {
290
+ const scale = ["text-xs", "text-sm", "text-base", "text-lg"];
291
+ const steps = ACCORDION_SIZES.map((size) => {
292
+ const title = cls(slotsFor(size).title());
293
+ return scale.findIndex((step) => new RegExp(`\\b${step}\\b`).test(title));
294
+ });
295
+ expect(steps).not.toContain(-1);
296
+ expect(steps).toEqual([...steps].sort((a, b) => a - b));
297
+ });
298
+
299
+ test("the root clips", () => {
300
+ // Load-bearing rather than tidiness: an expanding panel would otherwise
301
+ // square off the group's own rounded corners — `ListGroup`'s rule.
302
+ for (const size of ACCORDION_SIZES) {
303
+ expect(cls(slotsFor(size).root())).toMatch(/\boverflow-hidden\b/);
304
+ }
305
+ });
306
+
307
+ test("the content clips, because its height is animated below its content's", () => {
308
+ for (const size of ACCORDION_SIZES) {
309
+ expect(cls(slotsFor(size).content())).toMatch(/\boverflow-hidden\b/);
310
+ }
311
+ });
312
+
313
+ test("the measured layer is out of flow and spans the width", () => {
314
+ // In normal flow it is laid out against the clip's own height, so a panel
315
+ // measured while the clip sits at zero reports its padding and nothing else —
316
+ // sixteen points for a paragraph, on a simulator. Out of flow it reports its
317
+ // content. `left-0 right-0` is the other half: an absolute child is
318
+ // content-width without it, and a paragraph would measure as one long line.
319
+ for (const size of ACCORDION_SIZES) {
320
+ const inner = cls(slotsFor(size).contentInner());
321
+ expect(inner).toMatch(/\babsolute\b/);
322
+ expect(inner).toMatch(/\btop-0\b/);
323
+ expect(inner).toMatch(/\bleft-0\b/);
324
+ expect(inner).toMatch(/\bright-0\b/);
325
+ }
326
+ });
327
+
328
+ test("the trigger spans the group it is clipped by", () => {
329
+ // The root clips, so without this a short trigger leaves the press feedback
330
+ // ending mid-card instead of spanning it — `ListGroup.Item`'s rule.
331
+ for (const size of ACCORDION_SIZES) {
332
+ expect(cls(slotsFor(size).trigger())).toMatch(/\bw-full\b/);
333
+ }
334
+ });
335
+
336
+ test("the trigger's text column can shrink", () => {
337
+ // Without `min-w-0` a long title pushes the indicator off the row rather
338
+ // than truncating, because a flex item's default minimum is its content.
339
+ for (const size of ACCORDION_SIZES) {
340
+ const content = cls(slotsFor(size).triggerContent());
341
+ expect(content).toMatch(/\bflex-1\b/);
342
+ expect(content).toMatch(/\bmin-w-0\b/);
343
+ }
344
+ });
345
+
346
+ test("the disabled fade lands on the item, never on the trigger", () => {
347
+ // The trigger is a `Pressable`, whose root Animated.View writes `opacity`
348
+ // every frame through a useAnimatedStyle of its own. A class on that node
349
+ // is overwritten silently — the failure `Switch` and `Radio` both record.
350
+ for (const size of ACCORDION_SIZES) {
351
+ const slots = accordionVariants({ isDisabled: true, size, variant: ACCORDION_DEFAULT_VARIANT });
352
+ expect(cls(slots.item())).toMatch(/\bopacity-50\b/);
353
+ expect(cls(slots.trigger())).not.toMatch(/\bopacity-/);
354
+ }
355
+ });
356
+
357
+ test("every variant paints a distinct root", () => {
358
+ // Two cells collapsing means a caller can set the axis and see nothing
359
+ // change, which reads as the prop being ignored.
360
+ const roots = ACCORDION_VARIANTS.map((variant) =>
361
+ cls(accordionVariants({ isDisabled: false, size: ACCORDION_DEFAULT_SIZE, variant }).root())
362
+ );
363
+ expect(new Set(roots).size).toBe(roots.length);
364
+ });
365
+
366
+ test("the variant paints the root alone", () => {
367
+ // Only the root has a surface. A trigger or a panel that also changed with
368
+ // the variant would be a second source for one colour.
369
+ for (const variant of ACCORDION_VARIANTS) {
370
+ const slots = accordionVariants({ isDisabled: false, size: ACCORDION_DEFAULT_SIZE, variant });
371
+ expect(cls(slots.trigger())).toBe(cls(slotsFor(ACCORDION_DEFAULT_SIZE).trigger()));
372
+ expect(cls(slots.contentInner())).toBe(cls(slotsFor(ACCORDION_DEFAULT_SIZE).contentInner()));
373
+ }
374
+ });
375
+
376
+ test("nothing draws a shadow", () => {
377
+ // Nothing else in this package does, and React Native's shadow props
378
+ // diverge between platforms in a way a flat fill does not.
379
+ for (const size of ACCORDION_SIZES) {
380
+ for (const value of viewSlots(size)) {
381
+ expect(value).not.toMatch(/\bshadow/);
382
+ }
383
+ }
384
+ });
385
+ });
386
+
387
+ describe("accordion colour tokens", () => {
388
+ test("every token it names is declared in both themes", () => {
389
+ // A token no theme emits resolves to undefined and is dropped silently,
390
+ // leaving the platform default in place with no error anywhere.
391
+ for (const token of [ACCORDION_FOREGROUND_TOKEN, ACCORDION_INDICATOR_TOKEN]) {
392
+ expect(declarationCount(token)).toBe(2);
393
+ }
394
+ });
395
+
396
+ test("the indicator is quieter than the title beside it", () => {
397
+ // The chevron is chrome; the title is the content. Painting both `foreground`
398
+ // would give the glyph the same weight as the words it points at.
399
+ expect(ACCORDION_INDICATOR_TOKEN).not.toBe(ACCORDION_FOREGROUND_TOKEN);
400
+ });
401
+ });
402
+
403
+ describe("accordion animation", () => {
404
+ test("the panel settles without overshooting its own measured height", () => {
405
+ // Critically damped, and this is `Switch`'s rule turned inside out: an
406
+ // overshoot in *height* draws the panel taller than its content measured,
407
+ // flashing the surface behind it for a frame at the end of every expand.
408
+ const ratio = ACCORDION_SPRING.damping / (2 * Math.sqrt(ACCORDION_SPRING.stiffness * ACCORDION_SPRING.mass));
409
+ expect(ratio).toBeGreaterThanOrEqual(1);
410
+ // And not so far past it that the panel crawls open.
411
+ expect(ratio).toBeLessThan(2);
412
+ });
413
+
414
+ test("unmeasured is a height no layout can report", () => {
415
+ // Zero is a real answer — a panel whose content rendered nothing — so only a
416
+ // negative can mean "still waiting". An item that conflated the two would
417
+ // never start its spring for an empty panel.
418
+ expect(ACCORDION_UNMEASURED).toBeLessThan(0);
419
+ });
420
+
421
+ test("the indicator turns a half turn", () => {
422
+ const { collapsed, expanded } = ACCORDION_INDICATOR_ROTATION;
423
+ expect(Math.abs(expanded - collapsed)).toBe(180);
424
+ });
425
+
426
+ test("the fade runs ahead of the height, inside the travel", () => {
427
+ // A panel whose opacity tracked its height linearly would be half
428
+ // transparent at the midpoint of every expand. The window closes before the
429
+ // height does, so the content is legible for most of the travel.
430
+ const { start, end } = ACCORDION_CONTENT_FADE;
431
+ expect(start).toBeGreaterThanOrEqual(0);
432
+ expect(end).toBeGreaterThan(start);
433
+ expect(end).toBeLessThan(1);
434
+ });
435
+ });