@jobber/components-native 0.109.1 → 0.110.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 (48) 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/index.js +1 -0
  16. package/dist/src/utils/meta/meta.json +1 -0
  17. package/dist/tsconfig.build.tsbuildinfo +1 -1
  18. package/dist/types/src/ProgressBar/ProgressBar.d.ts +3 -0
  19. package/dist/types/src/ProgressBar/types.d.ts +3 -0
  20. package/dist/types/src/ProgressIndicator/ProgressIndicator.d.ts +3 -0
  21. package/dist/types/src/ProgressIndicator/ProgressIndicator.size.style.d.ts +19 -0
  22. package/dist/types/src/ProgressIndicator/ProgressIndicator.style.d.ts +48 -0
  23. package/dist/types/src/ProgressIndicator/ProgressIndicator.test.d.ts +1 -0
  24. package/dist/types/src/ProgressIndicator/index.d.ts +2 -0
  25. package/dist/types/src/ProgressIndicator/types.d.ts +44 -0
  26. package/dist/types/src/index.d.ts +1 -0
  27. package/package.json +2 -2
  28. package/src/ProgressBar/ProgressBar.stories.tsx +2 -0
  29. package/src/ProgressBar/ProgressBar.test.tsx +2 -0
  30. package/src/ProgressBar/ProgressBar.tsx +6 -0
  31. package/src/ProgressBar/docs/ProgressBarBasic.tsx +2 -0
  32. package/src/ProgressBar/docs/ProgressBarWithHeader.tsx +2 -0
  33. package/src/ProgressBar/types.ts +3 -0
  34. package/src/ProgressIndicator/ProgressIndicator.size.style.ts +20 -0
  35. package/src/ProgressIndicator/ProgressIndicator.stories.tsx +154 -0
  36. package/src/ProgressIndicator/ProgressIndicator.style.ts +53 -0
  37. package/src/ProgressIndicator/ProgressIndicator.test.tsx +214 -0
  38. package/src/ProgressIndicator/ProgressIndicator.tsx +198 -0
  39. package/src/ProgressIndicator/docs/ProgressIndicatorBasic.tsx +22 -0
  40. package/src/ProgressIndicator/docs/ProgressIndicatorPending.tsx +22 -0
  41. package/src/ProgressIndicator/docs/index.ts +2 -0
  42. package/src/ProgressIndicator/guide.md +104 -0
  43. package/src/ProgressIndicator/index.ts +2 -0
  44. package/src/ProgressIndicator/types.ts +51 -0
  45. package/src/hooks/useAtlantisI18n/locales/en.json +3 -0
  46. package/src/hooks/useAtlantisI18n/locales/es.json +3 -0
  47. package/src/index.ts +1 -0
  48. package/src/utils/meta/meta.json +1 -0
@@ -1,3 +1,6 @@
1
1
  import React from "react";
2
2
  import type { ProgressBarProps } from "./types";
3
+ /**
4
+ * @deprecated Use `ProgressIndicator` from `@jobber/components-native` instead.
5
+ */
3
6
  export declare function ProgressBar({ loading, total, current, inProgress, reverseTheme, header, variation, size, UNSAFE_style, }: ProgressBarProps): React.JSX.Element;
@@ -1,5 +1,8 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { StyleProp, ViewStyle } from "react-native";
3
+ /**
4
+ * @deprecated Use `ProgressIndicator` from `@jobber/components-native` instead.
5
+ */
3
6
  export interface ProgressBarProps {
4
7
  /**
5
8
  * The total number of items to be completed
@@ -0,0 +1,3 @@
1
+ import React from "react";
2
+ import type { ProgressIndicatorProps } from "./types";
3
+ export declare function ProgressIndicator({ value, max, pendingValue, variation, size, accessibilityLabel, style, }: ProgressIndicatorProps): React.JSX.Element;
@@ -0,0 +1,19 @@
1
+ import type { ProgressIndicatorProps } from "./types";
2
+ type Size = NonNullable<ProgressIndicatorProps["size"]>;
3
+ /**
4
+ * Bar heights per size. Matches the web `ProgressIndicator` ladder and today's
5
+ * mobile `ProgressBar` sizing.
6
+ */
7
+ export declare const sizeHeights: Record<Size, number>;
8
+ export declare const sizeStyles: {
9
+ smaller: {
10
+ height: number;
11
+ };
12
+ small: {
13
+ height: number;
14
+ };
15
+ base: {
16
+ height: number;
17
+ };
18
+ };
19
+ export {};
@@ -0,0 +1,48 @@
1
+ /**
2
+ * ProgressIndicator
3
+ *
4
+ * Colour tokens mirror the web `ProgressIndicator`: the fill / filled segments
5
+ * use `color-interactive--subtle`; the track / empty segments use
6
+ * `color-surface--active`. The mobile-only pending band / pending segments use
7
+ * `color-informative`, carried forward from today's `ProgressBar` `inProgress`
8
+ * overlay.
9
+ *
10
+ * The bar height is set by the size style; the border radius is derived from
11
+ * that height at render time so the fill and segments stay pill-shaped at every
12
+ * size.
13
+ */
14
+ export declare const useStyles: () => {
15
+ continuous: {
16
+ width: "100%";
17
+ flexDirection: "row";
18
+ overflow: "hidden";
19
+ backgroundColor: string;
20
+ };
21
+ fill: {
22
+ height: "100%";
23
+ backgroundColor: string;
24
+ };
25
+ pending: {
26
+ height: "100%";
27
+ position: "absolute";
28
+ left: number;
29
+ top: number;
30
+ backgroundColor: string;
31
+ };
32
+ stepped: {
33
+ width: "100%";
34
+ flexDirection: "row";
35
+ gap: number;
36
+ };
37
+ segment: {
38
+ flex: number;
39
+ height: "100%";
40
+ backgroundColor: string;
41
+ };
42
+ segmentFilled: {
43
+ backgroundColor: string;
44
+ };
45
+ segmentPending: {
46
+ backgroundColor: string;
47
+ };
48
+ };
@@ -0,0 +1,2 @@
1
+ export { ProgressIndicator } from "./ProgressIndicator";
2
+ export type { ProgressIndicatorProps } from "./types";
@@ -0,0 +1,44 @@
1
+ import type { StyleProp, ViewStyle } from "react-native";
2
+ export interface ProgressIndicatorProps {
3
+ /**
4
+ * Current progress. Expected to be in the range `0..max` inclusive.
5
+ */
6
+ readonly value: number;
7
+ /**
8
+ * Maximum value.
9
+ * @default 100
10
+ */
11
+ readonly max?: number;
12
+ /**
13
+ * Additional in-flight progress beyond `value`, measured on the same scale as
14
+ * `value` and `max`. The bar visually fills `value + pendingValue` of `max`,
15
+ * with the `value` portion shown as completed and the `pendingValue` portion
16
+ * shown as pending. Mobile-only; there is no web counterpart.
17
+ * @default 0
18
+ */
19
+ readonly pendingValue?: number;
20
+ /**
21
+ * Set the variation of the progress indicator. `continuous` renders a single
22
+ * fill; `stepped` renders one segment per integer step from `1..max`.
23
+ * @default continuous
24
+ */
25
+ readonly variation?: "continuous" | "stepped";
26
+ /**
27
+ * Set the size of the progress indicator. Corresponds to bar heights of
28
+ * 4px / 8px / 16px.
29
+ * @default base
30
+ */
31
+ readonly size?: "smaller" | "small" | "base";
32
+ /**
33
+ * Accessible label override. When omitted, the label is derived via
34
+ * `useAtlantisI18n`: `"{value}%"` when `max` is `100` (or omitted) and
35
+ * `pendingValue` is `0`; `"{value} / {max}"` when `max` is not `100` and
36
+ * `pendingValue` is `0`; `"{value} of {max}, {pendingValue} pending"` when
37
+ * `pendingValue` is greater than `0`.
38
+ */
39
+ readonly accessibilityLabel?: string;
40
+ /**
41
+ * Custom style applied to the root element.
42
+ */
43
+ readonly style?: StyleProp<ViewStyle>;
44
+ }
@@ -37,6 +37,7 @@ export * from "./InputSearch";
37
37
  export * from "./InputText";
38
38
  export * from "./InputTime";
39
39
  export * from "./ProgressBar";
40
+ export * from "./ProgressIndicator";
40
41
  export * from "./Select";
41
42
  export * from "./StatusLabel";
42
43
  export * from "./Switch";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jobber/components-native",
3
- "version": "0.109.1",
3
+ "version": "0.110.0",
4
4
  "license": "MIT",
5
5
  "description": "React Native implementation of Atlantis",
6
6
  "repository": {
@@ -124,5 +124,5 @@
124
124
  "react-native-screens": ">=4.18.0",
125
125
  "react-native-svg": ">=12.0.0"
126
126
  },
127
- "gitHead": "879bbb072a470c876e4ee7e9a9757f5edb0f3b82"
127
+ "gitHead": "c81a2b04748119a1bd84cc39f4f5e7a97c410afb"
128
128
  }
@@ -1,3 +1,5 @@
1
+ /* eslint-disable import/no-deprecated -- This file documents the deprecated
2
+ * ProgressBar component until it is removed or shimmed. */
1
3
  import type { ComponentProps } from "react";
2
4
  import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
3
5
  import { ProgressBar } from "@jobber/components-native";
@@ -1,3 +1,5 @@
1
+ /* eslint-disable import/no-deprecated -- This file tests the deprecated
2
+ * ProgressBar component until it is removed or shimmed. */
1
3
  import React from "react";
2
4
  import { render, screen } from "@testing-library/react-native";
3
5
  import type { ProgressBarProps } from ".";
@@ -1,3 +1,6 @@
1
+ /* eslint-disable import/no-deprecated -- This file implements the deprecated
2
+ * ProgressBar component; self-references predate the deprecation. Migrate to
3
+ * ProgressIndicator. */
1
4
  import React from "react";
2
5
  import { View } from "react-native";
3
6
  import type { ProgressBarProps } from "./types";
@@ -8,6 +11,9 @@ import { sizeStyles } from "./ProgressBar.size.style";
8
11
  import { useAtlantisI18n } from "../hooks/useAtlantisI18n";
9
12
  import { useAtlantisTheme } from "../AtlantisThemeContext";
10
13
 
14
+ /**
15
+ * @deprecated Use `ProgressIndicator` from `@jobber/components-native` instead.
16
+ */
11
17
  export function ProgressBar({
12
18
  loading,
13
19
  total,
@@ -1,3 +1,5 @@
1
+ /* eslint-disable import/no-deprecated -- This example documents the deprecated
2
+ * ProgressBar component until it is removed or shimmed. */
1
3
  import React from "react";
2
4
  import { ProgressBar } from "@jobber/components-native";
3
5
  import type { ProgressBarProps } from "@jobber/components-native";
@@ -1,3 +1,5 @@
1
+ /* eslint-disable import/no-deprecated -- This example documents the deprecated
2
+ * ProgressBar component until it is removed or shimmed. */
1
3
  import React, { useState } from "react";
2
4
  import {
3
5
  Button,
@@ -1,6 +1,9 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { StyleProp, ViewStyle } from "react-native";
3
3
 
4
+ /**
5
+ * @deprecated Use `ProgressIndicator` from `@jobber/components-native` instead.
6
+ */
4
7
  export interface ProgressBarProps {
5
8
  /**
6
9
  * The total number of items to be completed
@@ -0,0 +1,20 @@
1
+ import { StyleSheet } from "react-native";
2
+ import type { ProgressIndicatorProps } from "./types";
3
+
4
+ type Size = NonNullable<ProgressIndicatorProps["size"]>;
5
+
6
+ /**
7
+ * Bar heights per size. Matches the web `ProgressIndicator` ladder and today's
8
+ * mobile `ProgressBar` sizing.
9
+ */
10
+ export const sizeHeights: Record<Size, number> = {
11
+ smaller: 4,
12
+ small: 8,
13
+ base: 16,
14
+ };
15
+
16
+ export const sizeStyles = StyleSheet.create({
17
+ smaller: { height: sizeHeights.smaller },
18
+ small: { height: sizeHeights.small },
19
+ base: { height: sizeHeights.base },
20
+ });
@@ -0,0 +1,154 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { View } from "react-native";
3
+ import type { ComponentProps } from "react";
4
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
5
+ import { ProgressIndicator } from "@jobber/components-native";
6
+ import { ProgressIndicatorBasic, ProgressIndicatorPending } from "./docs";
7
+
8
+ type ProgressIndicatorStoryArgs = Partial<
9
+ Pick<
10
+ ComponentProps<typeof ProgressIndicator>,
11
+ | "value"
12
+ | "max"
13
+ | "pendingValue"
14
+ | "variation"
15
+ | "size"
16
+ | "accessibilityLabel"
17
+ >
18
+ >;
19
+
20
+ const meta = {
21
+ title: "Components/Status and Feedback/ProgressIndicator",
22
+ component: ProgressIndicator,
23
+ parameters: {
24
+ viewport: { defaultViewport: "mobile1" },
25
+ showNativeOnWebDisclaimer: true,
26
+ },
27
+ argTypes: {
28
+ variation: {
29
+ control: { type: "radio" },
30
+ options: ["continuous", "stepped"],
31
+ },
32
+ size: {
33
+ control: { type: "radio" },
34
+ options: ["smaller", "small", "base"],
35
+ },
36
+ },
37
+ } satisfies Meta<typeof ProgressIndicator>;
38
+
39
+ export default meta;
40
+
41
+ type Story = StoryObj<ProgressIndicatorStoryArgs>;
42
+
43
+ /**
44
+ * The default continuous bar, filled to `value` out of `max`.
45
+ */
46
+ export const Basic: Story = {
47
+ render: ProgressIndicatorBasic,
48
+ args: {
49
+ value: 75,
50
+ size: "base",
51
+ },
52
+ };
53
+
54
+ /**
55
+ * The three sizes stacked so the heights (4 / 8 / 16px) are visually
56
+ * comparable.
57
+ */
58
+ export const Sizes: Story = {
59
+ render: args => (
60
+ <View style={{ gap: 16 }}>
61
+ <ProgressIndicator {...args} value={args.value ?? 50} size="smaller" />
62
+ <ProgressIndicator {...args} value={args.value ?? 50} size="small" />
63
+ <ProgressIndicator {...args} value={args.value ?? 50} size="base" />
64
+ </View>
65
+ ),
66
+ args: {
67
+ value: 50,
68
+ },
69
+ };
70
+
71
+ /**
72
+ * The stepped variation renders one segment per step and fills the first
73
+ * `value` of `max`.
74
+ */
75
+ export const Stepped: Story = {
76
+ render: ProgressIndicatorBasic,
77
+ args: {
78
+ value: 2,
79
+ max: 5,
80
+ variation: "stepped",
81
+ size: "base",
82
+ },
83
+ };
84
+
85
+ /**
86
+ * Mobile-only pending overlay: `value` of `max` completed plus an additional
87
+ * `pendingValue` of `max` in flight.
88
+ */
89
+ export const Pending: Story = {
90
+ render: ProgressIndicatorPending,
91
+ args: {
92
+ value: 2,
93
+ max: 10,
94
+ pendingValue: 3,
95
+ },
96
+ };
97
+
98
+ /**
99
+ * The stepped variation with a pending overlay: the first `value` segments are
100
+ * filled and the next `pendingValue` segments are shown as in flight.
101
+ */
102
+ export const SteppedPending: Story = {
103
+ render: ProgressIndicatorPending,
104
+ args: {
105
+ value: 3,
106
+ max: 6,
107
+ pendingValue: 2,
108
+ variation: "stepped",
109
+ },
110
+ };
111
+
112
+ /**
113
+ * Demonstrates the default `"{value}%"` accessibility label when `max` is
114
+ * omitted or equal to 100.
115
+ */
116
+ export const Percentage: Story = {
117
+ render: ProgressIndicatorBasic,
118
+ args: {
119
+ value: 42,
120
+ },
121
+ };
122
+
123
+ /**
124
+ * Demonstrates the default `"{value} / {max}"` accessibility label when
125
+ * `max !== 100`.
126
+ */
127
+ export const Ratio: Story = {
128
+ render: ProgressIndicatorBasic,
129
+ args: {
130
+ value: 3,
131
+ max: 4,
132
+ },
133
+ };
134
+
135
+ /**
136
+ * Drives `value` over time so the determinate width transition is visible. The
137
+ * transition is intentionally preserved under a reduce-motion preference
138
+ * because it conveys functional progress (WCAG 2.3.3).
139
+ */
140
+ export const WithState: Story = {
141
+ render: function WithStateStory() {
142
+ const [value, setValue] = useState(0);
143
+
144
+ useEffect(() => {
145
+ const interval = setInterval(() => {
146
+ setValue(current => (current >= 100 ? 0 : current + 10));
147
+ }, 1000);
148
+
149
+ return () => clearInterval(interval);
150
+ }, []);
151
+
152
+ return <ProgressIndicator value={value} />;
153
+ },
154
+ };
@@ -0,0 +1,53 @@
1
+ import { buildThemedStyles } from "../AtlantisThemeContext";
2
+
3
+ /**
4
+ * ProgressIndicator
5
+ *
6
+ * Colour tokens mirror the web `ProgressIndicator`: the fill / filled segments
7
+ * use `color-interactive--subtle`; the track / empty segments use
8
+ * `color-surface--active`. The mobile-only pending band / pending segments use
9
+ * `color-informative`, carried forward from today's `ProgressBar` `inProgress`
10
+ * overlay.
11
+ *
12
+ * The bar height is set by the size style; the border radius is derived from
13
+ * that height at render time so the fill and segments stay pill-shaped at every
14
+ * size.
15
+ */
16
+ export const useStyles = buildThemedStyles(tokens => ({
17
+ /* Continuous: the root IS the track; the fill / pending sit on top. */
18
+ continuous: {
19
+ width: "100%",
20
+ flexDirection: "row",
21
+ overflow: "hidden",
22
+ backgroundColor: tokens["color-surface--active"],
23
+ },
24
+ fill: {
25
+ height: "100%",
26
+ backgroundColor: tokens["color-interactive--subtle"],
27
+ },
28
+ pending: {
29
+ height: "100%",
30
+ position: "absolute",
31
+ left: 0,
32
+ top: 0,
33
+ backgroundColor: tokens["color-informative"],
34
+ },
35
+
36
+ /* Stepped: the root IS the flex row; segments are direct children. */
37
+ stepped: {
38
+ width: "100%",
39
+ flexDirection: "row",
40
+ gap: tokens["space-small"],
41
+ },
42
+ segment: {
43
+ flex: 1,
44
+ height: "100%",
45
+ backgroundColor: tokens["color-surface--active"],
46
+ },
47
+ segmentFilled: {
48
+ backgroundColor: tokens["color-interactive--subtle"],
49
+ },
50
+ segmentPending: {
51
+ backgroundColor: tokens["color-informative"],
52
+ },
53
+ }));
@@ -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
+ });