@jobber/components-native 0.109.1 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/docs/ProgressBar/ProgressBar.md +1 -1
  2. package/dist/docs/ProgressIndicator/ProgressIndicator.md +117 -0
  3. package/dist/docs/index.md +1 -0
  4. package/dist/package.json +2 -2
  5. package/dist/src/ProgressBar/ProgressBar.js +6 -0
  6. package/dist/src/ProgressBar/ProgressBar.test.js +2 -0
  7. package/dist/src/ProgressIndicator/ProgressIndicator.js +106 -0
  8. package/dist/src/ProgressIndicator/ProgressIndicator.size.style.js +15 -0
  9. package/dist/src/ProgressIndicator/ProgressIndicator.style.js +51 -0
  10. package/dist/src/ProgressIndicator/ProgressIndicator.test.js +138 -0
  11. package/dist/src/ProgressIndicator/index.js +1 -0
  12. package/dist/src/ProgressIndicator/types.js +1 -0
  13. package/dist/src/hooks/useAtlantisI18n/locales/en.json +3 -0
  14. package/dist/src/hooks/useAtlantisI18n/locales/es.json +3 -0
  15. package/dist/src/hooks/useAtlantisI18n/releasedLanguages.js +21 -0
  16. package/dist/src/hooks/useAtlantisI18n/useAtlantisI18n.js +4 -2
  17. package/dist/src/hooks/useAtlantisI18n/useAtlantisI18n.test.js +115 -15
  18. package/dist/src/index.js +1 -0
  19. package/dist/src/utils/meta/meta.json +1 -0
  20. package/dist/tsconfig.build.tsbuildinfo +1 -1
  21. package/dist/types/src/ProgressBar/ProgressBar.d.ts +3 -0
  22. package/dist/types/src/ProgressBar/types.d.ts +3 -0
  23. package/dist/types/src/ProgressIndicator/ProgressIndicator.d.ts +3 -0
  24. package/dist/types/src/ProgressIndicator/ProgressIndicator.size.style.d.ts +19 -0
  25. package/dist/types/src/ProgressIndicator/ProgressIndicator.style.d.ts +48 -0
  26. package/dist/types/src/ProgressIndicator/ProgressIndicator.test.d.ts +1 -0
  27. package/dist/types/src/ProgressIndicator/index.d.ts +2 -0
  28. package/dist/types/src/ProgressIndicator/types.d.ts +44 -0
  29. package/dist/types/src/hooks/useAtlantisI18n/releasedLanguages.d.ts +18 -0
  30. package/dist/types/src/hooks/useAtlantisI18n/useAtlantisI18n.d.ts +3 -1
  31. package/dist/types/src/index.d.ts +1 -0
  32. package/package.json +2 -2
  33. package/src/ProgressBar/ProgressBar.stories.tsx +2 -0
  34. package/src/ProgressBar/ProgressBar.test.tsx +2 -0
  35. package/src/ProgressBar/ProgressBar.tsx +6 -0
  36. package/src/ProgressBar/docs/ProgressBarBasic.tsx +2 -0
  37. package/src/ProgressBar/docs/ProgressBarWithHeader.tsx +2 -0
  38. package/src/ProgressBar/types.ts +3 -0
  39. package/src/ProgressIndicator/ProgressIndicator.size.style.ts +20 -0
  40. package/src/ProgressIndicator/ProgressIndicator.stories.tsx +154 -0
  41. package/src/ProgressIndicator/ProgressIndicator.style.ts +53 -0
  42. package/src/ProgressIndicator/ProgressIndicator.test.tsx +214 -0
  43. package/src/ProgressIndicator/ProgressIndicator.tsx +198 -0
  44. package/src/ProgressIndicator/docs/ProgressIndicatorBasic.tsx +22 -0
  45. package/src/ProgressIndicator/docs/ProgressIndicatorPending.tsx +22 -0
  46. package/src/ProgressIndicator/docs/index.ts +2 -0
  47. package/src/ProgressIndicator/guide.md +104 -0
  48. package/src/ProgressIndicator/index.ts +2 -0
  49. package/src/ProgressIndicator/types.ts +51 -0
  50. package/src/hooks/useAtlantisI18n/locales/en.json +3 -0
  51. package/src/hooks/useAtlantisI18n/locales/es.json +3 -0
  52. package/src/hooks/useAtlantisI18n/releasedLanguages.ts +23 -0
  53. package/src/hooks/useAtlantisI18n/useAtlantisI18n.test.ts +202 -25
  54. package/src/hooks/useAtlantisI18n/useAtlantisI18n.ts +13 -3
  55. package/src/index.ts +1 -0
  56. package/src/utils/meta/meta.json +1 -0
@@ -0,0 +1,214 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react-native";
3
+ import { ProgressIndicator } from ".";
4
+
5
+ describe("ProgressIndicator", () => {
6
+ describe("public API", () => {
7
+ it("renders with role='progressbar'", () => {
8
+ render(<ProgressIndicator value={50} />);
9
+
10
+ expect(screen.getByRole("progressbar")).toBeTruthy();
11
+ });
12
+
13
+ it("sets accessibilityValue now/min/max", () => {
14
+ render(<ProgressIndicator value={50} />);
15
+
16
+ expect(screen.getByRole("progressbar").props.accessibilityValue).toEqual(
17
+ expect.objectContaining({ now: 50, min: 0, max: 100 }),
18
+ );
19
+ });
20
+
21
+ it("uses max in accessibilityValue when provided", () => {
22
+ render(<ProgressIndicator value={3} max={4} />);
23
+
24
+ expect(screen.getByRole("progressbar").props.accessibilityValue).toEqual(
25
+ expect.objectContaining({ now: 3, max: 4 }),
26
+ );
27
+ });
28
+
29
+ it("renders a continuous fill by default", () => {
30
+ render(<ProgressIndicator value={50} />);
31
+
32
+ expect(screen.getByTestId("progressindicator-fill")).toBeTruthy();
33
+ expect(screen.queryByTestId("progressindicator-step-filled")).toBeNull();
34
+ });
35
+
36
+ it("applies style to the root", () => {
37
+ render(<ProgressIndicator value={50} style={{ margin: 8 }} />);
38
+
39
+ expect(screen.getByRole("progressbar").props.style).toEqual(
40
+ expect.arrayContaining([expect.objectContaining({ margin: 8 })]),
41
+ );
42
+ });
43
+
44
+ it("rounds the continuous track so both ends are pill-shaped", () => {
45
+ render(<ProgressIndicator value={50} size="base" />);
46
+
47
+ // base height is 16px, so the track radius is 8. Without this the
48
+ // unfilled (right) end of the track renders with square corners.
49
+ expect(screen.getByRole("progressbar").props.style).toEqual(
50
+ expect.arrayContaining([expect.objectContaining({ borderRadius: 8 })]),
51
+ );
52
+ });
53
+
54
+ it("does not round the stepped root (segments carry their own radius)", () => {
55
+ render(<ProgressIndicator value={2} max={5} variation="stepped" />);
56
+
57
+ const rootStyles = screen
58
+ .getByRole("progressbar")
59
+ .props.style.filter(Boolean);
60
+ expect(rootStyles).not.toContainEqual(
61
+ expect.objectContaining({ borderRadius: expect.anything() }),
62
+ );
63
+ });
64
+ });
65
+
66
+ describe("accessibility label", () => {
67
+ it("defaults to '{value}%' when max is omitted", () => {
68
+ render(<ProgressIndicator value={42} />);
69
+
70
+ expect(screen.getByLabelText("42%")).toBeTruthy();
71
+ });
72
+
73
+ it("defaults to '{value}%' when max === 100", () => {
74
+ render(<ProgressIndicator value={42} max={100} />);
75
+
76
+ expect(screen.getByLabelText("42%")).toBeTruthy();
77
+ });
78
+
79
+ it("defaults to '{value} / {max}' when max !== 100", () => {
80
+ render(<ProgressIndicator value={3} max={4} />);
81
+
82
+ expect(screen.getByLabelText("3 / 4")).toBeTruthy();
83
+ });
84
+
85
+ it("defaults to the pending template when pendingValue > 0", () => {
86
+ render(<ProgressIndicator value={2} pendingValue={3} max={10} />);
87
+
88
+ expect(screen.getByLabelText("2 of 10, 3 pending")).toBeTruthy();
89
+ });
90
+
91
+ it("uses a consumer-supplied accessibilityLabel when provided", () => {
92
+ render(
93
+ <ProgressIndicator value={50} accessibilityLabel="Uploading file" />,
94
+ );
95
+
96
+ expect(screen.getByLabelText("Uploading file")).toBeTruthy();
97
+ });
98
+ });
99
+
100
+ /**
101
+ * Native screen readers (VoiceOver / TalkBack) announce a progressbar from
102
+ * its `accessibilityRole` plus the resolved `accessibilityValue.text` /
103
+ * `accessibilityLabel`. These assertions lock that announced payload
104
+ * deterministically — the automatable proxy for the on-device smoke, which
105
+ * cannot capture live screen-reader speech in CI.
106
+ */
107
+ describe("screen reader announcement contract", () => {
108
+ it.each([
109
+ ["percent (max omitted)", { value: 50 }, "50%"],
110
+ ["ratio (max !== 100)", { value: 3, max: 4 }, "3 / 4"],
111
+ ["pending", { value: 2, pendingValue: 3, max: 10 }, "2 of 10, 3 pending"],
112
+ [
113
+ "custom label",
114
+ { value: 50, accessibilityLabel: "Uploading file" },
115
+ "Uploading file",
116
+ ],
117
+ ])(
118
+ "announces %s as a progressbar with the derived text",
119
+ (_n, props, text) => {
120
+ render(<ProgressIndicator {...props} />);
121
+
122
+ const root = screen.getByRole("progressbar");
123
+ expect(root.props.accessibilityRole).toBe("progressbar");
124
+ expect(root.props.accessibilityLabel).toBe(text);
125
+ expect(root.props.accessibilityValue).toEqual(
126
+ expect.objectContaining({ text }),
127
+ );
128
+ },
129
+ );
130
+
131
+ it("carries the numeric value/min/max alongside the announced text", () => {
132
+ render(<ProgressIndicator value={3} max={4} />);
133
+
134
+ expect(screen.getByRole("progressbar").props.accessibilityValue).toEqual({
135
+ min: 0,
136
+ max: 4,
137
+ now: 3,
138
+ text: "3 / 4",
139
+ });
140
+ });
141
+ });
142
+
143
+ describe("sizes", () => {
144
+ it.each([
145
+ ["smaller", 4],
146
+ ["small", 8],
147
+ ["base", 16],
148
+ ] as const)("applies %s height (%dpx) to the root", (size, height) => {
149
+ render(<ProgressIndicator value={50} size={size} />);
150
+
151
+ expect(screen.getByRole("progressbar").props.style).toEqual(
152
+ expect.arrayContaining([expect.objectContaining({ height })]),
153
+ );
154
+ });
155
+ });
156
+
157
+ describe("pending overlay (continuous)", () => {
158
+ it("renders a pending band when pendingValue > 0", () => {
159
+ render(<ProgressIndicator value={2} pendingValue={3} max={10} />);
160
+
161
+ expect(screen.getByTestId("progressindicator-pending")).toBeTruthy();
162
+ });
163
+
164
+ it("renders no pending band when pendingValue is 0", () => {
165
+ render(<ProgressIndicator value={4} max={10} />);
166
+
167
+ expect(screen.queryByTestId("progressindicator-pending")).toBeNull();
168
+ });
169
+ });
170
+
171
+ describe("stepped variation", () => {
172
+ it("renders max segments with the first value filled", () => {
173
+ render(<ProgressIndicator value={2} max={5} variation="stepped" />);
174
+
175
+ expect(
176
+ screen.getAllByTestId("progressindicator-step-filled"),
177
+ ).toHaveLength(2);
178
+ expect(
179
+ screen.getAllByTestId("progressindicator-step-empty"),
180
+ ).toHaveLength(3);
181
+ });
182
+
183
+ it("classifies filled, pending, and empty segments", () => {
184
+ render(
185
+ <ProgressIndicator
186
+ value={3}
187
+ pendingValue={2}
188
+ max={6}
189
+ variation="stepped"
190
+ />,
191
+ );
192
+
193
+ expect(
194
+ screen.getAllByTestId("progressindicator-step-filled"),
195
+ ).toHaveLength(3);
196
+ expect(
197
+ screen.getAllByTestId("progressindicator-step-pending"),
198
+ ).toHaveLength(2);
199
+ expect(
200
+ screen.getAllByTestId("progressindicator-step-empty"),
201
+ ).toHaveLength(1);
202
+ });
203
+
204
+ it("keeps a single progressbar on the root and none on segments", () => {
205
+ render(<ProgressIndicator value={2} max={5} variation="stepped" />);
206
+
207
+ expect(screen.getAllByRole("progressbar")).toHaveLength(1);
208
+ screen.getAllByTestId(/progressindicator-step-/).forEach(segment => {
209
+ expect(segment.props.accessibilityRole).toBeUndefined();
210
+ expect(segment.props.accessibilityValue).toBeUndefined();
211
+ });
212
+ });
213
+ });
214
+ });
@@ -0,0 +1,198 @@
1
+ import React, { useEffect } from "react";
2
+ import { View } from "react-native";
3
+ import Animated, {
4
+ Easing,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withTiming,
8
+ } from "react-native-reanimated";
9
+ import type { ProgressIndicatorProps } from "./types";
10
+ import { useStyles } from "./ProgressIndicator.style";
11
+ import { sizeHeights, sizeStyles } from "./ProgressIndicator.size.style";
12
+ import { useAtlantisI18n } from "../hooks/useAtlantisI18n";
13
+ import { tokens } from "../utils/design";
14
+
15
+ type RendererProps = Required<
16
+ Pick<ProgressIndicatorProps, "value" | "max" | "pendingValue" | "size">
17
+ >;
18
+
19
+ export function ProgressIndicator({
20
+ value,
21
+ max = 100,
22
+ pendingValue = 0,
23
+ variation = "continuous",
24
+ size = "base",
25
+ accessibilityLabel,
26
+ style,
27
+ }: ProgressIndicatorProps) {
28
+ const styles = useStyles();
29
+ const { t } = useAtlantisI18n();
30
+
31
+ const label = accessibilityLabel ?? getA11yLabel();
32
+ const radius = sizeHeights[size] / 2;
33
+
34
+ return (
35
+ <View
36
+ accessible
37
+ accessibilityRole="progressbar"
38
+ accessibilityLabel={label}
39
+ accessibilityValue={{ min: 0, max, now: value, text: label }}
40
+ style={[
41
+ variation === "stepped" ? styles.stepped : styles.continuous,
42
+ sizeStyles[size],
43
+ // Round the continuous track so its trailing (unfilled) end is pill-
44
+ // shaped like the leading end; `overflow: "hidden"` clips the fill to
45
+ // match. The stepped variation rounds its individual segments instead.
46
+ variation === "continuous" && { borderRadius: radius },
47
+ style,
48
+ ]}
49
+ >
50
+ {variation === "stepped" ? (
51
+ <ProgressIndicatorStepped
52
+ value={value}
53
+ max={max}
54
+ pendingValue={pendingValue}
55
+ size={size}
56
+ />
57
+ ) : (
58
+ <ProgressIndicatorContinuous
59
+ value={value}
60
+ max={max}
61
+ pendingValue={pendingValue}
62
+ size={size}
63
+ />
64
+ )}
65
+ </View>
66
+ );
67
+
68
+ function getA11yLabel(): string {
69
+ if (pendingValue > 0) {
70
+ return t("ProgressIndicator.ariaLabelPending", {
71
+ value: String(value),
72
+ max: String(max),
73
+ pendingValue: String(pendingValue),
74
+ });
75
+ }
76
+
77
+ if (max === 100) {
78
+ return t("ProgressIndicator.ariaLabelPercent", { value: String(value) });
79
+ }
80
+
81
+ return t("ProgressIndicator.ariaLabelRatio", {
82
+ value: String(value),
83
+ max: String(max),
84
+ });
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Clamp a raw progress amount to a percentage of `max` in the range 0..100.
90
+ */
91
+ function toPercent(amount: number, max: number): number {
92
+ if (max <= 0) return 0;
93
+
94
+ return Math.min(100, Math.max(0, (amount / max) * 100));
95
+ }
96
+
97
+ /**
98
+ * File-private renderer for `variation="continuous"`. Renders the fill and,
99
+ * when `pendingValue > 0`, a pending band. The outer `ProgressIndicator` shell
100
+ * owns the accessibility contract; this renderer emits presentational children
101
+ * only.
102
+ */
103
+ function ProgressIndicatorContinuous({
104
+ value,
105
+ max,
106
+ pendingValue,
107
+ size,
108
+ }: RendererProps) {
109
+ const styles = useStyles();
110
+ const radius = sizeHeights[size] / 2;
111
+
112
+ const fillPercent = toPercent(value, max);
113
+ const pendingPercent = toPercent(value + pendingValue, max);
114
+
115
+ // The width transition is intentionally preserved regardless of the user's
116
+ // reduce-motion preference: the slide between progress values conveys
117
+ // functional progress and is exempt under WCAG 2.3.3 ("Animation from
118
+ // Interactions"). See design.md.
119
+ const fill = useSharedValue(fillPercent);
120
+ const pending = useSharedValue(pendingPercent);
121
+
122
+ useEffect(() => {
123
+ fill.value = withTiming(fillPercent, {
124
+ duration: tokens["timing-base"],
125
+ easing: Easing.out(Easing.ease),
126
+ });
127
+ }, [fillPercent]);
128
+
129
+ useEffect(() => {
130
+ pending.value = withTiming(pendingPercent, {
131
+ duration: tokens["timing-base"],
132
+ easing: Easing.out(Easing.ease),
133
+ });
134
+ }, [pendingPercent]);
135
+
136
+ const fillStyle = useAnimatedStyle(() => ({ width: `${fill.value}%` }));
137
+ const pendingStyle = useAnimatedStyle(() => ({ width: `${pending.value}%` }));
138
+
139
+ return (
140
+ <>
141
+ {pendingValue > 0 && (
142
+ <Animated.View
143
+ testID="progressindicator-pending"
144
+ style={[styles.pending, { borderRadius: radius }, pendingStyle]}
145
+ />
146
+ )}
147
+ <Animated.View
148
+ testID="progressindicator-fill"
149
+ style={[styles.fill, { borderRadius: radius }, fillStyle]}
150
+ />
151
+ </>
152
+ );
153
+ }
154
+
155
+ /**
156
+ * File-private renderer for `variation="stepped"`. Renders `max` presentational
157
+ * segments, each classified filled / pending / empty. No accessibility props on
158
+ * the segments — the outer shell announces once.
159
+ */
160
+ function ProgressIndicatorStepped({
161
+ value,
162
+ max,
163
+ pendingValue,
164
+ size,
165
+ }: RendererProps) {
166
+ const styles = useStyles();
167
+ const radius = sizeHeights[size] / 2;
168
+ const segmentCount = Math.max(0, Math.floor(max));
169
+
170
+ return (
171
+ <>
172
+ {Array.from({ length: segmentCount }).map((_, index) => {
173
+ const step = index + 1;
174
+ const isFilled = step <= value;
175
+ const isPending = !isFilled && step <= value + pendingValue;
176
+
177
+ return (
178
+ <View
179
+ key={step}
180
+ testID={
181
+ isFilled
182
+ ? "progressindicator-step-filled"
183
+ : isPending
184
+ ? "progressindicator-step-pending"
185
+ : "progressindicator-step-empty"
186
+ }
187
+ style={[
188
+ styles.segment,
189
+ { borderRadius: radius },
190
+ isFilled && styles.segmentFilled,
191
+ isPending && styles.segmentPending,
192
+ ]}
193
+ />
194
+ );
195
+ })}
196
+ </>
197
+ );
198
+ }
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import { ProgressIndicator } from "@jobber/components-native";
3
+ import type { ProgressIndicatorProps } from "@jobber/components-native";
4
+
5
+ type ProgressIndicatorStoryArgs = Partial<
6
+ Pick<
7
+ ProgressIndicatorProps,
8
+ "value" | "max" | "pendingValue" | "variation" | "size"
9
+ >
10
+ >;
11
+
12
+ export function ProgressIndicatorBasic(props: ProgressIndicatorStoryArgs) {
13
+ return (
14
+ <ProgressIndicator
15
+ value={props.value ?? 75}
16
+ max={props.max}
17
+ pendingValue={props.pendingValue}
18
+ size={props.size}
19
+ variation={props.variation}
20
+ />
21
+ );
22
+ }
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import { ProgressIndicator } from "@jobber/components-native";
3
+ import type { ProgressIndicatorProps } from "@jobber/components-native";
4
+
5
+ type ProgressIndicatorStoryArgs = Partial<
6
+ Pick<
7
+ ProgressIndicatorProps,
8
+ "value" | "max" | "pendingValue" | "variation" | "size"
9
+ >
10
+ >;
11
+
12
+ export function ProgressIndicatorPending(props: ProgressIndicatorStoryArgs) {
13
+ return (
14
+ <ProgressIndicator
15
+ value={props.value ?? 2}
16
+ max={props.max ?? 10}
17
+ pendingValue={props.pendingValue ?? 3}
18
+ size={props.size}
19
+ variation={props.variation}
20
+ />
21
+ );
22
+ }
@@ -0,0 +1,2 @@
1
+ export { ProgressIndicatorBasic } from "./ProgressIndicatorBasic";
2
+ export { ProgressIndicatorPending } from "./ProgressIndicatorPending";
@@ -0,0 +1,104 @@
1
+ # ProgressIndicator (mobile)
2
+
3
+ ## Purpose
4
+
5
+ `ProgressIndicator` communicates **determinate** progress the user is advancing
6
+ through — multi-step flows, uploads with a known size, and similar. It is the
7
+ React Native counterpart to the web `@jobber/components` `ProgressIndicator` and
8
+ the replacement for the mobile `ProgressBar`.
9
+
10
+ For **indeterminate** activity the user cannot control (loading, fetching), use
11
+ an activity indicator instead — not `ProgressIndicator`.
12
+
13
+ ## API
14
+
15
+ | Prop | Type | Default | Description |
16
+ | -------------------- | -------------------------------- | -------------- | ----------------------------------------------------------- |
17
+ | `value` | `number` (required) | — | Current progress, in the range `0..max`. |
18
+ | `max` | `number` | `100` | Maximum value. |
19
+ | `pendingValue` | `number` | `0` | Additive in-flight progress beyond `value` (mobile-only). |
20
+ | `variation` | `"continuous" \| "stepped"` | `"continuous"` | Single fill vs. one segment per integer step from `1..max`. |
21
+ | `size` | `"smaller" \| "small" \| "base"` | `"base"` | Bar height: 4 / 8 / 16px. |
22
+ | `accessibilityLabel` | `string` | derived | Accessible label override (see Accessibility). |
23
+ | `style` | `StyleProp<ViewStyle>` | — | Applied to the root element. |
24
+
25
+ ## Accessibility
26
+
27
+ The root renders `<View accessibilityRole="progressbar">` with
28
+ `accessibilityValue={{ now: value, min: 0, max }}` and the resolved
29
+ `accessibilityLabel`. The component is not focusable.
30
+
31
+ When `accessibilityLabel` is omitted, it is derived via `useAtlantisI18n`:
32
+
33
+ - `"{value}%"` when `max === 100` (or omitted) and `pendingValue` is `0`;
34
+ - `"{value} / {max}"` when `max !== 100` and `pendingValue` is `0`;
35
+ - `"{value} of {max}, {pendingValue} pending"` when `pendingValue > 0`.
36
+
37
+ In the stepped variation the segments are presentational only — they carry no
38
+ role or accessibility value. The whole indicator announces once as a single
39
+ progressbar.
40
+
41
+ ## Animation Model
42
+
43
+ The continuous fill (and the pending band, when present) animate their width
44
+ with `react-native-reanimated` `withTiming` over the `timing-base` token. The
45
+ stepped variation is not animated.
46
+
47
+ The width transition is **preserved** under a reduce-motion preference. The
48
+ slide between progress values conveys functional progress and is exempt under
49
+ WCAG 2.3.3 ("Animation from Interactions"); removing it would make progress
50
+ harder to perceive, not easier. This is the opposite of an indeterminate
51
+ activity indicator, whose motion IS suppressed under reduced motion.
52
+
53
+ ### Pending overlay
54
+
55
+ `pendingValue` is **additive**: with `value={2}`, `pendingValue={3}`,
56
+ `max={10}`, the bar shows 2/10 completed and an additional 3/10 in flight, for
57
+ 5/10 visually filled. In the continuous variation the pending band spans
58
+ `value .. value + pendingValue`; in the stepped variation the segments at
59
+ `value < pos <= value + pendingValue` are the pending state.
60
+
61
+ ## Tokens
62
+
63
+ - Fill / filled segments: `color-interactive--subtle`.
64
+ - Track / empty segments: `color-surface--active`.
65
+ - Pending band / pending segments: `color-informative`.
66
+
67
+ The fill and track tokens mirror the web `ProgressIndicator`. The pending color
68
+ is carried forward from today's `ProgressBar` `inProgress` overlay so the
69
+ pending state keeps its established meaning; a dedicated pending tone is an open
70
+ question for design. No hardcoded colors and no drop shadow are used; all colors
71
+ resolve through `AtlantisThemeContext` and adapt across light and dark themes.
72
+
73
+ ## Relationship to `ProgressBar`
74
+
75
+ `ProgressBar` is deprecated in favor of `ProgressIndicator` but continues to
76
+ work unchanged. Migration mapping:
77
+
78
+ | `ProgressBar` | `ProgressIndicator` |
79
+ | ------------------------ | ----------------------------------------------------------- |
80
+ | `current` | `value` |
81
+ | `total` | `max` |
82
+ | `inProgress` | `pendingValue` (same additive meaning) |
83
+ | `variation="progress"` | `variation="continuous"` |
84
+ | `loading` | render `value={0}` or conditionally mount the component |
85
+ | `header` | wrap the indicator in your own `View` / `Content` / `Stack` |
86
+ | `reverseTheme` | wrap in `ThemeContextOverride` for the dark surface |
87
+ | `UNSAFE_style.container` | `style` |
88
+ | other `UNSAFE_style.*` | no direct replacement — compose around the component |
89
+
90
+ ## Cross-Platform Note
91
+
92
+ The `value` / `max` prop surface is aligned with the web `ProgressIndicator`.
93
+ `pendingValue` is mobile-only — web deferred it to this component.
94
+
95
+ ## Ownership
96
+
97
+ Owned by the Atlantis / UX Foundations team. Ticket: JOB-167172.
98
+
99
+ ## Out of Scope
100
+
101
+ - A `variant="ring"` shape (deferred to v2, pending design).
102
+ - Shimming `ProgressBar` onto `ProgressIndicator` (or removing it).
103
+ - A mobile `ActivityIndicator` Atlantis wrapper.
104
+ - Migrating jobber-mobile consumers off `ProgressBar`.
@@ -0,0 +1,2 @@
1
+ export { ProgressIndicator } from "./ProgressIndicator";
2
+ export type { ProgressIndicatorProps } from "./types";
@@ -0,0 +1,51 @@
1
+ import type { StyleProp, ViewStyle } from "react-native";
2
+
3
+ export interface ProgressIndicatorProps {
4
+ /**
5
+ * Current progress. Expected to be in the range `0..max` inclusive.
6
+ */
7
+ readonly value: number;
8
+
9
+ /**
10
+ * Maximum value.
11
+ * @default 100
12
+ */
13
+ readonly max?: number;
14
+
15
+ /**
16
+ * Additional in-flight progress beyond `value`, measured on the same scale as
17
+ * `value` and `max`. The bar visually fills `value + pendingValue` of `max`,
18
+ * with the `value` portion shown as completed and the `pendingValue` portion
19
+ * shown as pending. Mobile-only; there is no web counterpart.
20
+ * @default 0
21
+ */
22
+ readonly pendingValue?: number;
23
+
24
+ /**
25
+ * Set the variation of the progress indicator. `continuous` renders a single
26
+ * fill; `stepped` renders one segment per integer step from `1..max`.
27
+ * @default continuous
28
+ */
29
+ readonly variation?: "continuous" | "stepped";
30
+
31
+ /**
32
+ * Set the size of the progress indicator. Corresponds to bar heights of
33
+ * 4px / 8px / 16px.
34
+ * @default base
35
+ */
36
+ readonly size?: "smaller" | "small" | "base";
37
+
38
+ /**
39
+ * Accessible label override. When omitted, the label is derived via
40
+ * `useAtlantisI18n`: `"{value}%"` when `max` is `100` (or omitted) and
41
+ * `pendingValue` is `0`; `"{value} / {max}"` when `max` is not `100` and
42
+ * `pendingValue` is `0`; `"{value} of {max}, {pendingValue} pending"` when
43
+ * `pendingValue` is greater than `0`.
44
+ */
45
+ readonly accessibilityLabel?: string;
46
+
47
+ /**
48
+ * Custom style applied to the root element.
49
+ */
50
+ readonly style?: StyleProp<ViewStyle>;
51
+ }
@@ -26,6 +26,9 @@
26
26
  "networkUnavailableDescription": "Check your internet connection and try again later.",
27
27
  "ProgressBar.complete": "{current} of {total} complete",
28
28
  "ProgressBar.inProgress": "{current} of {total} complete, {inProgress} in progress",
29
+ "ProgressIndicator.ariaLabelPercent": "{value}%",
30
+ "ProgressIndicator.ariaLabelRatio": "{value} / {max}",
31
+ "ProgressIndicator.ariaLabelPending": "{value} of {max}, {pendingValue} pending",
29
32
  "save": "Save",
30
33
  "Select.a11yHint": "Select to open the picker",
31
34
  "Select.emptyValue": "Select an option",
@@ -26,6 +26,9 @@
26
26
  "networkUnavailableDescription": "Compruebe su conexión a Internet e inténtelo de nuevo más adelante.",
27
27
  "ProgressBar.complete": "{current} de {total} completo",
28
28
  "ProgressBar.inProgress": "{current} de {total} completo, {inProgress} en progreso",
29
+ "ProgressIndicator.ariaLabelPercent": "{value}%",
30
+ "ProgressIndicator.ariaLabelRatio": "{value} / {max}",
31
+ "ProgressIndicator.ariaLabelPending": "{value} de {max}, {pendingValue} pendiente",
29
32
  "save": "Guardar",
30
33
  "Select.a11yHint": "Seleccionar para abrir el selector",
31
34
  "Select.emptyValue": "Seleccione una opción",
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Languages that have been fully released and are safe to surface to users.
3
+ * Any locale not present in this list will fall back to "en".
4
+ *
5
+ * To release a new language, add its BCP 47 language tag here (e.g. "es").
6
+ */
7
+ export const RELEASED_LANGUAGES: readonly string[] = ["en"];
8
+
9
+ /**
10
+ * Returns the locale to use based on what has been released. Matches on the
11
+ * language part of the locale (before the "-") so that regional variants like
12
+ * "en-CA" or "es-US" resolve correctly:
13
+ *
14
+ * - "en-CA" → "en-CA" (language "en" is released; full locale preserved for
15
+ * date/time formatting)
16
+ * - "es-US" → "en" (language "es" not yet released; falls back to "en")
17
+ * - "fr" → "en" (language "fr" not released; falls back to "en")
18
+ */
19
+ export function getReleasedLocale(locale: string): string {
20
+ const language = locale.split("-")[0];
21
+
22
+ return RELEASED_LANGUAGES.includes(language) ? locale : "en";
23
+ }