@jobber/components-native 0.104.3-TAYLORtai-d66f381.18 → 0.105.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.
@@ -1,8 +1,57 @@
1
1
  # Activity Indicator
2
2
 
3
- `ActivityIndicator` is used to communicate an **indeterminate** activity the
4
- user cannot directly control or measure — loading, fetching, or waiting on an
5
- external system.
3
+ `ActivityIndicator` communicates an **indeterminate** activity the user cannot
4
+ directly control or measure — loading, fetching, or waiting on an external
5
+ system.
6
+
7
+ Use `ActivityIndicator` when the loading time or fraction of progress is
8
+ unknown. If you know the fraction of progress (for example, "3 of 4 files
9
+ uploaded"), use `ProgressBar` instead. A `ProgressIndicator` counterpart that
10
+ will subsume `ProgressBar` under the new Indicators naming is forthcoming.
11
+
12
+ ## Design & usage guidelines
13
+
14
+ The default `ActivityIndicator` size is `base` (44px) and can be used in most
15
+ cases. The `small` size (28px) should be used on individual elements of an
16
+ interface (e.g. inside a `Button` or `Card`) or when sitting next to short
17
+ inline text.
18
+
19
+ Layout is the consumer's responsibility — `ActivityIndicator` does not expose an
20
+ `inline` prop. Place it inside your own flex / inline-block container, or pass a
21
+ `style`, to control surrounding layout.
22
+
23
+ ## Accessibility
24
+
25
+ `ActivityIndicator` announces itself to assistive technology on every platform:
26
+
27
+ * `accessibilityRole="progressbar"` and `accessibilityState={{ busy: true }}`
28
+ mark the component as an ongoing indeterminate activity.
29
+ * `accessibilityLabel` defaults to a localized `"Loading"` string via
30
+ `useAtlantisI18n("loading")`. Pass a custom `accessibilityLabel` to describe
31
+ the specific activity (e.g. `accessibilityLabel="Uploading file"`).
32
+ * On mount and whenever the label changes, the component calls
33
+ `AccessibilityInfo.announceForAccessibility` so VoiceOver and TalkBack
34
+ announce the label once.
35
+
36
+ The indicator is not focusable and is not in the accessibility navigation order
37
+ beyond the mount-time announcement.
38
+
39
+ ### Reduced motion
40
+
41
+ When the operating system reports the Reduce Motion accessibility setting, the
42
+ indicator hides the rocking and rotating layers, repaints a single static ring
43
+ in the icon foreground colour, and gently pulses its opacity so the indicator
44
+ still reads as "busy" without rotational motion. Detection is reactive — the
45
+ component responds to OS toggles without requiring a remount.
46
+
47
+ ## Cross-platform parity
48
+
49
+ `ActivityIndicator` renders the same Material Design 3 three-layer indeterminate
50
+ ring on iOS, Android, and web (`@jobber/components`). The visual identity,
51
+ sizes, motion durations, and reduced-motion fallback are unified across
52
+ platforms. Public prop names diverge to follow each platform's native
53
+ conventions (`accessibilityLabel` here vs `ariaLabel` on web; `style` here vs
54
+ `className` + `style` on web).
6
55
 
7
56
  Use `ActivityIndicator` when the loading time or fraction of progress is
8
57
  unknown. If you know the fraction of progress (for example, "3 of 4 files
@@ -61,96 +110,7 @@ component from React Native.
61
110
 
62
111
  | Prop | Type | Required | Default | Description |
63
112
  |------|------|----------|---------|-------------|
64
- | `accessibilityActions` | `readonly Readonly<{ name: string; label?: string; }>[]` | No | — | Provides an array of custom actions available for accessibility. |
65
- | `accessibilityElementsHidden` | `boolean` | No | | A Boolean value indicating whether the accessibility elements contained within this accessibility element are hidden ... |
66
- | `accessibilityHint` | `string` | No | — | An accessibility hint helps users understand what will happen when they perform an action on the accessibility elemen... |
67
- | `accessibilityIgnoresInvertColors` | `boolean` | No | | https://reactnative.dev/docs/accessibility#accessibilityignoresinvertcolorsios @platform ios |
68
- | `accessibilityLabel` | `string` | No | — | Overrides the text that's read by the screen reader when the user interacts with the element. By default, the label i... |
69
- | `accessibilityLabelledBy` | `string | string[]` | No | — | Identifies the element that labels the element it is applied to. When the assistive technology focuses on the compone... |
70
- | `accessibilityLanguage` | `string` | No | — | By using the accessibilityLanguage property, the screen reader will understand which language to use while reading th... |
71
- | `accessibilityLargeContentTitle` | `string` | No | — | When `accessibilityShowsLargeContentViewer` is set, this string will be used as title for the large content viewer. h... |
72
- | `accessibilityLiveRegion` | `"assertive" | "none" | "polite"` | No | — | Indicates to accessibility services whether the user should be notified when this view changes. Works for Android API... |
73
- | `accessibilityRespondsToUserInteraction` | `boolean` | No | — | Blocks the user from interacting with the component through keyboard while still allowing screen reader to interact w... |
74
- | `accessibilityRole` | `AccessibilityRole` | No | — | Accessibility Role tells a person using either VoiceOver on iOS or TalkBack on Android the type of element that is fo... |
75
- | `accessibilityShowsLargeContentViewer` | `boolean` | No | — | A Boolean value that indicates whether or not to show the item in the large content viewer. Available on iOS 13.0+ ht... |
76
- | `accessibilityState` | `AccessibilityState` | No | — | Accessibility State tells a person using either VoiceOver on iOS or TalkBack on Android the state of the element curr... |
77
- | `accessibilityValue` | `AccessibilityValue` | No | — | Represents the current value of a component. It can be a textual description of a component's value, or for range-bas... |
78
- | `accessibilityViewIsModal` | `boolean` | No | — | A Boolean value indicating whether VoiceOver should ignore the elements within views that are siblings of the receive... |
79
- | `accessible` | `boolean` | No | — | When true, indicates that the view is an accessibility element. By default, all the touchable elements are accessible. |
80
- | `animating` | `boolean` | No | — | Whether to show the indicator (true, the default) or hide it (false). |
81
- | `aria-busy` | `boolean` | No | — | alias for accessibilityState see https://reactnative.dev/docs/accessibility#accessibilitystate |
82
- | `aria-checked` | `"mixed" | boolean` | No | — | |
83
- | `aria-disabled` | `boolean` | No | — | |
84
- | `aria-expanded` | `boolean` | No | — | |
85
- | `aria-hidden` | `boolean` | No | — | A value indicating whether the accessibility elements contained within this accessibility element are hidden. |
86
- | `aria-label` | `string` | No | — | Alias for accessibilityLabel https://reactnative.dev/docs/view#accessibilitylabel https://github.com/facebook/react-... |
87
- | `aria-labelledby` | `string` | No | — | Identifies the element that labels the element it is applied to. When the assistive technology focuses on the compone... |
88
- | `aria-live` | `"assertive" | "off" | "polite"` | No | — | Indicates to accessibility services whether the user should be notified when this view changes. Works for Android API... |
89
- | `aria-modal` | `boolean` | No | — | |
90
- | `aria-selected` | `boolean` | No | — | |
91
- | `aria-valuemax` | `number` | No | — | |
92
- | `aria-valuemin` | `number` | No | — | |
93
- | `aria-valuenow` | `number` | No | — | |
94
- | `aria-valuetext` | `string` | No | — | |
95
- | `collapsable` | `boolean` | No | — | Views that are only used to layout their children or otherwise don't draw anything may be automatically removed from ... |
96
- | `collapsableChildren` | `boolean` | No | — | Setting to false prevents direct children of the view from being removed from the native view hierarchy, similar to t... |
97
- | `color` | `ColorValue` | No | — | The foreground color of the spinner (default is gray). |
98
- | `focusable` | `boolean` | No | — | Whether this `View` should be focusable with a non-touch input device, eg. receive focus with a hardware keyboard. |
99
- | `hasTVPreferredFocus` | `boolean` | No | — | *(Apple TV only)* May be set to true to force the Apple TV focus engine to move focus to this view. @platform ios @de... |
100
- | `hidesWhenStopped` | `boolean` | No | — | Whether the indicator should hide when not animating (true by default). |
101
- | `hitSlop` | `Insets | number` | No | — | This defines how far a touch event can start away from the view. Typical interface guidelines recommend touch targets... |
102
- | `id` | `string` | No | — | Used to reference react managed views from native code. |
103
- | `importantForAccessibility` | `"auto" | "no" | "no-hide-descendants" | "yes"` | No | — | [Android] Controlling if a view fires accessibility events and if it is reported to accessibility services. |
104
- | `isTVSelectable` | `boolean` | No | — | *(Apple TV only)* When set to true, this view will be focusable and navigable using the Apple TV remote. @platform ios |
105
- | `nativeID` | `string` | No | — | Used to reference react managed views from native code. |
106
- | `needsOffscreenAlphaCompositing` | `boolean` | No | — | Whether this view needs to rendered offscreen and composited with an alpha in order to preserve 100% correct colors a... |
107
- | `onAccessibilityAction` | `(event: AccessibilityActionEvent) => void` | No | — | When `accessible` is true, the system will try to invoke this function when the user performs an accessibility custom... |
108
- | `onAccessibilityEscape` | `() => void` | No | — | When accessible is true, the system will invoke this function when the user performs the escape gesture (scrub with t... |
109
- | `onAccessibilityTap` | `() => void` | No | — | When `accessible` is true, the system will try to invoke this function when the user performs accessibility tap gestu... |
110
- | `onBlur` | `(e: BlurEvent) => void` | No | — | Callback that is called when the view is blurred. Note: This will only be called if the view is focusable. |
111
- | `onFocus` | `(e: FocusEvent) => void` | No | — | Callback that is called when the view is focused. Note: This will only be called if the view is focusable. |
112
- | `onLayout` | `(event: LayoutChangeEvent) => void` | No | — | Invoked on mount and layout changes with {nativeEvent: { layout: {x, y, width, height}}}. |
113
- | `onMagicTap` | `() => void` | No | — | When accessible is true, the system will invoke this function when the user performs the magic tap gesture. @platform... |
114
- | `onMoveShouldSetResponder` | `(event: GestureResponderEvent) => boolean` | No | — | Called for every touch move on the View when it is not the responder: does this view want to "claim" touch responsive... |
115
- | `onMoveShouldSetResponderCapture` | `(event: GestureResponderEvent) => boolean` | No | — | onStartShouldSetResponder and onMoveShouldSetResponder are called with a bubbling pattern, where the deepest node is ... |
116
- | `onPointerCancel` | `(event: PointerEvent) => void` | No | — | |
117
- | `onPointerCancelCapture` | `(event: PointerEvent) => void` | No | — | |
118
- | `onPointerDown` | `(event: PointerEvent) => void` | No | — | |
119
- | `onPointerDownCapture` | `(event: PointerEvent) => void` | No | — | |
120
- | `onPointerEnter` | `(event: PointerEvent) => void` | No | — | |
121
- | `onPointerEnterCapture` | `(event: PointerEvent) => void` | No | — | |
122
- | `onPointerLeave` | `(event: PointerEvent) => void` | No | — | |
123
- | `onPointerLeaveCapture` | `(event: PointerEvent) => void` | No | — | |
124
- | `onPointerMove` | `(event: PointerEvent) => void` | No | — | |
125
- | `onPointerMoveCapture` | `(event: PointerEvent) => void` | No | — | |
126
- | `onPointerUp` | `(event: PointerEvent) => void` | No | — | |
127
- | `onPointerUpCapture` | `(event: PointerEvent) => void` | No | — | |
128
- | `onResponderEnd` | `(event: GestureResponderEvent) => void` | No | — | If the View returns true and attempts to become the responder, one of the following will happen: |
129
- | `onResponderGrant` | `(event: GestureResponderEvent) => void` | No | — | The View is now responding for touch events. This is the time to highlight and show the user what is happening |
130
- | `onResponderMove` | `(event: GestureResponderEvent) => void` | No | — | The user is moving their finger |
131
- | `onResponderReject` | `(event: GestureResponderEvent) => void` | No | — | Something else is the responder right now and will not release it |
132
- | `onResponderRelease` | `(event: GestureResponderEvent) => void` | No | — | Fired at the end of the touch, ie "touchUp" |
133
- | `onResponderStart` | `(event: GestureResponderEvent) => void` | No | — | |
134
- | `onResponderTerminate` | `(event: GestureResponderEvent) => void` | No | — | The responder has been taken from the View. Might be taken by other views after a call to onResponderTerminationReque... |
135
- | `onResponderTerminationRequest` | `(event: GestureResponderEvent) => boolean` | No | — | Something else wants to become responder. Should this view release the responder? Returning true allows release |
136
- | `onStartShouldSetResponder` | `(event: GestureResponderEvent) => boolean` | No | — | Does this view want to become responder on the start of a touch? |
137
- | `onStartShouldSetResponderCapture` | `(event: GestureResponderEvent) => boolean` | No | — | onStartShouldSetResponder and onMoveShouldSetResponder are called with a bubbling pattern, where the deepest node is ... |
138
- | `onTouchCancel` | `(event: GestureResponderEvent) => void` | No | — | |
139
- | `onTouchEnd` | `(event: GestureResponderEvent) => void` | No | — | |
140
- | `onTouchEndCapture` | `(event: GestureResponderEvent) => void` | No | — | |
141
- | `onTouchMove` | `(event: GestureResponderEvent) => void` | No | — | |
142
- | `onTouchStart` | `(event: GestureResponderEvent) => void` | No | — | |
143
- | `pointerEvents` | `"auto" | "box-none" | "box-only" | "none"` | No | — | In the absence of auto property, none is much like CSS's none value. box-none is as if you had applied the CSS class:... |
144
- | `removeClippedSubviews` | `boolean` | No | — | This is a special performance property exposed by RCTView and is useful for scrolling content when there are many sub... |
145
- | `renderToHardwareTextureAndroid` | `boolean` | No | — | Whether this view should render itself (and all of its children) into a single hardware texture on the GPU. On Andro... |
146
- | `role` | `Role` | No | — | Indicates to accessibility services to treat UI component like a specific role. |
147
- | `screenReaderFocusable` | `boolean` | No | — | Enables the view to be screen reader focusable, not keyboard focusable. @platform android |
148
- | `shouldRasterizeIOS` | `boolean` | No | — | Whether this view should be rendered as a bitmap before compositing. On iOS, this is useful for animations and inter... |
149
- | `size` | `"large" | "small" | number` | No | — | Size of the indicator. Small has a height of 20, large has a height of 36. enum('small', 'large') |
150
- | `style` | `StyleProp<ViewStyle>` | No | — | |
151
- | `tabIndex` | `-1 | 0` | No | — | Indicates whether this `View` should be focusable with a non-touch input device, eg. receive focus with a hardware ke... |
152
- | `testID` | `string` | No | — | Used to locate this view in end-to-end tests. |
153
- | `tvParallaxMagnification` | `number` | No | — | *(Apple TV only)* May be used to change the appearance of the Apple TV parallax effect when this view goes in or out ... |
154
- | `tvParallaxShiftDistanceX` | `number` | No | — | *(Apple TV only)* May be used to change the appearance of the Apple TV parallax effect when this view goes in or out ... |
155
- | `tvParallaxShiftDistanceY` | `number` | No | — | *(Apple TV only)* May be used to change the appearance of the Apple TV parallax effect when this view goes in or out ... |
156
- | `tvParallaxTiltAngle` | `number` | No | — | *(Apple TV only)* May be used to change the appearance of the Apple TV parallax effect when this view goes in or out ... |
113
+ | `accessibilityLabel` | `string` | No | — | Accessible label exposed to assistive technology and announced on mount. Defaults to a localized "Loading" string via... |
114
+ | `size` | `"base" | "large" | "small"` | No | `base` | Visual size. `"base"` renders at 44px, `"small"` renders at 28px. `"large"` is a deprecated alias for `"base"` and wi... |
115
+ | `style` | `StyleProp<ViewStyle>` | No | — | Inline styles applied to the root view. |
116
+ | `testID` | `string` | No | `ActivityIndicator` | Test identifier applied to the root view. Defaults to `"ActivityIndicator"`. |
@@ -24,7 +24,7 @@ displayed to them.
24
24
  | `accessibilityLabel` | `string` | No | — | VoiceOver will read this string when a user selects the associated element |
25
25
  | `assistiveText` | `string` | No | — | Text that helps the user understand the input |
26
26
  | `autoCapitalize` | `"characters" | "none" | "sentences" | "words"` | No | — | Determines where to autocapitalize |
27
- | `autoComplete` | `"email" | "off" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 44 more ... | "username-new"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
27
+ | `autoComplete` | `"email" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 45 more ... | "off"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
28
28
  | `autoCorrect` | `boolean` | No | — | Turn off autocorrect |
29
29
  | `autoFocus` | `boolean` | No | — | Automatically focus the input after it is rendered |
30
30
  | `clearable` | `Clearable` | No | — | Add a clear action on the input that clears the value. You should always use `while-editing` if you want the input t... |
@@ -36,7 +36,7 @@ If you are not worried about email address validation, consider using
36
36
  | `accessibilityLabel` | `string` | No | — | VoiceOver will read this string when a user selects the associated element |
37
37
  | `assistiveText` | `string` | No | — | Text that helps the user understand the input |
38
38
  | `autoCapitalize` | `"characters" | "none" | "sentences" | "words"` | No | — | Determines where to autocapitalize |
39
- | `autoComplete` | `"email" | "off" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 44 more ... | "username-new"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
39
+ | `autoComplete` | `"email" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 45 more ... | "off"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
40
40
  | `autoCorrect` | `boolean` | No | — | Turn off autocorrect |
41
41
  | `autoFocus` | `boolean` | No | — | Automatically focus the input after it is rendered |
42
42
  | `clearable` | `Clearable` | No | — | Add a clear action on the input that clears the value. You should always use `while-editing` if you want the input t... |
@@ -27,7 +27,7 @@ InputNumber does not show a clear button. The component enforces
27
27
  | `accessibilityLabel` | `string` | No | — | VoiceOver will read this string when a user selects the associated element |
28
28
  | `assistiveText` | `string` | No | — | Text that helps the user understand the input |
29
29
  | `autoCapitalize` | `"characters" | "none" | "sentences" | "words"` | No | — | Determines where to autocapitalize |
30
- | `autoComplete` | `"email" | "off" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 44 more ... | "username-new"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
30
+ | `autoComplete` | `"email" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 45 more ... | "off"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
31
31
  | `autoCorrect` | `boolean` | No | — | Turn off autocorrect |
32
32
  | `autoFocus` | `boolean` | No | — | Automatically focus the input after it is rendered |
33
33
  | `clearable` | `Clearable` | No | — | Add a clear action on the input that clears the value. You should always use `while-editing` if you want the input t... |
@@ -30,7 +30,7 @@ refer to [InputText](../InputText/InputText.md).
30
30
  | `accessibilityLabel` | `string` | No | — | VoiceOver will read this string when a user selects the associated element |
31
31
  | `assistiveText` | `string` | No | — | Text that helps the user understand the input |
32
32
  | `autoCapitalize` | `"characters" | "none" | "sentences" | "words"` | No | — | Determines where to autocapitalize |
33
- | `autoComplete` | `"email" | "off" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 44 more ... | "username-new"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
33
+ | `autoComplete` | `"email" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 45 more ... | "off"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
34
34
  | `autoCorrect` | `boolean` | No | — | Turn off autocorrect |
35
35
  | `autoFocus` | `boolean` | No | — | Automatically focus the input after it is rendered |
36
36
  | `defaultValue` | `string` | No | — | Default value for when component is uncontrolled |
@@ -320,7 +320,7 @@ export function InputTextKeyboardExample(
320
320
  | `accessibilityLabel` | `string` | No | — | VoiceOver will read this string when a user selects the associated element |
321
321
  | `assistiveText` | `string` | No | — | Text that helps the user understand the input |
322
322
  | `autoCapitalize` | `"characters" | "none" | "sentences" | "words"` | No | — | Determines where to autocapitalize |
323
- | `autoComplete` | `"email" | "off" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 44 more ... | "username-new"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
323
+ | `autoComplete` | `"email" | "name" | "additional-name" | "address-line1" | "address-line2" | "birthdate-day" | "birthdate-full" | "birthdate-month" | "birthdate-year" | "cc-csc" | "cc-exp" | ... 45 more ... | "off"` | No | — | Determines which content to suggest on auto complete, e.g.`username`. Default is `off` which disables auto complete ... |
324
324
  | `autoCorrect` | `boolean` | No | — | Turn off autocorrect |
325
325
  | `autoFocus` | `boolean` | No | — | Automatically focus the input after it is rendered |
326
326
  | `clearable` | `Clearable` | No | — | Add a clear action on the input that clears the value. You should always use `while-editing` if you want the input t... |
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jobber/components-native",
3
- "version": "0.104.3-TAYLORtai-d66f381.18+d66f381a",
3
+ "version": "0.105.0",
4
4
  "license": "MIT",
5
5
  "description": "React Native implementation of Atlantis",
6
6
  "repository": {
@@ -71,7 +71,7 @@
71
71
  "devDependencies": {
72
72
  "@babel/runtime": "^7.29.2",
73
73
  "@gorhom/bottom-sheet": "^5.2.8",
74
- "@jobber/design": "0.101.2-TAYLORtai-d66f381.36+d66f381a",
74
+ "@jobber/design": "0.102.0",
75
75
  "@jobber/hooks": "2.21.0",
76
76
  "@react-native-community/datetimepicker": "^8.4.5",
77
77
  "@react-native/babel-preset": "^0.82.1",
@@ -122,5 +122,5 @@
122
122
  "react-native-screens": ">=4.18.0",
123
123
  "react-native-svg": ">=12.0.0"
124
124
  },
125
- "gitHead": "d66f381a234db282667b16457410aad2fee95214"
125
+ "gitHead": "2dd2b2d127e9e7e00f8db56c9a5c8ab05344e128"
126
126
  }
@@ -1,7 +1,170 @@
1
- import React from "react";
2
- import { ActivityIndicator } from "react-native";
1
+ import React, { useEffect } from "react";
2
+ import { AccessibilityInfo } from "react-native";
3
+ import Animated, { Easing, ReduceMotion, useAnimatedProps, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming, } from "react-native-reanimated";
4
+ import Svg, { Circle } from "react-native-svg";
5
+ import { tokens } from "../utils/design";
3
6
  import { useAtlantisTheme } from "../AtlantisThemeContext";
4
- export function JobberActivityIndicator(props) {
5
- const { tokens } = useAtlantisTheme();
6
- return (React.createElement(ActivityIndicator, Object.assign({}, props, { color: props.color || tokens["color-greyBlue"], testID: props.testID || "ActivityIndicator" })));
7
+ import { useAtlantisI18n } from "../hooks/useAtlantisI18n";
8
+ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
9
+ const SIZE_PX = { small: 28, base: 44 };
10
+ // Stroke widths are expressed in viewBox units (the SVG is 48 across).
11
+ // The small variant uses a thicker stroke in viewBox units to compensate
12
+ // for its smaller rendered diameter, keeping the visual stroke thickness
13
+ // roughly constant across sizes. Matches the web component's CSS.
14
+ const STROKE_WIDTH = { small: 6, base: 4 };
15
+ const VIEWBOX = 48;
16
+ const CENTER = 24;
17
+ const RADIUS = 20;
18
+ // Circle circumference (2π·20 ≈ 125.66) drives the dasharray math below.
19
+ // "200" used as the gap value exceeds the circumference, ensuring only
20
+ // one stroke segment is visible at a time.
21
+ const GAP = 200;
22
+ function resolveLegacySize(size) {
23
+ if (size === "large") {
24
+ if (__DEV__) {
25
+ console.warn('[ActivityIndicator] size="large" is deprecated. Use size="base" instead.');
26
+ }
27
+ return "base";
28
+ }
29
+ return size;
30
+ }
31
+ function useAnnounceOnMount(label) {
32
+ useEffect(() => {
33
+ AccessibilityInfo.announceForAccessibility(label);
34
+ }, [label]);
35
+ }
36
+ /**
37
+ * `ActivityIndicator` communicates an indeterminate activity that the
38
+ * user cannot directly control or measure — loading, fetching, or
39
+ * waiting on an external system.
40
+ *
41
+ * Renders the Material Design 3 three-layer indeterminate ring,
42
+ * matching the visual identity of the web `ActivityIndicator` from
43
+ * `@jobber/components`. For determinate progress, use
44
+ * `ProgressIndicator` (forthcoming).
45
+ */
46
+ export function ActivityIndicator({ size = "base", accessibilityLabel, style, testID = "ActivityIndicator", }) {
47
+ const resolvedSize = resolveLegacySize(size);
48
+ const { tokens: themeTokens } = useAtlantisTheme();
49
+ const { t } = useAtlantisI18n();
50
+ const resolvedLabel = accessibilityLabel !== null && accessibilityLabel !== void 0 ? accessibilityLabel : t("loading");
51
+ useAnnounceOnMount(resolvedLabel);
52
+ const reducedMotion = useReducedMotion();
53
+ const pixelSize = SIZE_PX[resolvedSize];
54
+ const strokeWidth = STROKE_WIDTH[resolvedSize];
55
+ const a11yProps = {
56
+ accessible: true,
57
+ accessibilityRole: "progressbar",
58
+ accessibilityState: { busy: true },
59
+ accessibilityLabel: resolvedLabel,
60
+ };
61
+ if (reducedMotion) {
62
+ return (React.createElement(ReducedMotionRing, { pixelSize: pixelSize, strokeWidth: strokeWidth, ringColor: themeTokens["color-icon"], style: style, testID: testID, a11yProps: a11yProps }));
63
+ }
64
+ return (React.createElement(FullMotionRing, { pixelSize: pixelSize, strokeWidth: strokeWidth, arcColor: themeTokens["color-icon"], trackColor: themeTokens["color-disabled--secondary"], style: style, testID: testID, a11yProps: a11yProps }));
65
+ }
66
+ function FullMotionRing({ pixelSize, strokeWidth, arcColor, trackColor, style, testID, a11yProps, }) {
67
+ // Layer 1 — outer linear rotation. Continuous, constant speed.
68
+ const outerRotate = useSharedValue(0);
69
+ // Layer 2 — inner 8-phase rotation. Eight 135° eased segments per cycle
70
+ // (totalling 1080° over `timing-indicator--cycle`) reproduce the
71
+ // canonical Material Web "8-phase" rhythm.
72
+ const innerRotate = useSharedValue(0);
73
+ // Layer 3 — arc length and dash offset. Drives the visible arc growing
74
+ // from ~1 viewBox-unit to ~90 viewBox-units and sliding around the ring.
75
+ const arcProgress = useSharedValue(0);
76
+ useEffect(() => {
77
+ outerRotate.value = withRepeat(withTiming(360, {
78
+ duration: tokens["timing-indicator--linear-rotate"],
79
+ easing: Easing.linear,
80
+ }), -1);
81
+ const phaseDuration = tokens["timing-indicator--cycle"] / 8;
82
+ const phaseEasing = Easing.bezier(0.4, 0, 0.2, 1);
83
+ innerRotate.value = withRepeat(withSequence(withTiming(135, { duration: phaseDuration, easing: phaseEasing }), withTiming(270, { duration: phaseDuration, easing: phaseEasing }), withTiming(405, { duration: phaseDuration, easing: phaseEasing }), withTiming(540, { duration: phaseDuration, easing: phaseEasing }), withTiming(675, { duration: phaseDuration, easing: phaseEasing }), withTiming(810, { duration: phaseDuration, easing: phaseEasing }), withTiming(945, { duration: phaseDuration, easing: phaseEasing }), withTiming(1080, { duration: phaseDuration, easing: phaseEasing })), -1);
84
+ arcProgress.value = withRepeat(withTiming(1, {
85
+ duration: tokens["timing-indicator--arc"],
86
+ easing: Easing.bezier(0.4, 0, 0.2, 1),
87
+ }), -1);
88
+ // We intentionally do not include the shared values in the dependency
89
+ // array — they are stable references created by useSharedValue.
90
+ }, []);
91
+ const outerStyle = useAnimatedStyle(() => ({
92
+ transform: [{ rotate: `${outerRotate.value}deg` }],
93
+ }));
94
+ const innerStyle = useAnimatedStyle(() => ({
95
+ transform: [{ rotate: `${innerRotate.value}deg` }],
96
+ }));
97
+ const arcAnimatedProps = useAnimatedProps(() => {
98
+ "worklet";
99
+ // Match web's indicatorExpandArc keyframe:
100
+ // 0% → dasharray (1, 200), offset 0
101
+ // 50% → dasharray (90, 200), offset -35
102
+ // 100% → dasharray (90, 200), offset -125
103
+ //
104
+ // Phase A (0 → 0.5): expand the visible arc from 1 → 90 while
105
+ // sliding it from 0 → -35.
106
+ // Phase B (0.5 → 1): hold the visible arc at 90 while sliding from
107
+ // -35 → -125.
108
+ const p = arcProgress.value;
109
+ let visible;
110
+ let offset;
111
+ if (p < 0.5) {
112
+ const phase = p * 2;
113
+ visible = 1 + (90 - 1) * phase;
114
+ offset = -35 * phase;
115
+ }
116
+ else {
117
+ const phase = (p - 0.5) * 2;
118
+ visible = 90;
119
+ offset = -35 + -90 * phase;
120
+ }
121
+ return {
122
+ strokeDasharray: `${visible}, ${GAP}`,
123
+ strokeDashoffset: offset,
124
+ };
125
+ });
126
+ return (React.createElement(Animated.View, Object.assign({ style: [{ width: pixelSize, height: pixelSize }, style], testID: testID }, a11yProps),
127
+ React.createElement(Animated.View, { style: [{ width: pixelSize, height: pixelSize }, outerStyle] },
128
+ React.createElement(Animated.View, { style: [{ width: pixelSize, height: pixelSize }, innerStyle] },
129
+ React.createElement(Svg, { width: pixelSize, height: pixelSize, viewBox: `0 0 ${VIEWBOX} ${VIEWBOX}` },
130
+ React.createElement(Circle, { cx: CENTER, cy: CENTER, r: RADIUS, fill: "none", stroke: trackColor, strokeWidth: strokeWidth }),
131
+ React.createElement(AnimatedCircle, { cx: CENTER, cy: CENTER, r: RADIUS, fill: "none", stroke: arcColor, strokeWidth: strokeWidth, strokeLinecap: "round", animatedProps: arcAnimatedProps }))))));
132
+ }
133
+ function ReducedMotionRing({ pixelSize, strokeWidth, ringColor, style, testID, a11yProps, }) {
134
+ // Reduced motion: hide rotation entirely, render one static ring in the
135
+ // active foreground colour, and pulse opacity 1.0 ↔ 0.35 over a 4s
136
+ // ease-in-out alternate cycle (2s down, 2s up). Matches web's
137
+ // reduced-motion fallback exactly.
138
+ //
139
+ // CRITICAL: Reanimated 3 automatically suppresses `withTiming` /
140
+ // `withRepeat` when the OS reduce-motion setting is enabled, snapping
141
+ // to the target value instantly. That is the right default for most
142
+ // animations — but the pulse IS our reduce-motion fallback. We
143
+ // explicitly opt OUT of the suppression via `reduceMotion:
144
+ // ReduceMotion.Never` on each `withTiming`, so the pulse runs whenever
145
+ // this branch renders (which only happens when reduce-motion is on).
146
+ //
147
+ // We also use `withSequence(down, up)` rather than
148
+ // `withRepeat(down, -1, true)` because the `reverse` flag on
149
+ // `withRepeat` is observed to be honored inconsistently on
150
+ // react-native-reanimated's web target. `withSequence` is equivalent
151
+ // in observable behaviour and works on both web and native.
152
+ const pulse = useSharedValue(1);
153
+ useEffect(() => {
154
+ pulse.value = withRepeat(withSequence(withTiming(0.35, {
155
+ duration: 2000,
156
+ easing: Easing.inOut(Easing.ease),
157
+ reduceMotion: ReduceMotion.Never,
158
+ }), withTiming(1, {
159
+ duration: 2000,
160
+ easing: Easing.inOut(Easing.ease),
161
+ reduceMotion: ReduceMotion.Never,
162
+ })), -1, false, undefined, ReduceMotion.Never);
163
+ }, []);
164
+ const animatedStyle = useAnimatedStyle(() => ({
165
+ opacity: pulse.value,
166
+ }));
167
+ return (React.createElement(Animated.View, Object.assign({ style: [{ width: pixelSize, height: pixelSize }, animatedStyle, style], testID: testID }, a11yProps),
168
+ React.createElement(Svg, { width: pixelSize, height: pixelSize, viewBox: `0 0 ${VIEWBOX} ${VIEWBOX}` },
169
+ React.createElement(Circle, { cx: CENTER, cy: CENTER, r: RADIUS, fill: "none", stroke: ringColor, strokeWidth: strokeWidth }))));
7
170
  }
@@ -1,23 +1,171 @@
1
1
  import React from "react";
2
+ import { AccessibilityInfo } from "react-native";
2
3
  import { render } from "@testing-library/react-native";
4
+ import { useReducedMotion } from "react-native-reanimated";
3
5
  import { ActivityIndicator } from "./index";
4
- import { tokens } from "../utils/design";
6
+ // `useReducedMotion` is mocked globally in src/__mocks__/__mocks.ts and
7
+ // defaults to returning `false`. Tests that exercise the reduced-motion
8
+ // branch override the return value below.
5
9
  const testId = "ActivityIndicator";
6
10
  describe("ActivityIndicator", () => {
7
- it("renders with the default color when no props are provided", () => {
8
- const { getByTestId } = render(React.createElement(ActivityIndicator, null));
9
- expect(getByTestId(testId).props.color).toBe(tokens["color-greyBlue"]);
10
- });
11
- it("renders with a custom color", () => {
12
- const color = "red";
13
- const { getByTestId } = render(React.createElement(ActivityIndicator, { color: color }));
14
- expect(getByTestId(testId).props.color).toBe(color);
15
- });
16
- it("renders with large size", () => {
17
- const size = "large";
18
- const color = "red";
19
- const { getByTestId } = render(React.createElement(ActivityIndicator, { color: color, size: size }));
20
- expect(getByTestId(testId).props.size).toBe(size);
21
- expect(getByTestId(testId).props.color).toBe(color);
11
+ beforeEach(() => {
12
+ useReducedMotion.mockReturnValue(false);
13
+ jest.mocked(AccessibilityInfo.announceForAccessibility).mockClear();
14
+ });
15
+ describe("default rendering", () => {
16
+ it("renders with the default testID", () => {
17
+ const { getByTestId } = render(React.createElement(ActivityIndicator, null));
18
+ expect(getByTestId(testId)).toBeTruthy();
19
+ });
20
+ it("renders with the localized 'Loading' label by default", () => {
21
+ const { getByTestId } = render(React.createElement(ActivityIndicator, null));
22
+ expect(getByTestId(testId).props.accessibilityLabel).toBe("Loading");
23
+ });
24
+ it("renders with the progressbar accessibility role and busy state", () => {
25
+ const { getByTestId } = render(React.createElement(ActivityIndicator, null));
26
+ const root = getByTestId(testId);
27
+ expect(root.props.accessibilityRole).toBe("progressbar");
28
+ expect(root.props.accessibilityState).toEqual({ busy: true });
29
+ });
30
+ it("does not set accessibilityLiveRegion", () => {
31
+ const { getByTestId } = render(React.createElement(ActivityIndicator, null));
32
+ expect(getByTestId(testId).props.accessibilityLiveRegion).toBeUndefined();
33
+ });
34
+ it("renders at the base (44px) size by default", () => {
35
+ const { getByTestId } = render(React.createElement(ActivityIndicator, null));
36
+ const root = getByTestId(testId);
37
+ const flattenedStyle = Array.isArray(root.props.style)
38
+ ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean))
39
+ : root.props.style;
40
+ expect(flattenedStyle.width).toBe(44);
41
+ expect(flattenedStyle.height).toBe(44);
42
+ });
43
+ });
44
+ describe("size variants", () => {
45
+ it("renders the small ring at 28px", () => {
46
+ const { getByTestId } = render(React.createElement(ActivityIndicator, { size: "small" }));
47
+ const root = getByTestId(testId);
48
+ const flattenedStyle = Array.isArray(root.props.style)
49
+ ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean))
50
+ : root.props.style;
51
+ expect(flattenedStyle.width).toBe(28);
52
+ expect(flattenedStyle.height).toBe(28);
53
+ });
54
+ it("renders the base ring at 44px when size is explicit", () => {
55
+ const { getByTestId } = render(React.createElement(ActivityIndicator, { size: "base" }));
56
+ const root = getByTestId(testId);
57
+ const flattenedStyle = Array.isArray(root.props.style)
58
+ ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean))
59
+ : root.props.style;
60
+ expect(flattenedStyle.width).toBe(44);
61
+ expect(flattenedStyle.height).toBe(44);
62
+ });
63
+ });
64
+ describe('deprecated size="large" alias', () => {
65
+ let warnSpy;
66
+ beforeEach(() => {
67
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
68
+ });
69
+ afterEach(() => {
70
+ warnSpy.mockRestore();
71
+ });
72
+ it('maps size="large" to size="base" visually', () => {
73
+ const { getByTestId } = render(React.createElement(ActivityIndicator, { size: "large" }));
74
+ const root = getByTestId(testId);
75
+ const flattenedStyle = Array.isArray(root.props.style)
76
+ ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean))
77
+ : root.props.style;
78
+ expect(flattenedStyle.width).toBe(44);
79
+ expect(flattenedStyle.height).toBe(44);
80
+ });
81
+ it('warns once when size="large" is passed (in development)', () => {
82
+ render(React.createElement(ActivityIndicator, { size: "large" }));
83
+ expect(warnSpy).toHaveBeenCalledTimes(1);
84
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('size="large" is deprecated'));
85
+ });
86
+ it('does not warn for size="small" or size="base"', () => {
87
+ render(React.createElement(ActivityIndicator, { size: "small" }));
88
+ render(React.createElement(ActivityIndicator, { size: "base" }));
89
+ render(React.createElement(ActivityIndicator, null));
90
+ expect(warnSpy).not.toHaveBeenCalled();
91
+ });
92
+ });
93
+ describe("accessibility", () => {
94
+ it("uses a custom accessibilityLabel when supplied", () => {
95
+ const { getByTestId } = render(React.createElement(ActivityIndicator, { accessibilityLabel: "Uploading file" }));
96
+ expect(getByTestId(testId).props.accessibilityLabel).toBe("Uploading file");
97
+ });
98
+ it("announces the resolved label on mount", () => {
99
+ render(React.createElement(ActivityIndicator, null));
100
+ expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledTimes(1);
101
+ expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith("Loading");
102
+ });
103
+ it("announces a custom label on mount", () => {
104
+ render(React.createElement(ActivityIndicator, { accessibilityLabel: "Uploading file" }));
105
+ expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith("Uploading file");
106
+ });
107
+ it("re-announces when the label prop changes", () => {
108
+ const { rerender } = render(React.createElement(ActivityIndicator, { accessibilityLabel: "Loading clients" }));
109
+ jest.mocked(AccessibilityInfo.announceForAccessibility).mockClear();
110
+ rerender(React.createElement(ActivityIndicator, { accessibilityLabel: "Loading jobs" }));
111
+ expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith("Loading jobs");
112
+ });
113
+ });
114
+ describe("style passthrough", () => {
115
+ it("merges a supplied style onto the root view", () => {
116
+ const { getByTestId } = render(React.createElement(ActivityIndicator, { style: { marginTop: 8 } }));
117
+ const root = getByTestId(testId);
118
+ const flattenedStyle = Array.isArray(root.props.style)
119
+ ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean))
120
+ : root.props.style;
121
+ expect(flattenedStyle.marginTop).toBe(8);
122
+ });
123
+ });
124
+ describe("custom testID", () => {
125
+ it("uses the supplied testID on the root", () => {
126
+ const { getByTestId } = render(React.createElement(ActivityIndicator, { testID: "my-loader" }));
127
+ expect(getByTestId("my-loader")).toBeTruthy();
128
+ });
129
+ });
130
+ describe("reduced motion", () => {
131
+ it("renders the reduced-motion ring when the OS setting is on", () => {
132
+ useReducedMotion.mockReturnValue(true);
133
+ const { getByTestId } = render(React.createElement(ActivityIndicator, null));
134
+ const root = getByTestId(testId);
135
+ // The reduced-motion presentation renders a single static <Circle>
136
+ // wrapped in one Animated.View — the full-motion presentation renders
137
+ // three nested Animated.Views. We can distinguish by the children
138
+ // count on the root.
139
+ // The root in both cases is an Animated.View, so we assert by
140
+ // examining the SVG depth: reduced-motion has SVG as a direct child.
141
+ // Easiest: rely on absence of the nested rotation wrappers.
142
+ const directChildren = React.Children.toArray(root.props.children);
143
+ // ReducedMotionRing → root contains the <Svg> directly.
144
+ // FullMotionRing → root contains an Animated.View (outer rotation)
145
+ // that itself contains another Animated.View, then the <Svg>.
146
+ // Walk the tree to confirm.
147
+ function hasDirectSvgChild(node) {
148
+ var _a, _b;
149
+ if (!node || typeof node !== "object")
150
+ return false;
151
+ const element = node;
152
+ const typeName = ((_a = element.type) === null || _a === void 0 ? void 0 : _a.displayName) || ((_b = element.type) === null || _b === void 0 ? void 0 : _b.name) || "";
153
+ return typeName === "Svg" || typeName === "SvgRoot";
154
+ }
155
+ expect(directChildren.some(hasDirectSvgChild)).toBe(true);
156
+ });
157
+ it("still announces the label in reduced-motion mode", () => {
158
+ useReducedMotion.mockReturnValue(true);
159
+ render(React.createElement(ActivityIndicator, { accessibilityLabel: "Saving" }));
160
+ expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith("Saving");
161
+ });
162
+ it("preserves the accessibility contract in reduced-motion mode", () => {
163
+ useReducedMotion.mockReturnValue(true);
164
+ const { getByTestId } = render(React.createElement(ActivityIndicator, null));
165
+ const root = getByTestId(testId);
166
+ expect(root.props.accessibilityRole).toBe("progressbar");
167
+ expect(root.props.accessibilityState).toEqual({ busy: true });
168
+ expect(root.props.accessibilityLabel).toBe("Loading");
169
+ });
22
170
  });
23
171
  });
@@ -1 +1 @@
1
- export { JobberActivityIndicator as ActivityIndicator } from "./ActivityIndicator";
1
+ export { ActivityIndicator } from "./ActivityIndicator";