panelui-native 0.73.0 → 0.75.1

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 +62 -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 +47 -15
  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,78 @@
1
+ /**
2
+ * The arithmetic behind a stack of avatars: how far each one slides under the
3
+ * one before it, and how many faces are left over once the row is capped.
4
+ *
5
+ * It lives apart from the component because both answers are pure and both are
6
+ * easy to get subtly wrong — a cap of zero that hides everybody, a `total`
7
+ * lower than the number of faces already on screen, a negative overlap that
8
+ * turns a stack into a gap.
9
+ */
10
+
11
+ /** Rendered diameter of each avatar size, in points. */
12
+ export const AVATAR_SIZE_POINTS = { sm: 32, md: 40, lg: 56, xl: 80 } as const;
13
+
14
+ export type AvatarSizeName = keyof typeof AVATAR_SIZE_POINTS;
15
+
16
+ /**
17
+ * How much of each avatar the next one covers, as a share of its diameter.
18
+ * A third is enough for the faces to read as one group and to still leave the
19
+ * widest part of every face visible.
20
+ */
21
+ export const AVATAR_GROUP_OVERLAP_RATIO = 0.32;
22
+
23
+ /**
24
+ * Points one avatar slides under its neighbour.
25
+ *
26
+ * Derived from the size rather than fixed, so the stack keeps its proportions
27
+ * across the size ladder instead of looking crowded at `xl` and loose at `sm`.
28
+ * A caller's own number wins, clamped so it can close a stack up entirely but
29
+ * never open it into a gap — that is what a plain row is for.
30
+ */
31
+ export function avatarGroupOverlap(
32
+ size: AvatarSizeName,
33
+ overlap?: number
34
+ ): number {
35
+ const diameter = AVATAR_SIZE_POINTS[size] ?? AVATAR_SIZE_POINTS.md;
36
+ if (overlap === undefined || !Number.isFinite(overlap)) {
37
+ return Math.round(diameter * AVATAR_GROUP_OVERLAP_RATIO);
38
+ }
39
+ return Math.min(Math.max(overlap, 0), diameter);
40
+ }
41
+
42
+ export interface AvatarGroupCount {
43
+ /** How many avatars to render. */
44
+ visible: number;
45
+ /** How many are left over, for the trailing count. `0` renders no count. */
46
+ overflow: number;
47
+ }
48
+
49
+ /**
50
+ * Splits a row of faces into the ones shown and the number hidden behind them.
51
+ *
52
+ * `max` counts the faces, not the slots — a cap of three with five people shows
53
+ * three avatars and a `+2`, rather than two and a `+3`.
54
+ */
55
+ export function avatarGroupCount(
56
+ count: number,
57
+ max?: number,
58
+ total?: number
59
+ ): AvatarGroupCount {
60
+ const people = Number.isFinite(count) ? Math.max(Math.trunc(count), 0) : 0;
61
+
62
+ // An unusable cap is no cap: a `max` of zero or NaN reads as "not set", never
63
+ // as "hide everyone", because an empty row is never what a caller meant.
64
+ const capped =
65
+ max !== undefined && Number.isFinite(max) && max >= 1
66
+ ? Math.min(Math.trunc(max), people)
67
+ : people;
68
+
69
+ // `total` describes a population the children only sample, so it can only add
70
+ // to the count — a total below what is already on screen is stale, not a
71
+ // reason to claim there are fewer people than the row is showing.
72
+ const population =
73
+ total !== undefined && Number.isFinite(total)
74
+ ? Math.max(Math.trunc(total), people)
75
+ : people;
76
+
77
+ return { visible: capped, overflow: Math.max(population - capped, 0) };
78
+ }
@@ -1,4 +1,12 @@
1
- import { forwardRef, useState, type ReactNode } from 'react';
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ forwardRef,
5
+ isValidElement,
6
+ useState,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ } from 'react';
2
10
  import {
3
11
  Image,
4
12
  View,
@@ -9,6 +17,11 @@ import {
9
17
  import { tv, type VariantProps } from 'tailwind-variants';
10
18
  import { Text, textChildren } from '../../primitives/text';
11
19
  import { cn } from '../../utils/cn';
20
+ import {
21
+ avatarGroupCount,
22
+ avatarGroupOverlap,
23
+ type AvatarSizeName,
24
+ } from './avatar-group';
12
25
 
13
26
  const avatarVariants = tv({
14
27
  slots: {
@@ -111,6 +124,113 @@ const AvatarBadge = forwardRef<View, AvatarBadgeProps>(
111
124
  );
112
125
  AvatarBadge.displayName = 'Avatar.Badge';
113
126
 
127
+ export interface AvatarGroupProps extends ViewProps {
128
+ className?: string;
129
+ /** Size for every avatar in the stack. A child's own `size` still wins. */
130
+ size?: AvatarSizeName;
131
+ /**
132
+ * How many faces to show. The rest are counted into a trailing `+N`.
133
+ *
134
+ * It caps the faces, not the row: `max={3}` with five people shows three
135
+ * avatars and a `+2`.
136
+ */
137
+ max?: number;
138
+ /**
139
+ * How many people there are, when the children are only the first few of
140
+ * them. The count is measured against this instead of against the number of
141
+ * children, so a stack of three out of forty reads `+37`.
142
+ */
143
+ total?: number;
144
+ /** Points each avatar slides under the one before it. Defaults to a third of the size. */
145
+ overlap?: number;
146
+ }
147
+
148
+ /**
149
+ * A row of avatars, each overlapping the one after it, with the people who did
150
+ * not fit counted at the end.
151
+ *
152
+ * The faces are drawn back to front — the first is on top — which is why the
153
+ * row lays itself out in reverse: later siblings paint over earlier ones, so
154
+ * the stacking order and the reading order pull against each other and one of
155
+ * them has to be flipped. Flipping the layout keeps the children in the order
156
+ * they were written.
157
+ *
158
+ * Each face gets a ring in the page background so it separates from the one
159
+ * underneath whatever surface the stack sits on.
160
+ *
161
+ * ```tsx
162
+ * <Avatar.Group max={3} total={members.length}>
163
+ * {members.map((member) => (
164
+ * <Avatar key={member.id} source={{ uri: member.avatarUrl }} fallback={member.initials} />
165
+ * ))}
166
+ * </Avatar.Group>
167
+ * ```
168
+ */
169
+ const AvatarGroup = forwardRef<View, AvatarGroupProps>(
170
+ ({ className, size = 'md', max, total, overlap, children, ...props }, ref) => {
171
+ const faces = Children.toArray(children).filter(isValidElement) as ReactElement<AvatarProps>[];
172
+ const { visible, overflow } = avatarGroupCount(faces.length, max, total);
173
+ const slide = avatarGroupOverlap(size, overlap);
174
+
175
+ // Back to front: the count sits under the last face, which sits under the
176
+ // one before it, and so on up to the first.
177
+ const stack: { key: string; node: ReactNode }[] = [];
178
+
179
+ if (overflow > 0) {
180
+ stack.push({
181
+ key: 'overflow',
182
+ node: (
183
+ <AvatarRoot
184
+ size={size}
185
+ fallback={`+${overflow}`}
186
+ accessibilityLabel={`${overflow} more`}
187
+ className="border-2 border-background"
188
+ />
189
+ ),
190
+ });
191
+ }
192
+
193
+ for (let index = visible - 1; index >= 0; index -= 1) {
194
+ const face = faces[index]!;
195
+ stack.push({
196
+ key: String(face.key ?? index),
197
+ node: cloneElement(face, {
198
+ size: face.props.size ?? size,
199
+ className: cn('border-2 border-background', face.props.className),
200
+ }),
201
+ });
202
+ }
203
+
204
+ return (
205
+ <View
206
+ ref={ref}
207
+ accessibilityRole="list"
208
+ className={cn('flex-row-reverse self-start', className)}
209
+ {...props}
210
+ >
211
+ {stack.map((entry, index) => (
212
+ // The first node laid out is the trailing one, and it is the only one
213
+ // with nothing to slide under.
214
+ <View key={entry.key} style={index === 0 ? undefined : { marginEnd: -slide }}>
215
+ {entry.node}
216
+ </View>
217
+ ))}
218
+ </View>
219
+ );
220
+ }
221
+ );
222
+ AvatarGroup.displayName = 'Avatar.Group';
223
+
114
224
  export const Avatar = Object.assign(AvatarRoot, {
115
225
  Badge: AvatarBadge,
226
+ Group: AvatarGroup,
116
227
  });
228
+
229
+ export {
230
+ AVATAR_GROUP_OVERLAP_RATIO,
231
+ AVATAR_SIZE_POINTS,
232
+ avatarGroupCount,
233
+ avatarGroupOverlap,
234
+ type AvatarGroupCount,
235
+ type AvatarSizeName,
236
+ } from './avatar-group';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Returns an accessibility action only while the picker is interactive.
3
+ *
4
+ * React Native can still deliver an action to a disabled adjustable view, so
5
+ * the handler has to enforce the same disabled boundary as its touch gesture.
6
+ */
7
+ export function enabledColorPickerAction(
8
+ actionName: string,
9
+ disabled: boolean
10
+ ): string | undefined {
11
+ return disabled ? undefined : actionName;
12
+ }
@@ -108,6 +108,7 @@ import {
108
108
  import { cn } from '../../utils/cn';
109
109
  import { selectionTick } from '../../utils/haptics';
110
110
  import { Popover, type PopoverContentProps } from '../popover';
111
+ import { enabledColorPickerAction } from './color-picker-accessibility';
111
112
 
112
113
  /** Settles a thumb that was moved by something other than a finger. */
113
114
  const TIMING = { duration: 140 } as const;
@@ -586,7 +587,10 @@ const ColorPickerArea = forwardRef<View, ColorPickerAreaProps>(
586
587
  };
587
588
 
588
589
  const onAccessibilityAction = (event: AccessibilityActionEvent) => {
589
- const action = event.nativeEvent.actionName;
590
+ const action = enabledColorPickerAction(
591
+ event.nativeEvent.actionName,
592
+ ctx.disabled
593
+ );
590
594
  if (action === 'increment') nudge('saturation', 1);
591
595
  else if (action === 'decrement') nudge('saturation', -1);
592
596
  else if (action === 'brighter') nudge('brightness', 1);
@@ -829,8 +833,12 @@ const ColorPickerHue = forwardRef<View, ColorPickerHueProps>(
829
833
  disabled={ctx.disabled}
830
834
  accessibilityValue={{ min: 0, max: 360, now: announced, text: `${announced}°` }}
831
835
  onAccessibilityAction={(event) => {
832
- if (event.nativeEvent.actionName === 'increment') nudge(1);
833
- else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
836
+ const action = enabledColorPickerAction(
837
+ event.nativeEvent.actionName,
838
+ ctx.disabled
839
+ );
840
+ if (action === 'increment') nudge(1);
841
+ else if (action === 'decrement') nudge(-1);
834
842
  }}
835
843
  >
836
844
  <LinearGradient
@@ -948,8 +956,12 @@ const ColorPickerAlpha = forwardRef<View, ColorPickerAlphaProps>(
948
956
  text: `${announced}%`,
949
957
  }}
950
958
  onAccessibilityAction={(event) => {
951
- if (event.nativeEvent.actionName === 'increment') nudge(1);
952
- else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
959
+ const action = enabledColorPickerAction(
960
+ event.nativeEvent.actionName,
961
+ ctx.disabled
962
+ );
963
+ if (action === 'increment') nudge(1);
964
+ else if (action === 'decrement') nudge(-1);
953
965
  }}
954
966
  >
955
967
  <Checkerboard />
@@ -1504,8 +1516,12 @@ const ColorPickerBrightness = forwardRef<View, ColorPickerBrightnessProps>(
1504
1516
  text: `${announced}%`,
1505
1517
  }}
1506
1518
  onAccessibilityAction={(event) => {
1507
- if (event.nativeEvent.actionName === 'increment') nudge(1);
1508
- else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
1519
+ const action = enabledColorPickerAction(
1520
+ event.nativeEvent.actionName,
1521
+ ctx.disabled
1522
+ );
1523
+ if (action === 'increment') nudge(1);
1524
+ else if (action === 'decrement') nudge(-1);
1509
1525
  }}
1510
1526
  >
1511
1527
  <LinearGradient
@@ -1667,7 +1683,10 @@ const ColorPickerWheel = forwardRef<View, ColorPickerWheelProps>(
1667
1683
  };
1668
1684
 
1669
1685
  const onAccessibilityAction = (event: AccessibilityActionEvent) => {
1670
- const action = event.nativeEvent.actionName;
1686
+ const action = enabledColorPickerAction(
1687
+ event.nativeEvent.actionName,
1688
+ ctx.disabled
1689
+ );
1671
1690
  if (action === 'increment') nudge('hue', 1);
1672
1691
  else if (action === 'decrement') nudge('hue', -1);
1673
1692
  else if (action === 'saturate') nudge('saturation', 1);
@@ -84,6 +84,9 @@ const DEFAULT_PLACEHOLDER = 'Pick a date and time';
84
84
  */
85
85
  const PANEL_WIDTH = 308;
86
86
 
87
+ /** A stable rule that makes every calendar day unavailable while disabled. */
88
+ const DISABLE_ALL_DATES = () => true;
89
+
87
90
  /** How a `Date` is written on the closed trigger. */
88
91
  function describe(
89
92
  value: Date,
@@ -204,18 +207,23 @@ function DateTimePickerRoot({
204
207
 
205
208
  const setOpen = useCallback(
206
209
  (next: boolean) => {
210
+ if (disabled && next) return;
207
211
  if (!isOpenControlled) setInternalOpen(next);
208
212
  onOpenChange?.(next);
209
213
  },
210
- [isOpenControlled, onOpenChange]
214
+ [disabled, isOpenControlled, onOpenChange]
211
215
  );
212
216
 
213
217
  const commit = useCallback(
214
218
  (next: Date) => {
219
+ // A controlled caller can keep the panel mounted or open while disabled.
220
+ // Guard the value boundary as well as the visible controls so a stale
221
+ // press cannot commit during the same render that disables the picker.
222
+ if (disabled) return;
215
223
  if (!isValueControlled) setInternalValue(next);
216
224
  onValueChange?.(next);
217
225
  },
218
- [isValueControlled, onValueChange]
226
+ [disabled, isValueControlled, onValueChange]
219
227
  );
220
228
 
221
229
  /*
@@ -281,7 +289,7 @@ function DateTimePickerRoot({
281
289
  mode="single"
282
290
  selected={value}
283
291
  onSelect={handleDay}
284
- disabled={disabledDates}
292
+ disabled={disabled ? DISABLE_ALL_DATES : disabledDates}
285
293
  minDate={minDate}
286
294
  maxDate={maxDate}
287
295
  startMonth={minDate}
@@ -66,14 +66,11 @@ import {
66
66
  } from 'react-native';
67
67
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
68
68
  import Animated, {
69
+ Easing,
69
70
  SlideInDown,
70
71
  SlideInLeft,
71
72
  SlideInRight,
72
73
  SlideInUp,
73
- SlideOutDown,
74
- SlideOutLeft,
75
- SlideOutRight,
76
- SlideOutUp,
77
74
  runOnJS,
78
75
  useAnimatedStyle,
79
76
  useSharedValue,
@@ -96,6 +93,9 @@ import {
96
93
  import { cn } from '../../utils/cn';
97
94
 
98
95
  const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const;
96
+ /** How long the panel takes to leave, whether by a drag or a press. */
97
+ const EXIT_DURATION = 200;
98
+
99
99
  /** How far a drag has to travel outward before releasing it dismisses. */
100
100
  const DISMISS_DISTANCE = 80;
101
101
  /** A flick this fast dismisses regardless of how far it got. */
@@ -375,9 +375,11 @@ function DrawerContent({
375
375
  const velocity =
376
376
  (horizontal ? event.velocityX : event.velocityY) * outward;
377
377
  if (travel.value > DISMISS_DISTANCE || velocity > DISMISS_VELOCITY) {
378
- travel.value = withTiming(extent, { duration: 180 }, (finished) => {
379
- if (finished) runOnJS(close)();
380
- });
378
+ // Straight to the exit, without finishing the slide first. The exit
379
+ // starts from wherever `travel` is and carries it the rest of the
380
+ // way, so driving it to `extent` here would be the same distance
381
+ // animated twice.
382
+ runOnJS(close)();
381
383
  } else {
382
384
  travel.value = withSpring(0, SPRING);
383
385
  }
@@ -413,6 +415,43 @@ function DrawerContent({
413
415
  [closeCorner, showClose]
414
416
  );
415
417
 
418
+ /*
419
+ * The slide out is written by hand, because the panel is not always at rest
420
+ * when it starts.
421
+ *
422
+ * A preset animates from the view's *layout* position and applies its own
423
+ * transform, which overrides the one the drag was already using. So a
424
+ * swipe-dismiss played twice: the gesture carried the panel off-screen, and
425
+ * then the exit put it back at zero and slid it out again. Seeding the
426
+ * initial transform from `travel` makes the two one animation — the exit
427
+ * picks the panel up wherever the finger left it and carries it the rest of
428
+ * the way. A press on the close button or the backdrop leaves `travel` at
429
+ * zero, so that path is the same slide it always was.
430
+ *
431
+ * Above the early return, with every other hook. It reads nothing that is
432
+ * only known once the drawer is open, and a hook below that return is one
433
+ * that runs on an open drawer and not on a closed one.
434
+ */
435
+ const exiting = useCallback(() => {
436
+ 'worklet';
437
+ const shift = extent * outward;
438
+ const from = travel.value * outward;
439
+ const timing = { duration: EXIT_DURATION, easing: Easing.out(Easing.quad) };
440
+
441
+ return {
442
+ initialValues: {
443
+ transform: horizontal
444
+ ? [{ translateX: from }, { translateY: 0 }]
445
+ : [{ translateX: 0 }, { translateY: from }],
446
+ },
447
+ animations: {
448
+ transform: horizontal
449
+ ? [{ translateX: withTiming(shift, timing) }, { translateY: 0 }]
450
+ : [{ translateX: 0 }, { translateY: withTiming(shift, timing) }],
451
+ },
452
+ };
453
+ }, [extent, horizontal, outward, travel]);
454
+
416
455
  if (!open) return null;
417
456
 
418
457
  /*
@@ -433,13 +472,6 @@ function DrawerContent({
433
472
  bottom: SlideInDown,
434
473
  }[physical];
435
474
 
436
- const exiting = {
437
- left: SlideOutLeft,
438
- right: SlideOutRight,
439
- top: SlideOutUp,
440
- bottom: SlideOutDown,
441
- }[physical];
442
-
443
475
  /*
444
476
  * The inset and the panel's own padding stack rather than compete.
445
477
  *
@@ -499,7 +531,7 @@ function DrawerContent({
499
531
  .damping(24)
500
532
  .stiffness(260)
501
533
  .mass(0.7)}
502
- exiting={exiting.duration(200)}
534
+ exiting={exiting}
503
535
  accessibilityViewIsModal
504
536
  className={panelVariants({ side, className })}
505
537
  {...props}
@@ -59,6 +59,7 @@ import { IconColorProvider } from '../../icons';
59
59
  import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
60
60
  import { Text, textChildren } from '../../primitives/text';
61
61
  import { Label } from '../label';
62
+ import { inputContentPadding } from './input-content-padding';
62
63
 
63
64
  /** Long enough to read as a transition, short enough not to lag a fast tab. */
64
65
  const FOCUS_DURATION = 150;
@@ -303,6 +304,9 @@ export const Input = forwardRef<TextInput, InputProps>(
303
304
  setEndWidth(event.nativeEvent.layout.width);
304
305
  }, []);
305
306
 
307
+ const startPadding = inputContentPadding(startWidth, !!startContent);
308
+ const endPadding = inputContentPadding(endWidth, !!endContent);
309
+
306
310
  /*
307
311
  * The content is quieter than the value. An icon at the same weight as the
308
312
  * text reads as part of what was typed, and the placeholder colour is
@@ -325,8 +329,8 @@ export const Input = forwardRef<TextInput, InputProps>(
325
329
  borderStyle,
326
330
  // Only the side that has content, and only once it has been
327
331
  // measured — a 0 here would wipe out the field's own padding.
328
- startWidth ? { paddingStart: startWidth } : null,
329
- endWidth ? { paddingEnd: endWidth } : null,
332
+ startPadding ? { paddingStart: startPadding } : null,
333
+ endPadding ? { paddingEnd: endPadding } : null,
330
334
  // Caller styles come last so InputGroup can pad the field around its
331
335
  // decorators, but never last enough to drop the animated border.
332
336
  style,
@@ -0,0 +1,3 @@
1
+ export function inputContentPadding(width: number, present: boolean): number | undefined {
2
+ return present && width > 0 && Number.isFinite(width) ? width : undefined;
3
+ }
@@ -11,7 +11,6 @@ import {
11
11
  useCallback,
12
12
  useContext,
13
13
  useMemo,
14
- useState,
15
14
  type ReactNode,
16
15
  } from 'react';
17
16
  import {
@@ -23,6 +22,12 @@ import {
23
22
  import { tv } from 'tailwind-variants';
24
23
  import { Input, type InputProps } from '../input';
25
24
  import { textChildren } from '../../primitives/text';
25
+ import {
26
+ useInputGroupDecoratorMeasurement,
27
+ useInputGroupMeasurements,
28
+ type InputGroupDecoratorOwner,
29
+ type InputGroupDecoratorSide,
30
+ } from './input-group-measurements';
26
31
 
27
32
  const inputGroupVariants = tv({
28
33
  slots: {
@@ -41,8 +46,15 @@ interface InputGroupState {
41
46
  isDisabled: boolean;
42
47
  prefixWidth: number;
43
48
  suffixWidth: number;
44
- setPrefixWidth: (width: number) => void;
45
- setSuffixWidth: (width: number) => void;
49
+ measureDecorator: (
50
+ side: InputGroupDecoratorSide,
51
+ owner: InputGroupDecoratorOwner,
52
+ width: number
53
+ ) => void;
54
+ removeDecorator: (
55
+ side: InputGroupDecoratorSide,
56
+ owner: InputGroupDecoratorOwner
57
+ ) => void;
46
58
  }
47
59
 
48
60
  const InputGroupContext = createContext<InputGroupState | null>(null);
@@ -61,12 +73,24 @@ export interface InputGroupProps extends ViewProps {
61
73
  const InputGroupRoot = forwardRef<View, InputGroupProps>(
62
74
  ({ className, isDisabled = false, children, ...props }, ref) => {
63
75
  const { root } = inputGroupVariants();
64
- const [prefixWidth, setPrefixWidth] = useState(0);
65
- const [suffixWidth, setSuffixWidth] = useState(0);
76
+ const { prefixWidth, suffixWidth, measureDecorator, removeDecorator } =
77
+ useInputGroupMeasurements();
66
78
 
67
79
  const value = useMemo<InputGroupState>(
68
- () => ({ isDisabled, prefixWidth, suffixWidth, setPrefixWidth, setSuffixWidth }),
69
- [isDisabled, prefixWidth, suffixWidth]
80
+ () => ({
81
+ isDisabled,
82
+ prefixWidth,
83
+ suffixWidth,
84
+ measureDecorator,
85
+ removeDecorator,
86
+ }),
87
+ [
88
+ isDisabled,
89
+ prefixWidth,
90
+ suffixWidth,
91
+ measureDecorator,
92
+ removeDecorator,
93
+ ]
70
94
  );
71
95
 
72
96
  return (
@@ -97,14 +121,18 @@ function createDecorator(side: 'prefix' | 'suffix') {
97
121
  ({ className, isDecorative = false, onLayout, children, ...props }, ref) => {
98
122
  const group = useInputGroup();
99
123
  const slots = inputGroupVariants({ isDisabled: group?.isDisabled ?? false });
100
- const setWidth = side === 'prefix' ? group?.setPrefixWidth : group?.setSuffixWidth;
124
+ const measure = useInputGroupDecoratorMeasurement(
125
+ side,
126
+ group?.measureDecorator,
127
+ group?.removeDecorator
128
+ );
101
129
 
102
130
  const handleLayout = useCallback(
103
131
  (event: LayoutChangeEvent) => {
104
- setWidth?.(event.nativeEvent.layout.width);
132
+ measure(event.nativeEvent.layout.width);
105
133
  onLayout?.(event);
106
134
  },
107
- [setWidth, onLayout]
135
+ [measure, onLayout]
108
136
  );
109
137
 
110
138
  return (
@@ -0,0 +1,98 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ export type InputGroupDecoratorSide = 'prefix' | 'suffix';
4
+ export type InputGroupDecoratorOwner = symbol;
5
+
6
+ type Widths = Record<InputGroupDecoratorSide, number>;
7
+ type Registries = Record<
8
+ InputGroupDecoratorSide,
9
+ Map<InputGroupDecoratorOwner, number>
10
+ >;
11
+
12
+ export interface InputGroupMeasurements {
13
+ prefixWidth: number;
14
+ suffixWidth: number;
15
+ measureDecorator: (
16
+ side: InputGroupDecoratorSide,
17
+ owner: InputGroupDecoratorOwner,
18
+ width: number
19
+ ) => void;
20
+ removeDecorator: (
21
+ side: InputGroupDecoratorSide,
22
+ owner: InputGroupDecoratorOwner
23
+ ) => void;
24
+ }
25
+
26
+ type MeasureDecorator = InputGroupMeasurements['measureDecorator'];
27
+ type RemoveDecorator = InputGroupMeasurements['removeDecorator'];
28
+
29
+ const emptyWidths = (): Widths => ({ prefix: 0, suffix: 0 });
30
+
31
+ function measuredWidth(registry: Map<InputGroupDecoratorOwner, number>) {
32
+ let width = 0;
33
+ for (const candidate of registry.values()) width = Math.max(width, candidate);
34
+ return width;
35
+ }
36
+
37
+ /** Keeps measured padding tied to the decorators that are still mounted. */
38
+ export function useInputGroupMeasurements(): InputGroupMeasurements {
39
+ const registries = useRef<Registries>({ prefix: new Map(), suffix: new Map() });
40
+ const [widths, setWidths] = useState<Widths>(emptyWidths);
41
+
42
+ const commitWidth = useCallback((side: InputGroupDecoratorSide) => {
43
+ const width = measuredWidth(registries.current[side]);
44
+ setWidths((current) =>
45
+ current[side] === width ? current : { ...current, [side]: width }
46
+ );
47
+ }, []);
48
+
49
+ const measureDecorator = useCallback(
50
+ (side: InputGroupDecoratorSide, owner: InputGroupDecoratorOwner, width: number) => {
51
+ const registry = registries.current[side];
52
+ if (Number.isFinite(width) && width > 0) registry.set(owner, width);
53
+ else registry.delete(owner);
54
+ commitWidth(side);
55
+ },
56
+ [commitWidth]
57
+ );
58
+
59
+ const removeDecorator = useCallback(
60
+ (side: InputGroupDecoratorSide, owner: InputGroupDecoratorOwner) => {
61
+ if (!registries.current[side].delete(owner)) return;
62
+ commitWidth(side);
63
+ },
64
+ [commitWidth]
65
+ );
66
+
67
+ return {
68
+ prefixWidth: widths.prefix,
69
+ suffixWidth: widths.suffix,
70
+ measureDecorator,
71
+ removeDecorator,
72
+ };
73
+ }
74
+
75
+ /** Gives one mounted decorator an owner token and unregisters only that owner. */
76
+ export function useInputGroupDecoratorMeasurement(
77
+ side: InputGroupDecoratorSide,
78
+ measureDecorator: MeasureDecorator | undefined,
79
+ removeDecorator: RemoveDecorator | undefined
80
+ ) {
81
+ const owner = useRef<InputGroupDecoratorOwner>(Symbol(side));
82
+ const width = useRef(0);
83
+
84
+ useLayoutEffect(() => {
85
+ // Re-register the last layout if React replays effects in Strict Mode or
86
+ // the decorator moves between InputGroup providers.
87
+ if (width.current > 0) measureDecorator?.(side, owner.current, width.current);
88
+ return () => removeDecorator?.(side, owner.current);
89
+ }, [measureDecorator, removeDecorator, side]);
90
+
91
+ return useCallback(
92
+ (nextWidth: number) => {
93
+ width.current = nextWidth;
94
+ measureDecorator?.(side, owner.current, nextWidth);
95
+ },
96
+ [measureDecorator, side]
97
+ );
98
+ }