@jobber/components-native 0.113.1 → 0.113.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jobber/components-native",
3
- "version": "0.113.1",
3
+ "version": "0.113.2",
4
4
  "license": "MIT",
5
5
  "description": "React Native implementation of Atlantis",
6
6
  "repository": {
@@ -124,5 +124,5 @@
124
124
  "react-native-screens": ">=4.18.0",
125
125
  "react-native-svg": ">=12.0.0"
126
126
  },
127
- "gitHead": "06559aba2040cdb7808c71ac988826c60c9e7cd9"
127
+ "gitHead": "106e281adc9a3b0e87e125e1a9f1c945ce2de3cd"
128
128
  }
@@ -71,6 +71,52 @@ describe("ActivityIndicator", () => {
71
71
  expect(flattenedStyle.width).toBe(44);
72
72
  expect(flattenedStyle.height).toBe(44);
73
73
  });
74
+
75
+ it("uses a fixed-length arc for small (no Layer 2/3 wrappers)", () => {
76
+ const { getByTestId } = render(<ActivityIndicator size="small" />);
77
+ const outer = getByTestId(testId).children[0] as ReactTestInstance;
78
+ const outerChildren = React.Children.toArray(outer.props.children);
79
+
80
+ function typeName(node: unknown): string {
81
+ if (!node || typeof node !== "object") return "";
82
+ const element = node as {
83
+ type?: { displayName?: string; name?: string };
84
+ };
85
+
86
+ return element.type?.displayName || element.type?.name || "";
87
+ }
88
+
89
+ // Small: outer rotation wrapper → Svg directly (no inner Layer 2 view).
90
+ expect(
91
+ outerChildren.some(child => typeName(child).startsWith("Svg")),
92
+ ).toBe(true);
93
+
94
+ function findArcCircle(node: unknown): ReactTestInstance | undefined {
95
+ if (!node || typeof node !== "object") return undefined;
96
+ const element = node as ReactTestInstance;
97
+ const name = typeName(element);
98
+
99
+ if (
100
+ (name === "Circle" || name === "RNSVGCircle") &&
101
+ element.props.strokeDasharray === "40, 200"
102
+ ) {
103
+ return element;
104
+ }
105
+
106
+ const kids = React.Children.toArray(element.props?.children ?? []);
107
+
108
+ for (const kid of kids) {
109
+ const found = findArcCircle(kid);
110
+ if (found) return found;
111
+ }
112
+
113
+ return undefined;
114
+ }
115
+
116
+ const arc = findArcCircle(outer);
117
+ expect(arc).toBeTruthy();
118
+ expect(arc?.props.strokeDashoffset).toBe(0);
119
+ });
74
120
  });
75
121
 
76
122
  describe('deprecated size="large" alias', () => {
@@ -58,6 +58,8 @@ export interface ActivityIndicatorProps {
58
58
  readonly testID?: string;
59
59
  }
60
60
 
61
+ type ResolvedSize = "small" | "base";
62
+
61
63
  const SIZE_PX = { small: 28, base: 44 } as const;
62
64
  // Stroke widths are expressed in viewBox units (the SVG is 48 across).
63
65
  // The small variant uses a thicker stroke in viewBox units to compensate
@@ -73,9 +75,34 @@ const RADIUS = 20;
73
75
  // one stroke segment is visible at a time.
74
76
  const GAP = 200;
75
77
 
78
+ /**
79
+ * Per-size motion profile. Adding a size is an additive map entry — not a
80
+ * new boolean branch. `layers: 1` is Layer 1 only (fixed arc); `layers: 3`
81
+ * runs the full Material 3 indeterminate ring (Layers 1–3).
82
+ *
83
+ * Small matches web's ActivityIndicator.module.css (`.small .svg` /
84
+ * `.small .arc`): Layer 1 only, spun ~1.8× faster. Web hardcodes 1000ms
85
+ * (≈1803ms ÷ 1.8); we match that absolute duration.
86
+ */
87
+ const MOTION_BY_SIZE = {
88
+ small: {
89
+ layers: 1 as const,
90
+ linearRotateMs: 1000,
91
+ dasharray: `40, ${GAP}`,
92
+ },
93
+ base: {
94
+ layers: 3 as const,
95
+ linearRotateMs: tokens["timing-indicator--linear-rotate"],
96
+ },
97
+ } satisfies Record<
98
+ ResolvedSize,
99
+ | { layers: 1; linearRotateMs: number; dasharray: string }
100
+ | { layers: 3; linearRotateMs: number }
101
+ >;
102
+
76
103
  function resolveLegacySize(
77
104
  size: "small" | "base" | DeprecatedSize,
78
- ): "small" | "base" {
105
+ ): ResolvedSize {
79
106
  if (size === "large") {
80
107
  if (__DEV__) {
81
108
  console.warn(
@@ -143,6 +170,7 @@ export function ActivityIndicator({
143
170
 
144
171
  return (
145
172
  <FullMotionRing
173
+ size={resolvedSize}
146
174
  pixelSize={pixelSize}
147
175
  strokeWidth={strokeWidth}
148
176
  arcColor={themeTokens["color-icon"]}
@@ -170,11 +198,13 @@ interface RingProps {
170
198
  }
171
199
 
172
200
  interface FullMotionRingProps extends RingProps {
201
+ readonly size: ResolvedSize;
173
202
  readonly arcColor: string;
174
203
  readonly trackColor: string;
175
204
  }
176
205
 
177
206
  function FullMotionRing({
207
+ size,
178
208
  pixelSize,
179
209
  strokeWidth,
180
210
  arcColor,
@@ -183,25 +213,32 @@ function FullMotionRing({
183
213
  testID,
184
214
  a11yProps,
185
215
  }: FullMotionRingProps) {
216
+ const motion = MOTION_BY_SIZE[size];
217
+
186
218
  // Layer 1 — outer linear rotation. Continuous, constant speed.
187
219
  const outerRotate = useSharedValue(0);
188
220
  // Layer 2 — inner 8-phase rotation. Eight 135° eased segments per cycle
189
221
  // (totalling 1080° over `timing-indicator--cycle`) reproduce the
190
- // canonical Material Web "8-phase" rhythm.
222
+ // canonical Material Web "8-phase" rhythm. Only when motion.layers === 3.
191
223
  const innerRotate = useSharedValue(0);
192
224
  // Layer 3 — arc length and dash offset. Drives the visible arc growing
193
225
  // from ~1 viewBox-unit to ~90 viewBox-units and sliding around the ring.
226
+ // Only when motion.layers === 3; fixed-arc sizes use motion.dasharray.
194
227
  const arcProgress = useSharedValue(0);
195
228
 
196
229
  useEffect(() => {
197
230
  outerRotate.value = withRepeat(
198
231
  withTiming(360, {
199
- duration: tokens["timing-indicator--linear-rotate"],
232
+ duration: motion.linearRotateMs,
200
233
  easing: Easing.linear,
201
234
  }),
202
235
  -1,
203
236
  );
204
237
 
238
+ if (motion.layers === 1) {
239
+ return;
240
+ }
241
+
205
242
  const phaseDuration = tokens["timing-indicator--cycle"] / 8;
206
243
  const phaseEasing = Easing.bezier(0.4, 0, 0.2, 1);
207
244
  innerRotate.value = withRepeat(
@@ -227,7 +264,7 @@ function FullMotionRing({
227
264
  );
228
265
  // We intentionally do not include the shared values in the dependency
229
266
  // array — they are stable references created by useSharedValue.
230
- }, []);
267
+ }, [motion]);
231
268
 
232
269
  const outerStyle = useAnimatedStyle(() => ({
233
270
  transform: [{ rotate: `${outerRotate.value}deg` }],
@@ -268,6 +305,62 @@ function FullMotionRing({
268
305
  };
269
306
  });
270
307
 
308
+ const track = (
309
+ <Circle
310
+ cx={CENTER}
311
+ cy={CENTER}
312
+ r={RADIUS}
313
+ fill="none"
314
+ stroke={trackColor}
315
+ strokeWidth={strokeWidth}
316
+ />
317
+ );
318
+
319
+ const ring =
320
+ motion.layers === 1 ? (
321
+ // Fixed-length arc, Layer 1 rotation only — no Layer 2 wrapper.
322
+ <Svg
323
+ width={pixelSize}
324
+ height={pixelSize}
325
+ viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}
326
+ >
327
+ {track}
328
+ <Circle
329
+ cx={CENTER}
330
+ cy={CENTER}
331
+ r={RADIUS}
332
+ fill="none"
333
+ stroke={arcColor}
334
+ strokeWidth={strokeWidth}
335
+ strokeLinecap="round"
336
+ strokeDasharray={motion.dasharray}
337
+ strokeDashoffset={0}
338
+ />
339
+ </Svg>
340
+ ) : (
341
+ <Animated.View
342
+ style={[{ width: pixelSize, height: pixelSize }, innerStyle]}
343
+ >
344
+ <Svg
345
+ width={pixelSize}
346
+ height={pixelSize}
347
+ viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}
348
+ >
349
+ {track}
350
+ <AnimatedCircle
351
+ cx={CENTER}
352
+ cy={CENTER}
353
+ r={RADIUS}
354
+ fill="none"
355
+ stroke={arcColor}
356
+ strokeWidth={strokeWidth}
357
+ strokeLinecap="round"
358
+ animatedProps={arcAnimatedProps}
359
+ />
360
+ </Svg>
361
+ </Animated.View>
362
+ );
363
+
271
364
  return (
272
365
  <Animated.View
273
366
  style={[{ justifyContent: "center", alignItems: "center" }, style]}
@@ -277,34 +370,7 @@ function FullMotionRing({
277
370
  <Animated.View
278
371
  style={[{ width: pixelSize, height: pixelSize }, outerStyle]}
279
372
  >
280
- <Animated.View
281
- style={[{ width: pixelSize, height: pixelSize }, innerStyle]}
282
- >
283
- <Svg
284
- width={pixelSize}
285
- height={pixelSize}
286
- viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}
287
- >
288
- <Circle
289
- cx={CENTER}
290
- cy={CENTER}
291
- r={RADIUS}
292
- fill="none"
293
- stroke={trackColor}
294
- strokeWidth={strokeWidth}
295
- />
296
- <AnimatedCircle
297
- cx={CENTER}
298
- cy={CENTER}
299
- r={RADIUS}
300
- fill="none"
301
- stroke={arcColor}
302
- strokeWidth={strokeWidth}
303
- strokeLinecap="round"
304
- animatedProps={arcAnimatedProps}
305
- />
306
- </Svg>
307
- </Animated.View>
373
+ {ring}
308
374
  </Animated.View>
309
375
  </Animated.View>
310
376
  );
@@ -101,36 +101,60 @@ navigation order beyond the initial announcement.
101
101
 
102
102
  ## Animation Model
103
103
 
104
- The default presentation renders Material Design 3's three-layer indeterminate
105
- ring. The implementation uses `react-native-svg` for the ring geometry and
106
- `react-native-reanimated` 3 for the motion — all worklet-driven so the animation
107
- runs on the UI thread regardless of JS thread load.
104
+ The default (`base`) presentation renders Material Design 3's three-layer
105
+ indeterminate ring. The implementation uses `react-native-svg` for the ring
106
+ geometry and `react-native-reanimated` 3 for the motion — all worklet-driven so
107
+ the animation runs on the UI thread regardless of JS thread load.
108
108
 
109
- The three concurrent animations:
109
+ The three concurrent animations (base only):
110
110
 
111
111
  1. A wrapping `Animated.View` rotates continuously at a constant linear speed
112
- driven by `tokens["timing-indicator-linear-rotate"]` (1568ms per revolution).
113
- This is the "outer" rotation that prevents the ring from ever sitting still.
112
+ driven by `tokens["timing-indicator--linear-rotate"]` (1568ms per
113
+ revolution). This is the "outer" rotation that prevents the ring from ever
114
+ sitting still.
114
115
 
115
116
  2. An inner transform rotates through 1080° per cycle on a
116
- `tokens["timing-indicator-cycle"]` (5332ms) cubic-bezier easing curve
117
+ `tokens["timing-indicator--cycle"]` (5332ms) cubic-bezier easing curve
117
118
  approximating Material Web's `indicatorRotateArc` keyframe. Combined with
118
119
  Layer 1, this produces the eight-phase rhythm that reads as non-periodic.
119
120
 
120
121
  3. An animated `<Circle>` (via `Animated.createAnimatedComponent(Circle)`) has
121
122
  its `strokeDasharray` and `strokeDashoffset` driven by `useAnimatedProps` on
122
- a `tokens["timing-indicator-arc"]` (1333ms) sub-cycle, so the visible arc
123
- grows from "barely visible" to roughly 270° of the ring and shrinks back. A
124
- second arc renders 180° out of phase.
123
+ a `tokens["timing-indicator--arc"]` (1333ms) sub-cycle, so the visible arc
124
+ grows from "barely visible" to roughly 270° of the ring and shrinks back.
125
125
 
126
126
  A static `<Circle>` of the same dimensions renders the track ring beneath the
127
127
  active arcs, filled with `themeTokens["color-surface--active"]` so the track
128
128
  adapts to whichever surface (`surface`, `surface--background`,
129
129
  `surface--background--subtle`) the indicator is placed on.
130
130
 
131
- The literal durations match Material Web's published progress motion spec; the
132
- same values ship from `@jobber/design`'s `timing.tokens.json` to both web and
133
- mobile so the rhythm is identical across platforms.
131
+ ### Size motion config
132
+
133
+ Motion timing is looked up from `MOTION_BY_SIZE` (alongside `SIZE_PX` /
134
+ `STROKE_WIDTH`), not from size-name booleans. Each entry declares `layers` (`1`
135
+ = fixed arc / Layer 1 only; `3` = full Material ring), `linearRotateMs`, and —
136
+ for fixed-arc sizes — a static `dasharray`. A future size is an additive map
137
+ entry rather than nested `isSmall` / `isLarge` branches.
138
+
139
+ ### Small motion (parity with web)
140
+
141
+ `size="small"` deliberately does **not** run Layers 2 and 3. Matching web's
142
+ `ActivityIndicator.module.css` (`.small .svg` / `.small .arc`):
143
+
144
+ - Only Layer 1 runs — a fixed-length arc (`strokeDasharray: 40, 200`) spun by
145
+ the outer rotation.
146
+ - Layer 1 duration is `1000ms` (~1.8× faster than web's base linear rotate), so
147
+ the simpler single arc still feels lively.
148
+ - The 8-phase inner rotation and expand/contract arc are omitted because that
149
+ layered motion reads as too busy at 28px.
150
+
151
+ This keeps FormatFile grid thumbnails and other small call sites visually
152
+ aligned with web's small indicator, not just size-matched.
153
+
154
+ The literal base durations match Material Web's published progress motion spec;
155
+ the same values ship from `@jobber/design`'s `timing.tokens.json` to both web
156
+ and mobile so the base rhythm is identical across platforms. The small spin
157
+ duration is a CSS literal on web and a matching constant on native (`1000ms`).
134
158
 
135
159
  ## Reduced Motion
136
160
 
@@ -156,10 +180,10 @@ shipped — the original 0.55 lower bound was too subtle to read as activity.
156
180
 
157
181
  ## Sizing
158
182
 
159
- | `size` | Outer diameter | Stroke width |
160
- | ------- | -------------- | ----------------------------------- |
161
- | `small` | 28 px | proportional to web's small variant |
162
- | `base` | 44 px | proportional to web's base variant |
183
+ | `size` | Outer diameter | Stroke width | Motion |
184
+ | ------- | -------------- | ----------------------------------- | -------------------------------------------- |
185
+ | `small` | 28 px | proportional to web's small variant | Layer 1 only; fixed arc; 1000ms spin |
186
+ | `base` | 44 px | proportional to web's base variant | Full three-layer Material indeterminate ring |
163
187
 
164
188
  Sizes are unified across iOS, Android, and web. The component deliberately does
165
189
  not adopt iOS-native sizing (`UIActivityIndicatorView`'s ~20/~37 px) — the
@@ -193,6 +217,7 @@ Documented divergences:
193
217
  | Test id | (no equivalent; tests use `getByRole` / `getByText`) | `testID` |
194
218
  | Accessibility plumbing | `role="status"` + visually-hidden text + announce-on-mount fix for VoiceOver | `accessibilityRole="progressbar"` + `accessibilityState={{ busy: true }}` + `AccessibilityInfo.announceForAccessibility` |
195
219
  | Reduced motion detection | CSS `@media (prefers-reduced-motion: reduce)` | `useReducedMotion()` from `react-native-reanimated` |
220
+ | Small motion | Layer 1 only; fixed arc; 1000ms spin (CSS) | Same behavior (Reanimated); matches web intentionally |
196
221
 
197
222
  The divergences are intentional — each platform follows its own conventions on
198
223
  the user-facing side while the underlying intent and visual outcome match.
@@ -128,6 +128,7 @@ export function MediaView({
128
128
  file={file}
129
129
  onUploadComplete={onUploadComplete}
130
130
  styles={styles}
131
+ styleInGrid={styleInGrid}
131
132
  />
132
133
  </ImageBackground>
133
134
  </View>
@@ -141,6 +142,7 @@ interface OverlayProps {
141
142
  readonly file: FormattedFile;
142
143
  readonly onUploadComplete: () => void;
143
144
  readonly styles: ReturnType<typeof useStyles>;
145
+ readonly styleInGrid: boolean;
144
146
  }
145
147
 
146
148
  function Overlay({
@@ -150,8 +152,11 @@ function Overlay({
150
152
  file,
151
153
  onUploadComplete,
152
154
  styles,
155
+ styleInGrid,
153
156
  }: OverlayProps) {
154
- if (isLoading) return <ActivityIndicator />;
157
+ if (isLoading) {
158
+ return <ActivityIndicator size={styleInGrid ? "small" : "base"} />;
159
+ }
155
160
 
156
161
  if (hasError) return <ErrorOverlay styles={styles} />;
157
162
 
@@ -263,6 +268,7 @@ function VideoPlaceholder({
263
268
  file={file}
264
269
  onUploadComplete={onUploadComplete}
265
270
  styles={styles}
271
+ styleInGrid={styleInGrid}
266
272
  />
267
273
  </View>
268
274
  );
@@ -275,8 +281,12 @@ function PlaceholderStatusOverlay({
275
281
  file,
276
282
  onUploadComplete,
277
283
  styles,
284
+ styleInGrid,
278
285
  }: OverlayProps) {
279
- if (isLoading) return <ActivityIndicator />;
286
+ if (isLoading) {
287
+ return <ActivityIndicator size={styleInGrid ? "small" : "base"} />;
288
+ }
289
+
280
290
  if (hasError) return <ErrorOverlay styles={styles} />;
281
291
 
282
292
  if (showOverlay) {