@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.
- package/dist/docs/ProgressBar/ProgressBar.md +1 -1
- package/dist/docs/ProgressIndicator/ProgressIndicator.md +117 -0
- package/dist/docs/index.md +1 -0
- package/dist/package.json +2 -2
- package/dist/src/ProgressBar/ProgressBar.js +6 -0
- package/dist/src/ProgressBar/ProgressBar.test.js +2 -0
- package/dist/src/ProgressIndicator/ProgressIndicator.js +106 -0
- package/dist/src/ProgressIndicator/ProgressIndicator.size.style.js +15 -0
- package/dist/src/ProgressIndicator/ProgressIndicator.style.js +51 -0
- package/dist/src/ProgressIndicator/ProgressIndicator.test.js +138 -0
- package/dist/src/ProgressIndicator/index.js +1 -0
- package/dist/src/ProgressIndicator/types.js +1 -0
- package/dist/src/hooks/useAtlantisI18n/locales/en.json +3 -0
- package/dist/src/hooks/useAtlantisI18n/locales/es.json +3 -0
- package/dist/src/hooks/useAtlantisI18n/releasedLanguages.js +21 -0
- package/dist/src/hooks/useAtlantisI18n/useAtlantisI18n.js +4 -2
- package/dist/src/hooks/useAtlantisI18n/useAtlantisI18n.test.js +115 -15
- package/dist/src/index.js +1 -0
- package/dist/src/utils/meta/meta.json +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/src/ProgressBar/ProgressBar.d.ts +3 -0
- package/dist/types/src/ProgressBar/types.d.ts +3 -0
- package/dist/types/src/ProgressIndicator/ProgressIndicator.d.ts +3 -0
- package/dist/types/src/ProgressIndicator/ProgressIndicator.size.style.d.ts +19 -0
- package/dist/types/src/ProgressIndicator/ProgressIndicator.style.d.ts +48 -0
- package/dist/types/src/ProgressIndicator/ProgressIndicator.test.d.ts +1 -0
- package/dist/types/src/ProgressIndicator/index.d.ts +2 -0
- package/dist/types/src/ProgressIndicator/types.d.ts +44 -0
- package/dist/types/src/hooks/useAtlantisI18n/releasedLanguages.d.ts +18 -0
- package/dist/types/src/hooks/useAtlantisI18n/useAtlantisI18n.d.ts +3 -1
- package/dist/types/src/index.d.ts +1 -0
- package/package.json +2 -2
- package/src/ProgressBar/ProgressBar.stories.tsx +2 -0
- package/src/ProgressBar/ProgressBar.test.tsx +2 -0
- package/src/ProgressBar/ProgressBar.tsx +6 -0
- package/src/ProgressBar/docs/ProgressBarBasic.tsx +2 -0
- package/src/ProgressBar/docs/ProgressBarWithHeader.tsx +2 -0
- package/src/ProgressBar/types.ts +3 -0
- package/src/ProgressIndicator/ProgressIndicator.size.style.ts +20 -0
- package/src/ProgressIndicator/ProgressIndicator.stories.tsx +154 -0
- package/src/ProgressIndicator/ProgressIndicator.style.ts +53 -0
- package/src/ProgressIndicator/ProgressIndicator.test.tsx +214 -0
- package/src/ProgressIndicator/ProgressIndicator.tsx +198 -0
- package/src/ProgressIndicator/docs/ProgressIndicatorBasic.tsx +22 -0
- package/src/ProgressIndicator/docs/ProgressIndicatorPending.tsx +22 -0
- package/src/ProgressIndicator/docs/index.ts +2 -0
- package/src/ProgressIndicator/guide.md +104 -0
- package/src/ProgressIndicator/index.ts +2 -0
- package/src/ProgressIndicator/types.ts +51 -0
- package/src/hooks/useAtlantisI18n/locales/en.json +3 -0
- package/src/hooks/useAtlantisI18n/locales/es.json +3 -0
- package/src/hooks/useAtlantisI18n/releasedLanguages.ts +23 -0
- package/src/hooks/useAtlantisI18n/useAtlantisI18n.test.ts +202 -25
- package/src/hooks/useAtlantisI18n/useAtlantisI18n.ts +13 -3
- package/src/index.ts +1 -0
- 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,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,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
|
+
}
|