panelui-native 0.73.0 → 0.75.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 (192) hide show
  1. package/README.md +3 -1
  2. package/lib/module/components/avatar/avatar-group.js +64 -0
  3. package/lib/module/components/avatar/avatar-group.js.map +1 -0
  4. package/lib/module/components/avatar/index.js +84 -2
  5. package/lib/module/components/avatar/index.js.map +1 -1
  6. package/lib/module/components/color-picker/color-picker-accessibility.js +12 -0
  7. package/lib/module/components/color-picker/color-picker-accessibility.js.map +1 -0
  8. package/lib/module/components/color-picker/index.js +9 -5
  9. package/lib/module/components/color-picker/index.js.map +1 -1
  10. package/lib/module/components/date-time-picker/index.js +11 -3
  11. package/lib/module/components/date-time-picker/index.js.map +1 -1
  12. package/lib/module/components/drawer/index.js +58 -13
  13. package/lib/module/components/drawer/index.js.map +1 -1
  14. package/lib/module/components/input/index.js +7 -4
  15. package/lib/module/components/input/index.js.map +1 -1
  16. package/lib/module/components/input/input-content-padding.js +6 -0
  17. package/lib/module/components/input/input-content-padding.js.map +1 -0
  18. package/lib/module/components/input-group/index.js +14 -9
  19. package/lib/module/components/input-group/index.js.map +1 -1
  20. package/lib/module/components/input-group/input-group-measurements.js +60 -0
  21. package/lib/module/components/input-group/input-group-measurements.js.map +1 -0
  22. package/lib/module/components/otp-input/index.js +3 -1
  23. package/lib/module/components/otp-input/index.js.map +1 -1
  24. package/lib/module/components/otp-input/otp-input-state.js +7 -0
  25. package/lib/module/components/otp-input/otp-input-state.js.map +1 -0
  26. package/lib/module/components/planner/index.js +20 -6
  27. package/lib/module/components/planner/index.js.map +1 -1
  28. package/lib/module/components/planner/planner-today.js +8 -0
  29. package/lib/module/components/planner/planner-today.js.map +1 -0
  30. package/lib/module/components/plot/index.js +8 -11
  31. package/lib/module/components/plot/index.js.map +1 -1
  32. package/lib/module/components/plot/plot-series-registry.js +32 -0
  33. package/lib/module/components/plot/plot-series-registry.js.map +1 -0
  34. package/lib/module/components/radio-group/index.js +2 -1
  35. package/lib/module/components/radio-group/index.js.map +1 -1
  36. package/lib/module/components/radio-group/radio-group-value.js +8 -0
  37. package/lib/module/components/radio-group/radio-group-value.js.map +1 -0
  38. package/lib/module/components/rating/index.js +7 -4
  39. package/lib/module/components/rating/index.js.map +1 -1
  40. package/lib/module/components/rating/rating-inputs.js +15 -0
  41. package/lib/module/components/rating/rating-inputs.js.map +1 -0
  42. package/lib/module/components/search-bar/index.js +7 -7
  43. package/lib/module/components/search-bar/index.js.map +1 -1
  44. package/lib/module/components/search-bar/search-bar-debounce.js +23 -0
  45. package/lib/module/components/search-bar/search-bar-debounce.js.map +1 -0
  46. package/lib/module/components/select/index.js +9 -1
  47. package/lib/module/components/select/index.js.map +1 -1
  48. package/lib/module/components/select/native-select-contract.js +11 -0
  49. package/lib/module/components/select/native-select-contract.js.map +1 -0
  50. package/lib/module/components/split-view/index.js +439 -0
  51. package/lib/module/components/split-view/index.js.map +1 -0
  52. package/lib/module/components/split-view/split-view-math.js +132 -0
  53. package/lib/module/components/split-view/split-view-math.js.map +1 -0
  54. package/lib/module/components/splitter/index.js +433 -0
  55. package/lib/module/components/splitter/index.js.map +1 -0
  56. package/lib/module/components/splitter/splitter-math.js +206 -0
  57. package/lib/module/components/splitter/splitter-math.js.map +1 -0
  58. package/lib/module/components/steps/index.js +16 -5
  59. package/lib/module/components/steps/index.js.map +1 -1
  60. package/lib/module/components/steps/steps-trigger.js +6 -0
  61. package/lib/module/components/steps/steps-trigger.js.map +1 -0
  62. package/lib/module/components/tag-input/index.js +15 -10
  63. package/lib/module/components/tag-input/index.js.map +1 -1
  64. package/lib/module/components/tag-input/tag-input-state.js +14 -0
  65. package/lib/module/components/tag-input/tag-input-state.js.map +1 -0
  66. package/lib/module/components/textarea/index.js +9 -4
  67. package/lib/module/components/textarea/index.js.map +1 -1
  68. package/lib/module/components/textarea/textarea-disabled.js +6 -0
  69. package/lib/module/components/textarea/textarea-disabled.js.map +1 -0
  70. package/lib/module/components/timeline/index.js +83 -27
  71. package/lib/module/components/timeline/index.js.map +1 -1
  72. package/lib/module/components/timeline/timeline-geometry.js +11 -0
  73. package/lib/module/components/timeline/timeline-geometry.js.map +1 -0
  74. package/lib/module/components/toast/index.js +18 -1
  75. package/lib/module/components/toast/index.js.map +1 -1
  76. package/lib/module/components/toast/toast-store.js +36 -4
  77. package/lib/module/components/toast/toast-store.js.map +1 -1
  78. package/lib/module/components/tour/index.js +4 -2
  79. package/lib/module/components/tour/index.js.map +1 -1
  80. package/lib/module/components/tour/tour-card-measurement.js +24 -0
  81. package/lib/module/components/tour/tour-card-measurement.js.map +1 -0
  82. package/lib/module/index.js +2 -0
  83. package/lib/module/index.js.map +1 -1
  84. package/lib/typescript/src/components/avatar/avatar-group.d.ts +46 -0
  85. package/lib/typescript/src/components/avatar/avatar-group.d.ts.map +1 -0
  86. package/lib/typescript/src/components/avatar/index.d.ts +23 -1
  87. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
  88. package/lib/typescript/src/components/color-picker/color-picker-accessibility.d.ts +8 -0
  89. package/lib/typescript/src/components/color-picker/color-picker-accessibility.d.ts.map +1 -0
  90. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -1
  92. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  94. package/lib/typescript/src/components/input/input-content-padding.d.ts +2 -0
  95. package/lib/typescript/src/components/input/input-content-padding.d.ts.map +1 -0
  96. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  97. package/lib/typescript/src/components/input-group/input-group-measurements.d.ts +16 -0
  98. package/lib/typescript/src/components/input-group/input-group-measurements.d.ts.map +1 -0
  99. package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -1
  100. package/lib/typescript/src/components/otp-input/otp-input-state.d.ts +3 -0
  101. package/lib/typescript/src/components/otp-input/otp-input-state.d.ts.map +1 -0
  102. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/planner/planner-today.d.ts +2 -0
  104. package/lib/typescript/src/components/planner/planner-today.d.ts.map +1 -0
  105. package/lib/typescript/src/components/plot/index.d.ts +1 -1
  106. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  107. package/lib/typescript/src/components/plot/plot-series-registry.d.ts +8 -0
  108. package/lib/typescript/src/components/plot/plot-series-registry.d.ts.map +1 -0
  109. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  110. package/lib/typescript/src/components/radio-group/radio-group-value.d.ts +2 -0
  111. package/lib/typescript/src/components/radio-group/radio-group-value.d.ts.map +1 -0
  112. package/lib/typescript/src/components/rating/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/rating/rating-inputs.d.ts +4 -0
  114. package/lib/typescript/src/components/rating/rating-inputs.d.ts.map +1 -0
  115. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  116. package/lib/typescript/src/components/search-bar/search-bar-debounce.d.ts +7 -0
  117. package/lib/typescript/src/components/search-bar/search-bar-debounce.d.ts.map +1 -0
  118. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  119. package/lib/typescript/src/components/select/native-select-contract.d.ts +11 -0
  120. package/lib/typescript/src/components/select/native-select-contract.d.ts.map +1 -0
  121. package/lib/typescript/src/components/split-view/index.d.ts +159 -0
  122. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -0
  123. package/lib/typescript/src/components/split-view/split-view-math.d.ts +54 -0
  124. package/lib/typescript/src/components/split-view/split-view-math.d.ts.map +1 -0
  125. package/lib/typescript/src/components/splitter/index.d.ts +78 -0
  126. package/lib/typescript/src/components/splitter/index.d.ts.map +1 -0
  127. package/lib/typescript/src/components/splitter/splitter-math.d.ts +67 -0
  128. package/lib/typescript/src/components/splitter/splitter-math.d.ts.map +1 -0
  129. package/lib/typescript/src/components/steps/index.d.ts +2 -2
  130. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  131. package/lib/typescript/src/components/steps/steps-trigger.d.ts +2 -0
  132. package/lib/typescript/src/components/steps/steps-trigger.d.ts.map +1 -0
  133. package/lib/typescript/src/components/tag-input/index.d.ts.map +1 -1
  134. package/lib/typescript/src/components/tag-input/tag-input-state.d.ts +12 -0
  135. package/lib/typescript/src/components/tag-input/tag-input-state.d.ts.map +1 -0
  136. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -1
  137. package/lib/typescript/src/components/textarea/textarea-disabled.d.ts +2 -0
  138. package/lib/typescript/src/components/textarea/textarea-disabled.d.ts.map +1 -0
  139. package/lib/typescript/src/components/timeline/index.d.ts +1 -0
  140. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  141. package/lib/typescript/src/components/timeline/timeline-geometry.d.ts +4 -0
  142. package/lib/typescript/src/components/timeline/timeline-geometry.d.ts.map +1 -0
  143. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  144. package/lib/typescript/src/components/toast/toast-store.d.ts +7 -2
  145. package/lib/typescript/src/components/toast/toast-store.d.ts.map +1 -1
  146. package/lib/typescript/src/components/tour/index.d.ts.map +1 -1
  147. package/lib/typescript/src/components/tour/tour-card-measurement.d.ts +14 -0
  148. package/lib/typescript/src/components/tour/tour-card-measurement.d.ts.map +1 -0
  149. package/lib/typescript/src/index.d.ts +3 -1
  150. package/lib/typescript/src/index.d.ts.map +1 -1
  151. package/package.json +1 -1
  152. package/src/components/avatar/avatar-group.ts +78 -0
  153. package/src/components/avatar/index.tsx +121 -1
  154. package/src/components/color-picker/color-picker-accessibility.ts +12 -0
  155. package/src/components/color-picker/index.tsx +27 -8
  156. package/src/components/date-time-picker/index.tsx +11 -3
  157. package/src/components/drawer/index.tsx +42 -14
  158. package/src/components/input/index.tsx +6 -2
  159. package/src/components/input/input-content-padding.ts +3 -0
  160. package/src/components/input-group/index.tsx +38 -10
  161. package/src/components/input-group/input-group-measurements.ts +98 -0
  162. package/src/components/otp-input/index.tsx +3 -1
  163. package/src/components/otp-input/otp-input-state.ts +7 -0
  164. package/src/components/planner/index.tsx +24 -6
  165. package/src/components/planner/planner-today.ts +5 -0
  166. package/src/components/plot/index.tsx +16 -12
  167. package/src/components/plot/plot-series-registry.ts +41 -0
  168. package/src/components/radio-group/index.tsx +4 -1
  169. package/src/components/radio-group/radio-group-value.ts +9 -0
  170. package/src/components/rating/index.tsx +11 -4
  171. package/src/components/rating/rating-inputs.ts +19 -0
  172. package/src/components/search-bar/index.tsx +12 -7
  173. package/src/components/search-bar/search-bar-debounce.ts +35 -0
  174. package/src/components/select/index.tsx +10 -1
  175. package/src/components/select/native-select-contract.ts +12 -0
  176. package/src/components/split-view/index.tsx +565 -0
  177. package/src/components/split-view/split-view-math.ts +146 -0
  178. package/src/components/splitter/index.tsx +589 -0
  179. package/src/components/splitter/splitter-math.ts +244 -0
  180. package/src/components/steps/index.tsx +35 -9
  181. package/src/components/steps/steps-trigger.ts +6 -0
  182. package/src/components/tag-input/index.tsx +13 -11
  183. package/src/components/tag-input/tag-input-state.ts +18 -0
  184. package/src/components/textarea/index.tsx +8 -4
  185. package/src/components/textarea/textarea-disabled.ts +6 -0
  186. package/src/components/timeline/index.tsx +89 -26
  187. package/src/components/timeline/timeline-geometry.ts +9 -0
  188. package/src/components/toast/index.tsx +26 -1
  189. package/src/components/toast/toast-store.ts +43 -8
  190. package/src/components/tour/index.tsx +11 -3
  191. package/src/components/tour/tour-card-measurement.ts +29 -0
  192. package/src/index.ts +17 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The arithmetic a splitter runs while a seam is under a finger.
3
+ *
4
+ * Sizes are percentages of the whole splitter, never points, which is what
5
+ * makes a dragged layout survive a rotation or a window resize: the numbers
6
+ * mean the same thing at any container size, so nothing has to be re-measured
7
+ * and re-derived when one changes.
8
+ *
9
+ * It lives apart from the component for two reasons. It is the part with the
10
+ * edge cases — a pair of panels that cannot both honour their minimums, a
11
+ * collapse that has to decide between shut and its floor, a caller's sizes that
12
+ * do not add up — and it is the part that runs on the UI thread, where a wrong
13
+ * answer is a layout that sticks rather than an exception anybody sees.
14
+ */
15
+
16
+ /** How small a panel may get before its neighbour stops taking room, in percent. */
17
+ export const DEFAULT_MIN_SIZE = 10;
18
+ /** How large a panel may get, in percent. */
19
+ export const DEFAULT_MAX_SIZE = 100;
20
+
21
+ export interface SplitterConstraint {
22
+ /** Smallest share the panel may hold while open, in percent. */
23
+ minSize: number;
24
+ /** Largest share the panel may hold, in percent. */
25
+ maxSize: number;
26
+ /** Whether dragging past the minimum shuts the panel instead of stopping. */
27
+ collapsible: boolean;
28
+ /** Share the panel holds while shut, in percent. */
29
+ collapsedSize: number;
30
+ }
31
+
32
+ export interface SplitterConstraintInput {
33
+ minSize?: number;
34
+ maxSize?: number;
35
+ collapsible?: boolean;
36
+ collapsedSize?: number;
37
+ }
38
+
39
+ function clamp(value: number, low: number, high: number): number {
40
+ 'worklet';
41
+ return Math.min(Math.max(value, low), high);
42
+ }
43
+
44
+ function finite(value: number | undefined, fallback: number): number {
45
+ 'worklet';
46
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
47
+ }
48
+
49
+ /** The smallest share a panel can hold at all — its floor once shut, or its minimum. */
50
+ export function panelFloor(constraint: SplitterConstraint): number {
51
+ 'worklet';
52
+ return constraint.collapsible ? constraint.collapsedSize : constraint.minSize;
53
+ }
54
+
55
+ /**
56
+ * Fills a panel's declared limits in, in an order that cannot contradict
57
+ * itself: the maximum is never below the minimum, and a collapsed size is never
58
+ * above the minimum it exists to sit below.
59
+ */
60
+ export function normalizeConstraint(
61
+ input: SplitterConstraintInput | undefined
62
+ ): SplitterConstraint {
63
+ const minSize = clamp(finite(input?.minSize, DEFAULT_MIN_SIZE), 0, 100);
64
+ const maxSize = clamp(finite(input?.maxSize, DEFAULT_MAX_SIZE), minSize, 100);
65
+ const collapsible = input?.collapsible === true;
66
+ const collapsedSize = clamp(finite(input?.collapsedSize, 0), 0, minSize);
67
+ return { minSize, maxSize, collapsible, collapsedSize };
68
+ }
69
+
70
+ function total(layout: number[]): number {
71
+ 'worklet';
72
+ let sum = 0;
73
+ for (let index = 0; index < layout.length; index += 1) sum += layout[index]!;
74
+ return sum;
75
+ }
76
+
77
+ /**
78
+ * Pushes a layout onto exactly 100, moving each panel by its share of the room
79
+ * it has left in the direction the correction is going. Panels already against
80
+ * a limit therefore absorb none of it, which is what keeps a correction from
81
+ * quietly undoing the limit it just honoured.
82
+ *
83
+ * Four passes, because clamping after each one can hand the next a smaller
84
+ * remainder rather than none; it converges long before that in practice, and
85
+ * the bound is what stops a contradictory set of limits from spinning.
86
+ */
87
+ function settle(layout: number[], constraints: SplitterConstraint[]): number[] {
88
+ 'worklet';
89
+ const next = layout.slice();
90
+
91
+ for (let pass = 0; pass < 4; pass += 1) {
92
+ const remainder = 100 - total(next);
93
+ if (Math.abs(remainder) < 1e-6) break;
94
+
95
+ let room = 0;
96
+ const headroom: number[] = [];
97
+ for (let index = 0; index < next.length; index += 1) {
98
+ const constraint = constraints[index]!;
99
+ const available =
100
+ remainder > 0
101
+ ? constraint.maxSize - next[index]!
102
+ : next[index]! - panelFloor(constraint);
103
+ headroom.push(Math.max(available, 0));
104
+ room += Math.max(available, 0);
105
+ }
106
+ if (room <= 1e-6) break;
107
+
108
+ for (let index = 0; index < next.length; index += 1) {
109
+ next[index] = clamp(
110
+ next[index]! + (remainder * headroom[index]!) / room,
111
+ panelFloor(constraints[index]!),
112
+ constraints[index]!.maxSize
113
+ );
114
+ }
115
+ }
116
+
117
+ // Minimums that add up to more than the whole cannot all be honoured. Scaling
118
+ // the whole layout down is the failure that stays legible: every panel is
119
+ // smaller than it asked for, rather than the last one being pushed out of the
120
+ // container where nobody can see that it is there at all.
121
+ const sum = total(next);
122
+ if (sum > 100 + 1e-6) {
123
+ for (let index = 0; index < next.length; index += 1) {
124
+ next[index] = (next[index]! * 100) / sum;
125
+ }
126
+ }
127
+
128
+ return next;
129
+ }
130
+
131
+ /**
132
+ * Turns whatever sizes a caller gave into one layout that adds up.
133
+ *
134
+ * Panels with no size of their own split what the sized ones left over, so a
135
+ * three-pane splitter that only pins its sidebar gets the sensible reading of
136
+ * that: the sidebar keeps its number and the other two share the rest.
137
+ */
138
+ export function resolveLayout(
139
+ sizes: (number | undefined)[],
140
+ constraints: SplitterConstraint[]
141
+ ): number[] {
142
+ if (constraints.length === 0) return [];
143
+
144
+ let claimed = 0;
145
+ let unsized = 0;
146
+ for (let index = 0; index < constraints.length; index += 1) {
147
+ const size = sizes[index];
148
+ if (typeof size === 'number' && Number.isFinite(size)) {
149
+ claimed += clamp(size, 0, 100);
150
+ } else {
151
+ unsized += 1;
152
+ }
153
+ }
154
+
155
+ const share = unsized > 0 ? Math.max(100 - claimed, 0) / unsized : 0;
156
+ const layout: number[] = [];
157
+ for (let index = 0; index < constraints.length; index += 1) {
158
+ const size = sizes[index];
159
+ const raw =
160
+ typeof size === 'number' && Number.isFinite(size) ? clamp(size, 0, 100) : share;
161
+ layout.push(
162
+ clamp(raw, panelFloor(constraints[index]!), constraints[index]!.maxSize)
163
+ );
164
+ }
165
+
166
+ return settle(layout, constraints);
167
+ }
168
+
169
+ /**
170
+ * A panel's size once it is below its minimum: shut if it is collapsible and
171
+ * the drag has gone more than halfway there, and otherwise held at the minimum.
172
+ *
173
+ * The halfway point is what makes collapsing feel like a decision rather than
174
+ * an accident — a finger that grazes the minimum springs back, and one that
175
+ * keeps going shuts the panel.
176
+ */
177
+ function snapDown(size: number, constraint: SplitterConstraint): number {
178
+ 'worklet';
179
+ if (size >= constraint.minSize) return size;
180
+ if (!constraint.collapsible) return constraint.minSize;
181
+ return size < (constraint.collapsedSize + constraint.minSize) / 2
182
+ ? constraint.collapsedSize
183
+ : constraint.minSize;
184
+ }
185
+
186
+ /**
187
+ * Moves one seam, in percent, and hands back the whole layout.
188
+ *
189
+ * Only the two panels either side of the seam change: a drag borrows from its
190
+ * neighbour and nobody else, so the panels further along stay exactly where the
191
+ * reader left them.
192
+ *
193
+ * Collapsing is only ever offered to the panel being shrunk. Both sides could
194
+ * technically be past their minimum at once at the end of a long drag, and
195
+ * shutting the panel that is currently growing is never what the drag meant.
196
+ */
197
+ export function resizeLayout(
198
+ layout: number[],
199
+ boundary: number,
200
+ delta: number,
201
+ constraints: SplitterConstraint[]
202
+ ): number[] {
203
+ 'worklet';
204
+ const next = layout.slice();
205
+ if (boundary < 0 || boundary + 1 >= next.length) return next;
206
+ if (!Number.isFinite(delta)) return next;
207
+
208
+ const first = constraints[boundary];
209
+ const second = constraints[boundary + 1];
210
+ if (!first || !second) return next;
211
+
212
+ const pair = next[boundary]! + next[boundary + 1]!;
213
+ let intended = next[boundary]! + delta;
214
+
215
+ if (delta < 0) {
216
+ intended = snapDown(intended, first);
217
+ } else if (delta > 0) {
218
+ intended = pair - snapDown(pair - intended, second);
219
+ }
220
+
221
+ const low = Math.max(panelFloor(first), pair - second.maxSize);
222
+ const high = Math.min(first.maxSize, pair - panelFloor(second));
223
+ if (low > high) return next;
224
+
225
+ next[boundary] = clamp(intended, low, high);
226
+ next[boundary + 1] = pair - next[boundary]!;
227
+ return next;
228
+ }
229
+
230
+ /** Where a seam sits, in percent, measured from the start of the splitter. */
231
+ export function layoutOffset(layout: number[], boundary: number): number {
232
+ 'worklet';
233
+ let offset = 0;
234
+ for (let index = 0; index <= boundary && index < layout.length; index += 1) {
235
+ offset += layout[index]!;
236
+ }
237
+ return offset;
238
+ }
239
+
240
+ /** Whether a panel is currently shut rather than merely small. */
241
+ export function isCollapsed(size: number, constraint: SplitterConstraint): boolean {
242
+ 'worklet';
243
+ return constraint.collapsible && size <= constraint.collapsedSize + 1e-6;
244
+ }
@@ -38,12 +38,19 @@ import {
38
38
  useState,
39
39
  type ReactNode,
40
40
  } from 'react';
41
- import { Pressable, View, type Text as RNText, type ViewProps } from 'react-native';
41
+ import {
42
+ Pressable,
43
+ View,
44
+ type PressableProps,
45
+ type Text as RNText,
46
+ type ViewProps,
47
+ } from 'react-native';
42
48
  import { tv } from 'tailwind-variants';
43
49
  import { useCSSVariable } from 'uniwind';
44
50
  import { CheckIcon } from '../../icons';
45
51
  import { Text, type TextProps, textChildren } from '../../primitives/text';
46
52
  import { Spinner } from '../spinner';
53
+ import { stepsTriggerDisabled } from './steps-trigger';
47
54
 
48
55
  export type StepState = 'active' | 'completed' | 'inactive' | 'loading';
49
56
  export type StepsOrientation = 'horizontal' | 'vertical';
@@ -293,7 +300,7 @@ const StepsItem = forwardRef<View, StepsItemProps>(
293
300
  );
294
301
  StepsItem.displayName = 'Steps.Item';
295
302
 
296
- export interface StepsTriggerProps extends ViewProps {
303
+ export interface StepsTriggerProps extends PressableProps {
297
304
  className?: string;
298
305
  children?: ReactNode;
299
306
  }
@@ -308,11 +315,23 @@ const STATE_WORDS: Record<StepState, string> = {
308
315
 
309
316
  /** Makes its item selectable. Omit it for a read-only stepper. */
310
317
  const StepsTrigger = forwardRef<View, StepsTriggerProps>(
311
- ({ className, children, ...props }, ref) => {
318
+ (
319
+ {
320
+ className,
321
+ children,
322
+ disabled,
323
+ onPress,
324
+ accessibilityState,
325
+ accessibilityValue,
326
+ ...props
327
+ },
328
+ ref
329
+ ) => {
312
330
  const { setActiveStep, orientation } = useSteps('Steps.Trigger');
313
331
  const { step, state, isDisabled } = useStepItem('Steps.Trigger');
314
332
  const placement = useContext(StepPositionContext);
315
- const { trigger } = stepsVariants({ orientation, state, isDisabled });
333
+ const triggerDisabled = stepsTriggerDisabled(isDisabled, disabled);
334
+ const { trigger } = stepsVariants({ orientation, state, isDisabled: triggerDisabled });
316
335
 
317
336
  /*
318
337
  * Said as a value rather than a label, because the label is the step's own
@@ -327,13 +346,20 @@ const StepsTrigger = forwardRef<View, StepsTriggerProps>(
327
346
  return (
328
347
  <Pressable
329
348
  ref={ref}
349
+ {...props}
330
350
  accessibilityRole="button"
331
- accessibilityState={{ disabled: isDisabled, selected: state === 'active' }}
332
- accessibilityValue={{ text: position }}
333
- disabled={isDisabled}
334
- onPress={() => setActiveStep(step)}
351
+ accessibilityState={{
352
+ ...accessibilityState,
353
+ disabled: triggerDisabled,
354
+ selected: state === 'active',
355
+ }}
356
+ accessibilityValue={{ ...accessibilityValue, text: position }}
357
+ disabled={triggerDisabled}
358
+ onPress={(event) => {
359
+ onPress?.(event);
360
+ setActiveStep(step);
361
+ }}
335
362
  className={trigger({ className })}
336
- {...props}
337
363
  >
338
364
  {textChildren(children)}
339
365
  </Pressable>
@@ -0,0 +1,6 @@
1
+ export function stepsTriggerDisabled(
2
+ itemDisabled: boolean,
3
+ triggerDisabled: boolean | null | undefined
4
+ ): boolean {
5
+ return itemDisabled || !!triggerDisabled;
6
+ }
@@ -64,6 +64,7 @@ import { Text } from '../../primitives/text';
64
64
  import { selectionTick } from '../../utils/haptics';
65
65
  import { Chip, type ChipSize, type ChipVariant } from '../chip';
66
66
  import { Label } from '../label';
67
+ import { reconcileMarkedTag, type MarkedTag } from './tag-input-state';
67
68
 
68
69
  /** Matches Input and Combobox, so the three read as the same control focused. */
69
70
  const FOCUS_DURATION = 150;
@@ -298,18 +299,18 @@ export const TagInput = forwardRef<TextInput, TagInputProps>(
298
299
  * itself, because with `allowDuplicates` two tags can read the same and
299
300
  * marking "the last one" has to mean the last one.
300
301
  */
301
- const [marked, setMarked] = useState<number | null>(null);
302
+ const [marked, setMarked] = useState<MarkedTag | null>(null);
302
303
 
303
304
  const overflowing = max !== undefined && tags.length > max;
304
305
  const invalid = !!errorMessage || overflowing;
305
306
  const interactive = !disabled && !readOnly;
306
307
 
307
- // The list can be emptied from outside, which would leave the mark pointing
308
- // past the end of it — and a stale mark is a tag deleted by a backspace
309
- // meant for the one that used to be there.
308
+ // A controlled list can be replaced or reordered between the two
309
+ // backspaces. Keep the mark only while the exact tag it armed still owns
310
+ // that slot, so a key press aimed at the old value cannot delete the new one.
310
311
  useEffect(() => {
311
- if (marked !== null && marked >= tags.length) setMarked(null);
312
- }, [marked, tags.length]);
312
+ setMarked((current) => reconcileMarkedTag(current, tags));
313
+ }, [tags]);
313
314
 
314
315
  const setText = useCallback(
315
316
  (next: string) => {
@@ -418,14 +419,15 @@ export const TagInput = forwardRef<TextInput, TagInputProps>(
418
419
  // what it always means, and the tags are none of its business.
419
420
  if (text.length > 0 || tags.length === 0) return;
420
421
 
421
- if (marked !== null && marked < tags.length) {
422
- removeAt(marked);
422
+ if (marked !== null && reconcileMarkedTag(marked, tags)) {
423
+ removeAt(marked.index);
423
424
  setMarked(null);
424
425
  return;
425
426
  }
426
- setMarked(tags.length - 1);
427
+ const index = tags.length - 1;
428
+ setMarked({ index, tag: tags[index]! });
427
429
  },
428
- [onKeyPress, text.length, tags.length, marked, removeAt]
430
+ [onKeyPress, text.length, tags, marked, removeAt]
429
431
  );
430
432
 
431
433
  const handleFocus = useCallback<NonNullable<TextInputProps['onFocus']>>(
@@ -541,7 +543,7 @@ export const TagInput = forwardRef<TextInput, TagInputProps>(
541
543
  // The marked tag turns destructive rather than growing an
542
544
  // outline: it is about to be deleted, and that is the one
543
545
  // colour in the theme that already means exactly that.
544
- variant={marked === index ? 'destructive' : chipVariant}
546
+ variant={marked?.index === index ? 'destructive' : chipVariant}
545
547
  onClose={interactive ? () => removeAt(index) : undefined}
546
548
  closeLabel={`Remove ${tag}`}
547
549
  haptics={haptics}
@@ -0,0 +1,18 @@
1
+ /** The exact slot a first Backspace armed for removal. */
2
+ export interface MarkedTag {
3
+ index: number;
4
+ tag: string;
5
+ }
6
+
7
+ /**
8
+ * Keeps an armed removal only while the same tag still occupies the same slot.
9
+ * Controlled fields can replace or reorder their value between key presses;
10
+ * a mark from the old value must never authorize deleting the new one.
11
+ */
12
+ export function reconcileMarkedTag(
13
+ marked: MarkedTag | null,
14
+ tags: readonly string[]
15
+ ): MarkedTag | null {
16
+ if (marked === null) return null;
17
+ return tags[marked.index] === marked.tag ? marked : null;
18
+ }
@@ -39,6 +39,7 @@ import type { KeyboardAvoidanceMode } from '../../hooks/use-keyboard-avoidance';
39
39
  import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
40
40
  import { Text } from '../../primitives/text';
41
41
  import { Label } from '../label';
42
+ import { textareaIsDisabled } from './textarea-disabled';
42
43
 
43
44
  /** Long enough to read as a transition, short enough not to lag a fast tab. */
44
45
  const FOCUS_DURATION = 150;
@@ -183,6 +184,8 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(
183
184
  onFocus,
184
185
  onBlur,
185
186
  onContentSizeChange,
187
+ editable,
188
+ accessibilityState,
186
189
  style,
187
190
  ...props
188
191
  },
@@ -190,6 +193,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(
190
193
  ) => {
191
194
  const [focused, setFocused] = useState(false);
192
195
  const invalid = !!errorMessage;
196
+ const isDisabled = textareaIsDisabled(disabled, editable);
193
197
 
194
198
  const metrics = METRICS[size];
195
199
  /* Both paddings plus the lines themselves — the box a row count asks for. */
@@ -225,7 +229,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(
225
229
  const slots = textareaVariants({
226
230
  variant,
227
231
  size,
228
- disabled: !!disabled,
232
+ disabled: isDisabled,
229
233
  over,
230
234
  });
231
235
 
@@ -320,19 +324,19 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(
320
324
  const body = (
321
325
  <>
322
326
  {label ? (
323
- <Label isRequired={isRequired} isInvalid={invalid} isDisabled={!!disabled}>
327
+ <Label isRequired={isRequired} isInvalid={invalid} isDisabled={isDisabled}>
324
328
  {label}
325
329
  </Label>
326
330
  ) : null}
327
331
  <AnimatedTextInput
328
332
  ref={ref}
329
333
  multiline
330
- editable={!disabled}
334
+ editable={!isDisabled}
331
335
  onFocus={handleFocus}
332
336
  onBlur={handleBlur}
333
337
  onContentSizeChange={handleContentSizeChange}
334
338
  accessibilityLabel={label}
335
- accessibilityState={{ disabled: !!disabled }}
339
+ accessibilityState={{ ...accessibilityState, disabled: isDisabled }}
336
340
  aria-required={isRequired}
337
341
  aria-invalid={invalid}
338
342
  className={slots.field({ className })}
@@ -0,0 +1,6 @@
1
+ export function textareaIsDisabled(
2
+ disabled: boolean | undefined,
3
+ editable: boolean | undefined
4
+ ): boolean {
5
+ return !!disabled || editable === false;
6
+ }
@@ -65,6 +65,7 @@ import { tv } from 'tailwind-variants';
65
65
  import { useCSSVariable } from 'uniwind';
66
66
  import { IconColorProvider } from '../../icons';
67
67
  import { Text, type TextProps, textChildren } from '../../primitives/text';
68
+ import { timelineColumnWidth } from './timeline-geometry';
68
69
 
69
70
  export type TimelineVariant = 'dot' | 'icon' | 'numbered' | 'card' | 'compact';
70
71
  /** Semantic colour for a single event, independent of progress. */
@@ -87,10 +88,6 @@ const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
87
88
  const HORIZONTAL_RAIL_TOP = 64;
88
89
  const HORIZONTAL_TICK_HEIGHT = 10;
89
90
 
90
- /** A column with something to say, and one with nothing. */
91
- const HORIZONTAL_WIDE = 268;
92
- const HORIZONTAL_NARROW = 76;
93
-
94
91
  /**
95
92
  * How wide a column is: what it was given, else what its contents ask for.
96
93
  *
@@ -99,8 +96,6 @@ const HORIZONTAL_NARROW = 76;
99
96
  * snap point half a column out.
100
97
  */
101
98
  function itemWidth(props: TimelineItemProps): number {
102
- if (typeof props.width === 'number') return props.width;
103
-
104
99
  let filled = false;
105
100
  Children.forEach(props.children, (child) => {
106
101
  if (filled || !isValidElement(child)) return;
@@ -110,7 +105,7 @@ function itemWidth(props: TimelineItemProps): number {
110
105
  if (inner !== undefined && inner !== null && inner !== false) filled = true;
111
106
  });
112
107
 
113
- return filled ? HORIZONTAL_WIDE : HORIZONTAL_NARROW;
108
+ return timelineColumnWidth(props.width, filled);
114
109
  }
115
110
 
116
111
  /** Variants whose node is a filled disc rather than an outlined ring. */
@@ -265,6 +260,15 @@ interface TimelineItemContextValue {
265
260
  tone: TimelineTone;
266
261
  /** False on the last item, so its rail does not trail into nothing. */
267
262
  showSeparator: boolean;
263
+ /**
264
+ * Horizontal only: where this column starts, in points, and how wide it is.
265
+ *
266
+ * Both are worked out by the item, and the parts inside it need the same two
267
+ * numbers to fade against the scroll. Passing them down beats deriving them
268
+ * twice from measurements that would then be free to disagree.
269
+ */
270
+ offset: number;
271
+ columnWidth: number;
268
272
  }
269
273
 
270
274
  const TimelineContext = createContext<TimelineContextValue | null>(null);
@@ -425,6 +429,7 @@ export interface TimelineItemProps extends ViewProps {
425
429
  * carries none collapses to a tick — so a quiet stretch of the sequence
426
430
  * compresses instead of paying full width for nothing. Set it to override
427
431
  * that for a column that needs more or less room than its contents suggest.
432
+ * It must be finite and greater than zero; invalid values use the content default.
428
433
  */
429
434
  width?: number;
430
435
  children?: ReactNode;
@@ -439,6 +444,7 @@ const TimelineItem = forwardRef<View, TimelineItemProps>(
439
444
  tone = 'default',
440
445
  last = false,
441
446
  width,
447
+ style,
442
448
  children,
443
449
  ...props
444
450
  },
@@ -455,35 +461,53 @@ const TimelineItem = forwardRef<View, TimelineItemProps>(
455
461
  });
456
462
  const horizontal = orientation === 'horizontal';
457
463
 
458
- const context = useMemo(
459
- () => ({ step, completed: isCompleted, tone, showSeparator: !last }),
460
- [step, isCompleted, tone, last]
461
- );
462
-
463
464
  const columnWidth = horizontal
464
465
  ? itemWidth({ step, width, children } as TimelineItemProps)
465
466
  : undefined;
466
467
  const offset = offsets[step] ?? 0;
467
468
 
469
+ const context = useMemo(
470
+ () => ({
471
+ step,
472
+ completed: isCompleted,
473
+ tone,
474
+ showSeparator: !last,
475
+ offset,
476
+ columnWidth: columnWidth ?? 0,
477
+ }),
478
+ [step, isCompleted, tone, last, offset, columnWidth]
479
+ );
480
+
468
481
  /*
469
482
  * A column recedes as it leaves the reading edge, so the one being read is
470
- * the one that looks read. The window is a column's own width either side,
471
- * which keeps the neighbours legible — this is a timeline, and the value of
472
- * it is being able to see what came before and after at once.
483
+ * the one that looks read.
484
+ *
485
+ * One column's width either side, not two: a window wide enough to hold
486
+ * three columns near full strength is a row where nothing is picked out,
487
+ * and picking one out is the whole job. The neighbours stay legible at the
488
+ * far end of it — a timeline is worth reading for what came before and
489
+ * after — they simply stop competing.
490
+ *
491
+ * The scale and the drop are what turn a fade into a focus. Four points and
492
+ * four percent is under the threshold where anybody would call it movement,
493
+ * and over the one where the column at the edge stops looking flat against
494
+ * the rest.
473
495
  */
474
496
  const columnStyle = useAnimatedStyle(() => {
475
- if (!horizontal || !animate) return { opacity: 1 };
497
+ if (!horizontal || !animate) return { opacity: 1, transform: [] };
476
498
  const distance = Math.abs(scrollX.value - offset);
477
- const window = Math.max(columnWidth ?? HORIZONTAL_WIDE, 1) * 2;
499
+ const window = Math.max(columnWidth ?? timelineColumnWidth(undefined, true), 1);
500
+ const away = interpolate(distance, [0, window], [0, 1], 'clamp');
478
501
  return {
479
- opacity: interpolate(distance, [0, window], [1, 0.45], 'clamp'),
502
+ opacity: 1 - away * 0.4,
503
+ transform: [{ scale: 1 - away * 0.04 }, { translateY: away * 4 }],
480
504
  };
481
505
  });
482
506
 
483
507
  if (!horizontal) {
484
508
  return (
485
509
  <TimelineItemContext.Provider value={context}>
486
- <View ref={ref} className={item({ className })} {...props}>
510
+ <View ref={ref} className={item({ className })} style={style} {...props}>
487
511
  {textChildren(children)}
488
512
  </View>
489
513
  </TimelineItemContext.Provider>
@@ -495,8 +519,8 @@ const TimelineItem = forwardRef<View, TimelineItemProps>(
495
519
  <Animated.View
496
520
  ref={ref}
497
521
  className={item({ className })}
498
- style={[{ width: columnWidth }, columnStyle]}
499
522
  {...props}
523
+ style={[style, { width: columnWidth }, columnStyle]}
500
524
  >
501
525
  {textChildren(children)}
502
526
  </Animated.View>
@@ -647,15 +671,54 @@ TimelineIndicator.displayName = 'Timeline.Indicator';
647
671
  * so the connector runs unbroken.
648
672
  */
649
673
  const TimelineContent = forwardRef<View, ViewProps & { className?: string }>(
650
- ({ className, children, ...props }, ref) => {
651
- const { variant, orientation } = useTimeline('Timeline.Content');
652
- const { completed, tone } = useTimelineItem('Timeline.Content');
674
+ ({ className, children, style, ...props }, ref) => {
675
+ const { variant, orientation, scrollX, animate } = useTimeline('Timeline.Content');
676
+ const { completed, tone, offset, columnWidth } =
677
+ useTimelineItem('Timeline.Content');
653
678
  const { body, panel } = timelineVariants({ variant, tone, completed, orientation });
679
+ const horizontal = orientation === 'horizontal';
680
+
681
+ /*
682
+ * Horizontal: the body recedes further than the column around it.
683
+ *
684
+ * The rail is a continuous thing and has to stay readable across the whole
685
+ * track — the years either side of the one being read are half of what a
686
+ * timeline is for. The prose under them is not: several columns of it at
687
+ * equal weight is a wall, and no amount of fading the column as a whole
688
+ * fixes that without taking the years down with it. So the body takes a
689
+ * second, steeper curve and the dates keep the first one.
690
+ */
691
+ const bodyStyle = useAnimatedStyle(() => {
692
+ if (!horizontal || !animate) return { opacity: 1 };
693
+ const distance = Math.abs(scrollX.value - offset);
694
+ const window = Math.max(columnWidth, 1);
695
+ return { opacity: interpolate(distance, [0, window], [1, 0.3], 'clamp') };
696
+ });
697
+
698
+ const inner =
699
+ variant === 'card' ? (
700
+ <View className={panel()}>{textChildren(children)}</View>
701
+ ) : (
702
+ children
703
+ );
704
+
705
+ if (!horizontal) {
706
+ return (
707
+ <View ref={ref} className={body({ className })} style={style} {...props}>
708
+ {inner}
709
+ </View>
710
+ );
711
+ }
654
712
 
655
713
  return (
656
- <View ref={ref} className={body({ className })} {...props}>
657
- {variant === 'card' ? <View className={panel()}>{textChildren(children)}</View> : children}
658
- </View>
714
+ <Animated.View
715
+ ref={ref}
716
+ className={body({ className })}
717
+ {...props}
718
+ style={[style, bodyStyle]}
719
+ >
720
+ {inner}
721
+ </Animated.View>
659
722
  );
660
723
  }
661
724
  );