diff --git a/packages/pluggableWidgets/progress-bar-native/package.json b/packages/pluggableWidgets/progress-bar-native/package.json index fc5515b18..695c12638 100644 --- a/packages/pluggableWidgets/progress-bar-native/package.json +++ b/packages/pluggableWidgets/progress-bar-native/package.json @@ -20,8 +20,7 @@ }, "dependencies": { "@mendix/piw-native-utils-internal": "*", - "@mendix/piw-utils-internal": "1.0.0", - "react-native-progress": "5.0.1" + "@mendix/piw-utils-internal": "1.0.0" }, "devDependencies": { "@mendix/pluggable-widgets-tools": "*" diff --git a/packages/pluggableWidgets/progress-bar-native/src/ProgressBar.tsx b/packages/pluggableWidgets/progress-bar-native/src/ProgressBar.tsx index b8b57d1ad..ccedf780a 100644 --- a/packages/pluggableWidgets/progress-bar-native/src/ProgressBar.tsx +++ b/packages/pluggableWidgets/progress-bar-native/src/ProgressBar.tsx @@ -1,7 +1,6 @@ -import { flattenStyles } from "@mendix/piw-native-utils-internal"; +import { Bar, flattenStyles } from "@mendix/piw-native-utils-internal"; import { isAvailable } from "@mendix/piw-utils-internal"; import { Text, View } from "react-native"; -import { Bar } from "react-native-progress"; import { JSX } from "react"; import { ProgressBarProps } from "../typings/ProgressBarProps"; @@ -55,15 +54,28 @@ export function ProgressBar(props: ProgressBarProps): JSX.Elem return ( - + + + {validationMessages.length > 0 && ( {validationMessages.join("\n")} )} diff --git a/packages/pluggableWidgets/progress-bar-native/src/__tests__/ProgressBar.spec.tsx b/packages/pluggableWidgets/progress-bar-native/src/__tests__/ProgressBar.spec.tsx index 6b03cee7e..2df20f6b3 100644 --- a/packages/pluggableWidgets/progress-bar-native/src/__tests__/ProgressBar.spec.tsx +++ b/packages/pluggableWidgets/progress-bar-native/src/__tests__/ProgressBar.spec.tsx @@ -1,7 +1,7 @@ +import { Bar } from "@mendix/piw-native-utils-internal"; import { dynamicValue } from "@mendix/piw-utils-internal"; import { Big } from "big.js"; -import { Text } from "react-native"; -import { Bar } from "react-native-progress"; +import { Text, View } from "react-native"; import { render } from "@testing-library/react-native"; import { ProgressBar, Props } from "../ProgressBar"; @@ -47,6 +47,30 @@ describe("ProgressBar", () => { expect(component.UNSAFE_queryByType(Text)).toBeNull(); }); + it("has progressbar accessibility role", () => { + const component = render(); + const views = component.UNSAFE_getAllByType(View); + // Find the View with accessibilityRole="progressbar" + const accessibleElement = views.find(view => view.props.accessibilityRole === "progressbar"); + expect(accessibleElement).toBeDefined(); + expect(accessibleElement?.props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 60 + }); + }); + + it("has correct accessibility values with different range", () => { + const component = render(); + const views = component.UNSAFE_getAllByType(View); + const accessibleElement = views.find(view => view.props.accessibilityRole === "progressbar"); + expect(accessibleElement?.props.accessibilityValue).toEqual({ + min: 10, + max: 50, + now: 25 + }); + }); + it("preserves expected style key set", () => { const keys = Object.keys(defaultProgressBarStyle).sort(); expect(keys).toEqual(["bar", "container", "fill", "validationMessage"]); diff --git a/packages/pluggableWidgets/progress-bar-native/src/__tests__/__snapshots__/ProgressBar.spec.tsx.snap b/packages/pluggableWidgets/progress-bar-native/src/__tests__/__snapshots__/ProgressBar.spec.tsx.snap index f35f7be67..e4aa2b445 100644 --- a/packages/pluggableWidgets/progress-bar-native/src/__tests__/__snapshots__/ProgressBar.spec.tsx.snap +++ b/packages/pluggableWidgets/progress-bar-native/src/__tests__/__snapshots__/ProgressBar.spec.tsx.snap @@ -5,58 +5,53 @@ exports[`ProgressBar renders 1`] = ` style={{}} > + testID="progress-bar-test" + > + + `; diff --git a/packages/pluggableWidgets/progress-circle-native/package.json b/packages/pluggableWidgets/progress-circle-native/package.json index fd47ea887..03c01e2ce 100644 --- a/packages/pluggableWidgets/progress-circle-native/package.json +++ b/packages/pluggableWidgets/progress-circle-native/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@mendix/piw-native-utils-internal": "*", - "react-native-progress": "5.0.1" + "react-native-svg": "15.15.4" }, "devDependencies": { "@mendix/piw-utils-internal": "*", diff --git a/packages/pluggableWidgets/progress-circle-native/src/ProgressCircle.tsx b/packages/pluggableWidgets/progress-circle-native/src/ProgressCircle.tsx index b44bf5ef1..328e78190 100644 --- a/packages/pluggableWidgets/progress-circle-native/src/ProgressCircle.tsx +++ b/packages/pluggableWidgets/progress-circle-native/src/ProgressCircle.tsx @@ -1,92 +1,146 @@ -import { available, flattenStyles, unavailable } from "@mendix/piw-native-utils-internal"; -import { Component, JSX } from "react"; +import { Arc, available, flattenStyles, unavailable } from "@mendix/piw-native-utils-internal"; +import { JSX } from "react"; import { Text, View, PixelRatio } from "react-native"; -import { Circle } from "react-native-progress"; +import { Svg } from "react-native-svg"; import { ProgressCircleProps } from "../typings/ProgressCircleProps"; import { defaultProgressCircleStyle, ProgressCircleStyle } from "./ui/Styles"; export type Props = ProgressCircleProps; -export class ProgressCircle extends Component { - private readonly styles = flattenStyles(defaultProgressCircleStyle, this.props.style); +const CIRCLE = Math.PI * 2; - render(): JSX.Element { - const validationMessages = this.validate(); - const progress = validationMessages.length === 0 ? this.calculateProgress() : 0; - const showsText = this.props.circleText !== "none"; - return ( - - this.formatText(progress) } : {})} - strokeCap={this.styles.fill.lineCapRounded ? "round" : "square"} +export function ProgressCircle(props: Props): JSX.Element { + const styles = flattenStyles(defaultProgressCircleStyle, props.style); + const validationMessages = validate(props); + const progress = validationMessages.length === 0 ? calculateProgress(props) : 0; + const showsText = props.circleText !== "none"; + + // Update the progress size based on the device's font scale + const size = Number(styles.circle.size) * PixelRatio.getFontScale(); + const border = Number(styles.circle.borderWidth) || 0; + const thickness = Number(styles.fill.width); + const color = styles.fill.backgroundColor; + const strokeCap = styles.fill.lineCapRounded ? "round" : "square"; + + const radius = size / 2 - border; + const offset = { top: border, left: border }; + const textOffset = border + thickness; + const textSize = size - textOffset * 2; + const angle = progress * CIRCLE; + + const text = showsText ? formatText(progress, props) : ""; + + return ( + + + - {validationMessages.length > 0 && ( - {validationMessages.join("\n")} + {border > 0 && ( + )} - - ); - } + + {showsText && ( + + + {text} + + + )} + {validationMessages.length > 0 && ( + {validationMessages.join("\n")} + )} + + ); +} - private formatText(progress: number): string { - switch (this.props.circleText as "customText" | "percentage") { - case "customText": - return (this.props.customText && this.props.customText.value) || ""; - case "percentage": - return `${Math.round(progress * 100)}%`; - default: - return ""; - } +function formatText(progress: number, props: Props): string { + switch (props.circleText as "customText" | "percentage") { + case "customText": + return (props.customText && props.customText.value) || ""; + case "percentage": + return `${Math.round(progress * 100)}%`; + default: + return ""; } +} - private validate(): string[] { - const messages: string[] = []; - const { minimumValue, maximumValue, progressValue } = this.props; +function validate(props: Props): string[] { + const messages: string[] = []; + const { minimumValue, maximumValue, progressValue } = props; - if (unavailable(minimumValue)) { - messages.push("No minimum value provided."); - } - if (unavailable(maximumValue)) { - messages.push("No maximum value provided."); - } - if (unavailable(progressValue)) { - messages.push("No current value provided."); - } - if (available(minimumValue) && available(maximumValue) && available(progressValue)) { - if (minimumValue.value!.gte(maximumValue.value!)) { - messages.push("The minimum value must be equal or less than the maximum value."); - } else { - if (progressValue.value!.lt(minimumValue.value!)) { - messages.push("The current value must be equal or greater than the minimum value."); - } - if (progressValue.value!.gt(maximumValue.value!)) { - messages.push("The current value must be equal or less than the maximum value."); - } + if (unavailable(minimumValue)) { + messages.push("No minimum value provided."); + } + if (unavailable(maximumValue)) { + messages.push("No maximum value provided."); + } + if (unavailable(progressValue)) { + messages.push("No current value provided."); + } + if (available(minimumValue) && available(maximumValue) && available(progressValue)) { + if (minimumValue.value!.gte(maximumValue.value!)) { + messages.push("The minimum value must be equal or less than the maximum value."); + } else { + if (progressValue.value!.lt(minimumValue.value!)) { + messages.push("The current value must be equal or greater than the minimum value."); + } + if (progressValue.value!.gt(maximumValue.value!)) { + messages.push("The current value must be equal or less than the maximum value."); } } - - return messages; } - private calculateProgress(): number { - const { minimumValue, maximumValue, progressValue } = this.props; + return messages; +} - if (!available(minimumValue) || !available(maximumValue) || !available(progressValue)) { - return 0; - } +function calculateProgress(props: Props): number { + const { minimumValue, maximumValue, progressValue } = props; - const numerator = progressValue.value!.minus(minimumValue.value!); - const denominator = maximumValue.value!.minus(minimumValue.value!); - return Number(numerator.div(denominator)); + if (!available(minimumValue) || !available(maximumValue) || !available(progressValue)) { + return 0; } + + const numerator = progressValue.value!.minus(minimumValue.value!); + const denominator = maximumValue.value!.minus(minimumValue.value!); + return Number(numerator.div(denominator)); } diff --git a/packages/pluggableWidgets/progress-circle-native/src/__tests__/ProgressCircle.spec.tsx b/packages/pluggableWidgets/progress-circle-native/src/__tests__/ProgressCircle.spec.tsx index fef764eaf..af891503d 100644 --- a/packages/pluggableWidgets/progress-circle-native/src/__tests__/ProgressCircle.spec.tsx +++ b/packages/pluggableWidgets/progress-circle-native/src/__tests__/ProgressCircle.spec.tsx @@ -1,23 +1,33 @@ +import { Arc } from "@mendix/piw-native-utils-internal"; import { dynamicValue } from "@mendix/piw-utils-internal"; import { Big } from "big.js"; import { Text, PixelRatio } from "react-native"; -import { Circle } from "react-native-progress"; +import { Svg } from "react-native-svg"; import { render } from "@testing-library/react-native"; import { ProgressCircle, Props } from "../ProgressCircle"; import { defaultProgressCircleStyle } from "../ui/Styles"; +const TEST_ID = "progress-circle-test"; + +// The progress arc is always the first Arc rendered; the optional border ring is the second. +function getProgressArc( + component: ReturnType +): ReturnType[number] { + return component.UNSAFE_getAllByType(Arc)[0]; +} + describe("ProgressCircle", () => { it("renders", () => { jest.spyOn(PixelRatio, "getFontScale").mockReturnValue(1); const component = render(); expect(component.toJSON()).toMatchSnapshot(); - expect(component.UNSAFE_getByType(Circle).props.progress).toBe(0.5); + expect(getProgressArc(component).props.endAngle).toBeCloseTo(Math.PI); }); it("renders no progress with undefined values", () => { const component = render(); - expect(component.UNSAFE_getByType(Circle).props.progress).toBe(0); + expect(getProgressArc(component).props.endAngle).toBe(0); expect(component.UNSAFE_getByType(Text).props.children).toBe( `No minimum value provided. No maximum value provided. @@ -27,7 +37,7 @@ No current value provided.` it("renders no progress and an error when minimum equals maximum", () => { const component = render(); - expect(component.UNSAFE_getByType(Circle).props.progress).toBe(0); + expect(getProgressArc(component).props.endAngle).toBe(0); expect(component.UNSAFE_getByType(Text).props.children).toBe( "The minimum value must be equal or less than the maximum value." ); @@ -35,7 +45,7 @@ No current value provided.` it("renders no progress and an error when the value is less than the minimum", () => { const component = render(); - expect(component.UNSAFE_getByType(Circle).props.progress).toBe(0); + expect(getProgressArc(component).props.endAngle).toBe(0); expect(component.UNSAFE_getByType(Text).props.children).toBe( "The current value must be equal or greater than the minimum value." ); @@ -43,7 +53,7 @@ No current value provided.` it("renders no progress and an error when the value is greater than the maximum", () => { const component = render(); - expect(component.UNSAFE_getByType(Circle).props.progress).toBe(0); + expect(getProgressArc(component).props.endAngle).toBe(0); expect(component.UNSAFE_getByType(Text).props.children).toBe( "The current value must be equal or less than the maximum value." ); @@ -51,12 +61,12 @@ No current value provided.` it("renders correct progress with decimal values", () => { const component = render(); - expect(component.UNSAFE_getByType(Circle).props.progress).toBe(0.25); + expect(getProgressArc(component).props.endAngle).toBeCloseTo(0.25 * Math.PI * 2); }); it("renders correct progress with negative values", () => { const component = render(); - expect(component.UNSAFE_getByType(Circle).props.progress).toBe(0.7); + expect(getProgressArc(component).props.endAngle).toBeCloseTo(0.7 * Math.PI * 2); }); it("renders custom text", () => { @@ -82,6 +92,62 @@ No current value provided.` expect(component.UNSAFE_queryByType(Text)).toBeNull(); }); + it("has progressbar accessibility role", () => { + const component = render(); + const accessibleElement = component.getByTestId(TEST_ID); + expect(accessibleElement.props.accessibilityRole).toBe("progressbar"); + expect(accessibleElement.props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 60, + text: "60%" + }); + }); + + it("omits accessibility text when circleText is none", () => { + const component = render(); + const accessibleElement = component.getByTestId(TEST_ID); + expect(accessibleElement.props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 60 + }); + }); + + it("scales size by device font scale", () => { + const spy = jest.spyOn(PixelRatio, "getFontScale").mockReturnValue(2); + const component = render(); + const svg = component.UNSAFE_getByType(Svg); + expect(svg.props.width).toBe(defaultProgressCircleStyle.circle.size * 2); + expect(svg.props.height).toBe(defaultProgressCircleStyle.circle.size * 2); + spy.mockRestore(); + }); + + it("maps fill.width to the arc stroke width", () => { + const component = render(); + expect(getProgressArc(component).props.strokeWidth).toBe(defaultProgressCircleStyle.fill.width); + }); + + it("maps fill.lineCapRounded to strokeCap", () => { + const component = render(); + expect(getProgressArc(component).props.strokeCap).toBe("square"); + }); + + it("maps fill.lineCapRounded=true to a round strokeCap", () => { + const component = render( + + ); + expect(getProgressArc(component).props.strokeCap).toBe("round"); + }); + it("preserves expected style key set", () => { const keys = Object.keys(defaultProgressCircleStyle).sort(); expect(keys).toEqual(["circle", "container", "fill", "text", "validationMessage"]); @@ -100,7 +166,7 @@ No current value provided.` function createProps(progressValue?: number, minimumValue?: number, maximumValue?: number): Props { return { - name: "progress-circle-test", + name: TEST_ID, style: [], circleText: "percentage", progressValue: dynamicValue(progressValue != null ? new Big(progressValue) : undefined), diff --git a/packages/pluggableWidgets/progress-circle-native/src/__tests__/__snapshots__/ProgressCircle.spec.tsx.snap b/packages/pluggableWidgets/progress-circle-native/src/__tests__/__snapshots__/ProgressCircle.spec.tsx.snap index 066efa218..4b03fab08 100644 --- a/packages/pluggableWidgets/progress-circle-native/src/__tests__/__snapshots__/ProgressCircle.spec.tsx.snap +++ b/packages/pluggableWidgets/progress-circle-native/src/__tests__/__snapshots__/ProgressCircle.spec.tsx.snap @@ -2,137 +2,134 @@ exports[`ProgressCircle renders 1`] = ` - - - - - + - - - + + + + - - 50% - - + 50% + `; diff --git a/packages/tools/piw-native-utils-internal/package.json b/packages/tools/piw-native-utils-internal/package.json index 373b559bd..67482a640 100644 --- a/packages/tools/piw-native-utils-internal/package.json +++ b/packages/tools/piw-native-utils-internal/package.json @@ -17,6 +17,7 @@ "prebuild": "rimraf dist", "build": "tsc", "lint": "eslint --ext .jsx,.js,.ts,.tsx src/", + "test": "jest --projects \"$(node -p \"require.resolve('@mendix/pluggable-widgets-tools/test-config/jest.native.config.js')\")\"", "prepare": "pnpm build", "postinstall": "pnpm build" }, diff --git a/packages/tools/piw-native-utils-internal/src/index.ts b/packages/tools/piw-native-utils-internal/src/index.ts index 1fc8e356c..3ec56f693 100644 --- a/packages/tools/piw-native-utils-internal/src/index.ts +++ b/packages/tools/piw-native-utils-internal/src/index.ts @@ -1,3 +1,5 @@ export { flattenStyles, Style } from "./styles"; export { only, exclude } from "./objectFilters"; export { available, toNumber, unavailable } from "./properties"; +export { Bar, Arc, makeArcPath } from "./primitives"; +export type { BarProps, ArcProps } from "./primitives"; diff --git a/packages/tools/piw-native-utils-internal/src/primitives/Arc.tsx b/packages/tools/piw-native-utils-internal/src/primitives/Arc.tsx new file mode 100644 index 000000000..3b4145e6f --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/primitives/Arc.tsx @@ -0,0 +1,110 @@ +import { JSX } from "react"; +import { ColorValue } from "react-native"; +import { Path } from "react-native-svg"; + +export interface ArcProps { + radius: number; + startAngle: number; + endAngle: number; + strokeWidth: number; + stroke: ColorValue; + strokeCap?: "butt" | "round" | "square"; + fill?: ColorValue; + offset?: { top: number; left: number }; + direction?: "clockwise" | "counter-clockwise"; + testID?: string; +} + +const CIRCLE = Math.PI * 2; + +/** + * Calculates SVG path data for an arc. + * Matches react-native-progress Arc.js implementation exactly. + * + * @param x - Center X coordinate + * @param y - Center Y coordinate + * @param startAngleArg - Start angle in radians + * @param endAngleArg - End angle in radians + * @param radius - Arc radius + * @param direction - Direction of arc drawing + * @returns SVG path string + */ +export function makeArcPath( + x: number, + y: number, + startAngleArg: number, + endAngleArg: number, + radius: number, + direction: "clockwise" | "counter-clockwise" = "clockwise" +): string { + let startAngle = startAngleArg; + let endAngle = endAngleArg; + + // Normalize angles to be within 0-2π + if (endAngle - startAngle >= CIRCLE) { + endAngle = CIRCLE + (endAngle % CIRCLE); + } else { + endAngle = endAngle % CIRCLE; + } + startAngle = startAngle % CIRCLE; + + const angle = startAngle > endAngle ? CIRCLE - startAngle + endAngle : endAngle - startAngle; + + // Special case for full circle + if (angle >= CIRCLE) { + return `M${x + radius} ${y} + a${radius} ${radius} 0 0 1 0 ${radius * 2} + a${radius} ${radius} 0 0 1 0 ${radius * -2}`; + } + + const directionFactor = direction === "counter-clockwise" ? -1 : 1; + endAngle *= directionFactor; + startAngle *= directionFactor; + + const startSine = Math.sin(startAngle); + const startCosine = Math.cos(startAngle); + const endSine = Math.sin(endAngle); + const endCosine = Math.cos(endAngle); + + const arcFlag = angle > Math.PI ? 1 : 0; + const reverseFlag = direction === "counter-clockwise" ? 0 : 1; + + return `M${x + radius * (1 + startSine)} ${y + radius - radius * startCosine} + A${radius} ${radius} 0 ${arcFlag} ${reverseFlag} ${x + radius * (1 + endSine)} ${ + y + radius - radius * endCosine + }`; +} + +export function Arc(props: ArcProps): JSX.Element { + const { + radius, + startAngle, + endAngle, + strokeWidth, + stroke, + strokeCap = "butt", + fill = "none", + offset = { top: 0, left: 0 }, + direction = "clockwise", + testID + } = props; + + // Match react-native-progress behavior: adjust offset and radius by strokeWidth + const adjustedX = (offset.left || 0) + strokeWidth / 2; + const adjustedY = (offset.top || 0) + strokeWidth / 2; + const adjustedRadius = radius - strokeWidth / 2; + + // Generate the arc path + const pathData = makeArcPath(adjustedX, adjustedY, startAngle, endAngle, adjustedRadius, direction); + + return ( + + ); +} diff --git a/packages/tools/piw-native-utils-internal/src/primitives/Bar.tsx b/packages/tools/piw-native-utils-internal/src/primitives/Bar.tsx new file mode 100644 index 000000000..3930ea6d0 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/primitives/Bar.tsx @@ -0,0 +1,88 @@ +import { JSX, useState } from "react"; +import { LayoutChangeEvent, View, ViewStyle, I18nManager, ColorValue } from "react-native"; + +export interface BarProps { + progress: number; + color: ColorValue; + height: number; + width?: number | null; + borderWidth?: number | string; + borderColor?: ColorValue; + borderRadius?: number | string; + unfilledColor?: ColorValue; + style?: ViewStyle; + testID?: string; +} + +export function Bar(props: BarProps): JSX.Element { + const { + progress, + color, + height, + width, + borderWidth = 0, + borderColor, + borderRadius = 0, + unfilledColor, + style, + testID + } = props; + + // Track layout width when width prop is null, matching react-native-progress behavior + const [layoutWidth, setLayoutWidth] = useState(0); + + // Clamp progress between 0 and 1, matching react-native-progress behavior + const clampedProgress = Math.min(Math.max(progress, 0), 1); + + // Normalize numeric values from string | number to number + const numericBorderWidth = typeof borderWidth === "string" ? parseFloat(borderWidth) || 0 : borderWidth || 0; + const numericBorderRadius = typeof borderRadius === "string" ? parseFloat(borderRadius) || 0 : borderRadius || 0; + + // Calculate inner width considering border + // Use provided width or measured layout width, matching react-native-progress + const innerWidth = Math.max(0, (width || layoutWidth) - numericBorderWidth * 2); + + // Match react-native-progress transform behavior + // Uses interpolation for RTL: inputRange [0,1] -> outputRange [innerWidth/-2, 0] + // And scaleX: inputRange [0,1] -> outputRange [0.0001, 1] + const translateX = I18nManager.isRTL + ? (innerWidth / 2) * (1 - clampedProgress) + : (innerWidth / -2) * (1 - clampedProgress); + + // Use 0.0001 instead of 0 to avoid transform issues (react-native-progress workaround) + const scaleX = clampedProgress === 0 ? 0.0001 : clampedProgress; + + const handleLayout = (event: LayoutChangeEvent): void => { + if (!width) { + setLayoutWidth(event.nativeEvent.layout.width); + } + }; + + return ( + + + + ); +} diff --git a/packages/tools/piw-native-utils-internal/src/primitives/__tests__/Arc.spec.tsx b/packages/tools/piw-native-utils-internal/src/primitives/__tests__/Arc.spec.tsx new file mode 100644 index 000000000..cb55ccdff --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/primitives/__tests__/Arc.spec.tsx @@ -0,0 +1,132 @@ +import { JSX } from "react"; +import { render } from "@testing-library/react-native"; +import { Path, Svg } from "react-native-svg"; + +import { Arc, makeArcPath } from "../Arc"; + +describe("makeArcPath", () => { + it("generates path for quarter circle (90 degrees)", () => { + const path = makeArcPath(50, 50, 0, Math.PI / 2, 40, "clockwise"); + // With sine/cosine positioning: start at angle 0, end at PI/2 + // Should be arc flag 0 (small arc), clockwise direction flag 1 + expect(path).toContain("A40 40 0 0 1"); + }); + + it("generates path for exactly half circle (180 degrees)", () => { + const path = makeArcPath(50, 50, 0, Math.PI, 40, "clockwise"); + // Large arc flag only triggers when angle is strictly greater than PI, + // so exactly half a circle still uses the small-arc flag (matches react-native-progress). + expect(path).toContain("A40 40 0 0 1"); + }); + + it("generates special path for full circle", () => { + const path = makeArcPath(50, 50, 0, Math.PI * 2, 40, "clockwise"); + // Full circle has special handling with two arc commands + expect(path).toContain(`M${50 + 40} ${50}`); + expect(path).toContain(`a${40} ${40}`); + }); + + it("handles three-quarter circle", () => { + const path = makeArcPath(50, 50, 0, (Math.PI * 3) / 2, 40, "clockwise"); + // Angle > PI so should use large arc flag + expect(path).toContain("A40 40 0 1 1"); + }); + + it("supports counter-clockwise direction", () => { + const path = makeArcPath(50, 50, 0, Math.PI / 2, 40, "counter-clockwise"); + // Counter-clockwise should use reverse flag 0 + expect(path).toContain("A40 40 0 0 0"); + }); + + it("handles zero-length arc", () => { + const path = makeArcPath(50, 50, 0, 0, 40, "clockwise"); + // Start and end at same point; path data spans multiple lines, so match with toContain + // rather than a "." regex (which doesn't cross the embedded newline). + expect(path).toContain("A40 40 0 0 1"); + }); +}); + +describe("Arc", () => { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- inferred from UNSAFE_getByType + function renderArcPath(children: JSX.Element) { + const { UNSAFE_getByType } = render({children}); + return UNSAFE_getByType(Path); + } + + it("renders a Path component", () => { + const path = renderArcPath(); + expect(path).toBeDefined(); + }); + + it("forwards testID to the Path", () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId("arc")).toBeDefined(); + }); + + it("adjusts radius by strokeWidth/2", () => { + const path = renderArcPath(); + // Adjusted radius should be 40 - 4/2 = 38 + expect(path.props.d).toContain("A38 38"); + }); + + it("adjusts offset by strokeWidth/2", () => { + const path = renderArcPath( + + ); + // Adjusted center: (5 + 4/2, 10 + 4/2) = (7, 12); start point = (7 + 38, 12) = (45, 12) + expect(path.props.d).toMatch(/^M45 12/); + }); + + it("applies strokeCap prop", () => { + const path = renderArcPath( + + ); + expect(path.props.strokeLinecap).toBe("round"); + }); + + it("defaults to butt strokeCap", () => { + const path = renderArcPath(); + expect(path.props.strokeLinecap).toBe("butt"); + }); + + it("applies fill prop", () => { + const path = renderArcPath( + + ); + expect(path.props.fill).toBe("red"); + }); + + it("defaults to no fill", () => { + const path = renderArcPath(); + expect(path.props.fill).toBe("none"); + }); + + it("defaults to clockwise direction", () => { + const path = renderArcPath( + + ); + // Clockwise uses reverse flag 1 + expect(path.props.d).toContain("0 1"); + }); + + it("applies strokeWidth to Path", () => { + const path = renderArcPath(); + expect(path.props.strokeWidth).toBe(5); + }); + + it("passes stroke color through to Path", () => { + const path = renderArcPath(); + expect(path.props.stroke).toBe("blue"); + }); +}); diff --git a/packages/tools/piw-native-utils-internal/src/primitives/__tests__/Bar.spec.tsx b/packages/tools/piw-native-utils-internal/src/primitives/__tests__/Bar.spec.tsx new file mode 100644 index 000000000..0fadf6a58 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/primitives/__tests__/Bar.spec.tsx @@ -0,0 +1,129 @@ +import { render } from "@testing-library/react-native"; +import { StyleSheet, View } from "react-native"; + +import { Bar } from "../Bar"; + +describe("Bar", () => { + it("renders with basic props", () => { + const { getByTestId } = render(); + expect(getByTestId("bar")).toBeDefined(); + }); + + it("applies correct transform for 50% progress", () => { + const { getByTestId } = render( + + ); + const fill = getByTestId("bar-fill"); + // innerWidth = 150 - 2 = 148 + // translateX = 148 / -2 * (1 - 0.5) = -37 + expect(fill.props.style.transform).toEqual([{ translateX: -37 }, { scaleX: 0.5 }]); + }); + + it("applies correct transform for 75% progress", () => { + const { getByTestId } = render( + + ); + const fill = getByTestId("bar-fill"); + // innerWidth = 150 - 2 = 148 + // translateX = 148 / -2 * (1 - 0.75) = -18.5 + expect(fill.props.style.transform).toEqual([{ translateX: -18.5 }, { scaleX: 0.75 }]); + }); + + it("clamps progress to 0.0001 when negative", () => { + const { getByTestId } = render( + + ); + const fill = getByTestId("bar-fill"); + // Progress clamped to 0, scaleX becomes 0.0001 + expect(fill.props.style.transform).toEqual([{ translateX: -74 }, { scaleX: 0.0001 }]); + }); + + it("clamps progress to 1 when greater than 1", () => { + const { getByTestId } = render( + + ); + const fill = getByTestId("bar-fill"); + // translateX collapses to (positive or negative) zero at full progress; -0 === 0 visually. + expect(fill.props.style.transform[0].translateX).toBeCloseTo(0); + expect(fill.props.style.transform[1].scaleX).toBe(1); + }); + + it("renders with border styling", () => { + const { getByTestId } = render( + + ); + const container = getByTestId("bar"); + expect(StyleSheet.flatten(container.props.style)).toMatchObject({ + borderWidth: 2, + borderColor: "red", + borderRadius: 5 + }); + }); + + it("defaults borderColor to color when not specified", () => { + const { getByTestId } = render( + + ); + const container = getByTestId("bar"); + expect(StyleSheet.flatten(container.props.style)).toMatchObject({ + borderColor: "blue" + }); + }); + + it("renders with unfilled background color", () => { + const { getByTestId } = render( + + ); + const container = getByTestId("bar"); + expect(StyleSheet.flatten(container.props.style)).toMatchObject({ + backgroundColor: "lightgray" + }); + }); + + it("applies custom style", () => { + const customStyle = { marginTop: 10, marginBottom: 5 }; + const { getByTestId } = render( + + ); + const container = getByTestId("bar"); + expect(StyleSheet.flatten(container.props.style)).toMatchObject(customStyle); + }); + + it("renders at 0% progress with 0.0001 scaleX", () => { + const { getByTestId } = render(); + const fill = getByTestId("bar-fill"); + expect(fill.props.style.transform[1]).toEqual({ scaleX: 0.0001 }); + }); + + it("renders at 100% progress", () => { + const { getByTestId } = render( + + ); + const fill = getByTestId("bar-fill"); + expect(fill.props.style.transform[0].translateX).toBeCloseTo(0); + expect(fill.props.style.transform[1].scaleX).toBe(1); + }); + + it("handles null width for flex layout", () => { + const { getByTestId } = render(); + const container = getByTestId("bar"); + expect(StyleSheet.flatten(container.props.style)).toMatchObject({ + width: undefined + }); + }); + + it("renders without testID", () => { + const { UNSAFE_queryAllByType } = render(); + const views = UNSAFE_queryAllByType(View); + expect(views).toHaveLength(2); // Container and fill + }); +}); diff --git a/packages/tools/piw-native-utils-internal/src/primitives/index.ts b/packages/tools/piw-native-utils-internal/src/primitives/index.ts new file mode 100644 index 000000000..1ee0a3f37 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/primitives/index.ts @@ -0,0 +1,4 @@ +export { Bar } from "./Bar"; +export type { BarProps } from "./Bar"; +export { Arc, makeArcPath } from "./Arc"; +export type { ArcProps } from "./Arc"; diff --git a/packages/tools/piw-native-utils-internal/tsconfig.json b/packages/tools/piw-native-utils-internal/tsconfig.json index 888eb9a65..f7d2dddd1 100644 --- a/packages/tools/piw-native-utils-internal/tsconfig.json +++ b/packages/tools/piw-native-utils-internal/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@mendix/pluggable-widgets-tools/configs/tsconfig.base", "include": ["./src"], + "exclude": ["./src/**/__tests__", "./src/**/*.spec.ts", "./src/**/*.spec.tsx"], "compilerOptions": { "target": "es2017", "lib": ["es2015", "es2017"], @@ -18,8 +19,7 @@ "noUnusedParameters": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "jsx": "react", - "jsxFactory": "createElement", + "jsx": "react-jsx", "baseUrl": "./", "outDir": "./dist", "declaration": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bb461204..61d248b50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -689,9 +689,6 @@ importers: '@mendix/piw-utils-internal': specifier: 1.0.0 version: link:../../tools/piw-utils-internal - react-native-progress: - specifier: 5.0.1 - version: 5.0.1(react-native-svg@15.15.4(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)) devDependencies: '@mendix/pluggable-widgets-tools': specifier: 11.12.0 @@ -702,9 +699,9 @@ importers: '@mendix/piw-native-utils-internal': specifier: '*' version: link:../../tools/piw-native-utils-internal - react-native-progress: - specifier: 5.0.1 - version: 5.0.1(react-native-svg@15.15.4(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)) + react-native-svg: + specifier: 15.15.4 + version: 15.15.4(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) devDependencies: '@mendix/piw-utils-internal': specifier: '*' @@ -6634,11 +6631,6 @@ packages: react-native-windows: optional: true - react-native-progress@5.0.1: - resolution: {integrity: sha512-TYfJ4auAe5vubDma2yfFvt7ktSI+UCfysqJnkdHEcLXqAitRFOozgF/cLgN5VNi/iLdaf3ga1ETi2RF4jVZ/+g==} - peerDependencies: - react-native-svg: '*' - react-native-qrcode-svg@6.0.6: resolution: {integrity: sha512-b+/teD+xj17VDujJzf956U2+9mX+gKwVJss2aqmhEIyjP7+TVOuE08D3UkzfOCWXE8gppcUTTz5gkY1NXgfwyQ==} peerDependencies: @@ -14868,11 +14860,6 @@ snapshots: react: 19.2.3 react-native: 0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) - react-native-progress@5.0.1(react-native-svg@15.15.4(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)): - dependencies: - prop-types: 15.8.1 - react-native-svg: 15.15.4(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - react-native-qrcode-svg@6.0.6(react-native-svg@15.15.4(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): dependencies: prop-types: 15.8.1