diff --git a/package.json b/package.json index 3165c6eb3..e25e7b7f6 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,6 @@ }, "patchedDependencies": { "react-native-gesture-handler@2.31.2": "patches/react-native-gesture-handler+2.31.2.patch", - "react-native-slider@0.11.0": "patches/react-native-slider+0.11.0.patch", "react-native-snap-carousel@3.9.1": "patches/react-native-snap-carousel+3.9.1.patch", "@mendix/pluggable-widgets-tools": "patches/@mendix__pluggable-widgets-tools.patch" } diff --git a/packages/pluggableWidgets/color-picker-native/CHANGELOG.md b/packages/pluggableWidgets/color-picker-native/CHANGELOG.md index a1e038c38..84f5fb6b2 100644 --- a/packages/pluggableWidgets/color-picker-native/CHANGELOG.md +++ b/packages/pluggableWidgets/color-picker-native/CHANGELOG.md @@ -8,10 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed -- We updated deprecated-react-native-prop-types to 4.2.3. -- We pinned react-native-color to 0.0.10. -- We pinned react-native-slider to 0.11.0. - We updated tinycolor2 to 1.6.0. +- We replaced react-native-slider and react-native-color with built-in components, and removed deprecated-react-native-prop-types. + +### Fixed + +- We fixed the sliders not announcing their value to screen readers. They can now also be adjusted with VoiceOver and TalkBack. +- We fixed the other sliders moving slightly while one slider is dragged, and hue and saturation being lost when the color becomes black or white. ## [2.1.0] - 2024-12-3 diff --git a/packages/pluggableWidgets/color-picker-native/package.json b/packages/pluggableWidgets/color-picker-native/package.json index 712aef765..128f09f13 100644 --- a/packages/pluggableWidgets/color-picker-native/package.json +++ b/packages/pluggableWidgets/color-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "color-picker-native", "widgetName": "ColorPicker", - "version": "2.3.0", + "version": "2.4.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -21,9 +21,6 @@ "dependencies": { "@mendix/piw-native-utils-internal": "*", "@mendix/piw-utils-internal": "*", - "deprecated-react-native-prop-types": "4.2.3", - "react-native-color": "0.0.10", - "react-native-slider": "0.11.0", "tinycolor2": "1.6.0" }, "devDependencies": { diff --git a/packages/pluggableWidgets/color-picker-native/src/ColorPicker.tsx b/packages/pluggableWidgets/color-picker-native/src/ColorPicker.tsx index bf9c7f2ce..e56d2feb9 100644 --- a/packages/pluggableWidgets/color-picker-native/src/ColorPicker.tsx +++ b/packages/pluggableWidgets/color-picker-native/src/ColorPicker.tsx @@ -2,13 +2,12 @@ import { flattenStyles } from "@mendix/piw-native-utils-internal"; import { ValueStatus } from "mendix"; import { Component, ReactElement, ReactNode } from "react"; import { View, ViewStyle } from "react-native"; - -import { HueGradient, LightnessGradient, SaturationGradient } from "react-native-color"; import tinycolor from "tinycolor2"; import { ColorPickerProps } from "../typings/ColorPickerProps"; import { PickerSlider } from "./components/PickerSlider"; import { AlphaGradient } from "./components/AlphaGradient"; import { DisabledHueGradient } from "./components/DisabledHueGradient"; +import { HueGradient, LightnessGradient, SaturationGradient } from "./components/Gradients"; import { ColorPickerStyle, defaultColorPickerStyle } from "./ui/Styles"; import HSLA = tinycolor.ColorFormats.HSLA; import { executeAction } from "@mendix/piw-utils-internal"; @@ -27,6 +26,8 @@ export class ColorPicker extends Component { private readonly onChangeCompleteHandler = this.onChangeComplete.bind(this); private readonly styles = flattenStyles(defaultColorPickerStyle, this.props.style); private readonly defaultSteps = 80; + // Screen reader actions change and complete in one event, before the state update is applied + private pendingColor?: HSLA; readonly state: State = { color: undefined }; @@ -36,8 +37,7 @@ export class ColorPicker extends Component { return null; } - const colorHex = this.state.color ? this.getColor() : this.props.color.value; - const color = tinycolor(colorHex).toHsl(); + const color = this.getCurrentColor(); return ( @@ -51,45 +51,54 @@ export class ColorPicker extends Component { } componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { - if (this.props.color.value !== prevProps.color.value && this.state.color === prevState.color) { + // Keep the unrounded color while it still matches the value, so the other sliders don't jump when it is + // stored with 8-bit precision or hue and saturation are lost at black and white + if ( + this.props.color.value !== prevProps.color.value && + this.state.color === prevState.color && + this.state.color && + this.getColor() !== this.props.color.value + ) { this.setState({ color: undefined }); } } private onChangeHue(value: number): void { - const color = tinycolor(this.props.color.value).toHsl(); - color.h = value; - this.setState({ color }); + this.setColor({ ...this.getCurrentColor(), h: value }); } private onChangeSaturation(value: number): void { - const color = tinycolor(this.props.color.value).toHsl(); - color.s = value; - this.setState({ color }); + this.setColor({ ...this.getCurrentColor(), s: value }); } private onChangeLightness(value: number): void { - const color = tinycolor(this.props.color.value).toHsl(); - color.l = value; - this.setState({ color }); + this.setColor({ ...this.getCurrentColor(), l: value }); } private onChangeAlpha(value: number): void { - const color = tinycolor(this.props.color.value).toHsl(); - color.a = value; + this.setColor({ ...this.getCurrentColor(), a: value }); + } + + private getCurrentColor(): HSLA { + return this.pendingColor ?? this.state.color ?? tinycolor(this.props.color.value).toHsl(); + } + + private setColor(color: HSLA): void { + this.pendingColor = color; this.setState({ color }); } private onChangeComplete(): void { - if (this.state.color && this.props.color.value !== this.getColor()) { - this.props.color.setValue(this.getColor()); + if (this.pendingColor && this.props.color.value !== this.getColor(this.pendingColor)) { + this.props.color.setValue(this.getColor(this.pendingColor)); } + this.pendingColor = undefined; executeAction(this.props.onChange); } - private getColor(): string { - const color = tinycolor(this.state.color); + private getColor(hsla = this.state.color): string { + const color = tinycolor(hsla); switch (this.props.format) { case "hex": return color.toHexString(); diff --git a/packages/pluggableWidgets/color-picker-native/src/__tests__/ColorPicker.spec.tsx b/packages/pluggableWidgets/color-picker-native/src/__tests__/ColorPicker.spec.tsx index a3b9197d2..db85160af 100644 --- a/packages/pluggableWidgets/color-picker-native/src/__tests__/ColorPicker.spec.tsx +++ b/packages/pluggableWidgets/color-picker-native/src/__tests__/ColorPicker.spec.tsx @@ -1,6 +1,5 @@ import { actionValue, EditableValueBuilder } from "@mendix/piw-utils-internal"; import { View } from "react-native"; -import Slider from "react-native-slider"; import { fireEvent, render, RenderAPI } from "@testing-library/react-native"; import { ReactTestInstance } from "react-test-renderer"; import { ColorPicker, Props } from "../ColorPicker"; @@ -59,124 +58,193 @@ describe("Color Picker", () => { it("changes the value when swiping hue slider", () => { const onChangeAction = actionValue(); - const component = render( - - ); - - const hueHandler = getHueHandle(component); + const component = render(); - fireEvent(hueHandler, "responderGrant", { touchHistory: { touchBank: [] } }); - fireEvent(hueHandler, "responderMove", responderMove(0)); + const hue = getTouchArea(component, "hue"); + fireEvent(hue, "responderGrant", touch(0)); + fireEvent(hue, "responderMove", move(140)); expect(onChangeAction.execute).not.toHaveBeenCalled(); - fireEvent(hueHandler, "responderRelease", {}); + fireEvent(hue, "responderRelease", {}); expect(onChangeAction.execute).toHaveBeenCalledTimes(1); - - expect(defaultProps.color.setValue).toHaveBeenCalledWith("#000000"); + expect(defaultProps.color.setValue).toHaveBeenCalledWith("#00ffff"); }); it("changes the value when swiping saturation slider", () => { const onChangeAction = actionValue(); const component = render(); - const saturationHandler = getSaturationHandle(component); - - fireEvent(saturationHandler, "responderGrant", { touchHistory: { touchBank: [] } }); - fireEvent(saturationHandler, "responderMove", responderMove(0)); - - expect(onChangeAction.execute).not.toHaveBeenCalled(); - - fireEvent(saturationHandler, "responderRelease", {}); + const saturation = getTouchArea(component, "saturation"); + fireEvent(saturation, "responderGrant", touch(0)); + fireEvent(saturation, "responderMove", move(-280)); + fireEvent(saturation, "responderRelease", {}); expect(onChangeAction.execute).toHaveBeenCalledTimes(1); - expect(defaultProps.color.setValue).toHaveBeenCalledWith("#000000"); + expect(defaultProps.color.setValue).toHaveBeenCalledWith("#808080"); }); it("changes the value when swiping lightness slider with no lightness", () => { - const onChangeAction = actionValue(); - const component = render(); + const component = render(); - const lightnessHandler = getLightnessHandle(component); + const lightness = getTouchArea(component, "lightness"); + fireEvent(lightness, "responderGrant", touch(0)); + fireEvent(lightness, "responderMove", move(-140)); + fireEvent(lightness, "responderRelease", {}); - fireEvent(lightnessHandler, "responderGrant", { touchHistory: { touchBank: [] } }); - fireEvent(lightnessHandler, "responderMove", responderMove(0)); + expect(defaultProps.color.setValue).toHaveBeenCalledWith("#000000"); + }); - expect(onChangeAction.execute).not.toHaveBeenCalled(); + it("changes the value when swiping lightness slider with full lightness", () => { + const component = render(); - fireEvent(lightnessHandler, "responderRelease", {}); + const lightness = getTouchArea(component, "lightness"); + fireEvent(lightness, "responderGrant", touch(0)); + fireEvent(lightness, "responderMove", move(140)); + fireEvent(lightness, "responderRelease", {}); - expect(onChangeAction.execute).toHaveBeenCalledTimes(1); - expect(defaultProps.color.setValue).toHaveBeenCalledWith("#000000"); + expect(defaultProps.color.setValue).toHaveBeenCalledWith("#ffffff"); }); - it("changes the value when swiping lightness slider with full lightness", () => { - const onChangeAction = actionValue(); - const component = render(); + it("keeps the saturation when swiping lightness slider", () => { + const color = new EditableValueBuilder().withValue("#d66329").build(); + const component = render(); + const saturation = (): object => component.getByTestId("color-picker-test$saturation").props.accessibilityValue; + expect(saturation()).toEqual({ min: 0, max: 100, now: 68 }); + + // Lightness 0.03 is stored as #0d0602, which reads back as saturation 0.73 + const lightness = getTouchArea(component, "lightness"); + fireEvent(lightness, "responderGrant", touch(0)); + fireEvent(lightness, "responderMove", move(-131)); + expect(saturation()).toEqual({ min: 0, max: 100, now: 68 }); + + fireEvent(lightness, "responderRelease", {}); + expect(color.setValue).toHaveBeenCalledWith("#0d0602"); + component.rerender( + ().withValue("#0d0602").build()} /> + ); + expect(saturation()).toEqual({ min: 0, max: 100, now: 68 }); + }); - const lightnessHandler = getLightnessHandle(component); + it("keeps hue and saturation when swiping lightness slider through black", () => { + const component = render(); - fireEvent(lightnessHandler, "responderGrant", { touchHistory: { touchBank: [] } }); - fireEvent(lightnessHandler, "responderMove", responderMove(1)); + const lightness = getTouchArea(component, "lightness"); + fireEvent(lightness, "responderGrant", touch(0)); + fireEvent(lightness, "responderMove", move(-140)); + fireEvent(lightness, "responderRelease", {}); + expect(defaultProps.color.setValue).toHaveBeenLastCalledWith("#000000"); + + const color = new EditableValueBuilder().withValue("#000000").build(); + component.rerender(); + fireEvent(lightness, "responderGrant", touch(0)); + fireEvent(lightness, "responderMove", move(140)); + fireEvent(lightness, "responderRelease", {}); + expect(color.setValue).toHaveBeenCalledWith("#ff0000"); + }); - expect(onChangeAction.execute).not.toHaveBeenCalled(); + it("follows value changes from outside", () => { + const component = render(); - fireEvent(lightnessHandler, "responderRelease", {}); + const lightness = getTouchArea(component, "lightness"); + fireEvent(lightness, "responderGrant", touch(0)); + fireEvent(lightness, "responderMove", move(-140)); + fireEvent(lightness, "responderRelease", {}); - expect(onChangeAction.execute).toHaveBeenCalledTimes(1); - expect(defaultProps.color.setValue).toHaveBeenCalledWith("#ffffff"); + component.rerender( + ().withValue("#00ffff").build()} /> + ); + expect(component.getByTestId("color-picker-test$hue").props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 50 + }); + expect(component.getByTestId("color-picker-test$lightness").props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 50 + }); }); it("changes the value when swiping alpha slider", () => { const onChangeAction = actionValue(); const component = render(); - const alphaHandler = getAlphaHandle(component); + const alpha = getTouchArea(component, "alpha"); + fireEvent(alpha, "responderGrant", touch(0)); + fireEvent(alpha, "responderMove", move(-280)); + fireEvent(alpha, "responderRelease", {}); - fireEvent(alphaHandler, "responderGrant", { touchHistory: { touchBank: [] } }); - fireEvent(alphaHandler, "responderMove", responderMove(-1)); + expect(onChangeAction.execute).toHaveBeenCalledTimes(1); + expect(defaultProps.color.setValue).toHaveBeenCalledWith("rgba(255, 0, 0, 0)"); + }); - expect(onChangeAction.execute).not.toHaveBeenCalled(); + it("moves to the tapped position on the track", () => { + const onChangeAction = actionValue(); + const component = render(); - fireEvent(alphaHandler, "responderRelease", {}); + // Thumb centre at half of the track: 10 + 140 in the slider, plus half of the 28px touch overflow + const hue = getTouchArea(component, "hue"); + expect(hue.props.onStartShouldSetResponder(touch(14 + 150))).toBe(true); + fireEvent(hue, "responderGrant", touch(14 + 150)); + fireEvent(hue, "responderRelease", {}); expect(onChangeAction.execute).toHaveBeenCalledTimes(1); - expect(defaultProps.color.setValue).toHaveBeenCalledWith("rgba(255, 0, 0, 0)"); + expect(defaultProps.color.setValue).toHaveBeenCalledWith("#00ffff"); }); - function getHueHandle(component: RenderAPI): ReactTestInstance { - return getSliders(component)[0] - .findAllByType(View) - .filter(instance => instance.props.onMoveShouldSetResponder)[0]; - } + it("does not respond to touches when read-only", () => { + const component = render( + ().withValue("#ff0000").isReadOnly().build()} + /> + ); - function getSaturationHandle(component: RenderAPI): ReactTestInstance { - return getSliders(component)[1] - .findAllByType(View) - .filter(instance => instance.props.onMoveShouldSetResponder)[0]; - } + const hue = getTouchArea(component, "hue"); + expect(hue.props.onStartShouldSetResponder(touch(14 + 150))).toBe(false); + expect(component.getByTestId("color-picker-test$hue").props.accessibilityState).toEqual({ disabled: true }); + }); - function getLightnessHandle(component: RenderAPI): ReactTestInstance { - return getSliders(component)[2] - .findAllByType(View) - .filter(instance => instance.props.onMoveShouldSetResponder)[0]; - } + it("announces the slider value to screen readers", () => { + const component = render( + ().withValue("#00ffff").build()} /> + ); - function getAlphaHandle(component: RenderAPI): ReactTestInstance { - return getSliders(component)[3] - .findAllByType(View) - .filter(instance => instance.props.onMoveShouldSetResponder)[0]; - } + const hue = component.getByTestId("color-picker-test$hue"); + expect(hue.props.accessibilityRole).toBe("adjustable"); + expect(hue.props.accessibilityValue).toEqual({ min: 0, max: 100, now: 50 }); + expect(component.getByTestId("color-picker-test$lightness").props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 50 + }); + }); + + it("changes the value with screen reader actions", () => { + const onChangeAction = actionValue(); + const component = render(); + + fireEvent(component.getByTestId("color-picker-test$lightness"), "accessibilityAction", { + nativeEvent: { actionName: "increment" } + }); + + expect(onChangeAction.execute).toHaveBeenCalledTimes(1); + expect(defaultProps.color.setValue).toHaveBeenCalledWith("#ff3333"); + }); - function getSliders(component: RenderAPI): ReactTestInstance[] { - return component.UNSAFE_getAllByType(Slider); + // Lays out the slider 300 wide with a 20 wide thumb, leaving a 280px track + function getTouchArea(component: RenderAPI, name: string): ReactTestInstance { + const slider = component.getByTestId(`color-picker-test$${name}`); + const [container, thumb] = slider.findAll( + node => typeof node.type === "string" && typeof node.props.onLayout === "function" + ); + fireEvent(container, "layout", { nativeEvent: { layout: { width: 300, height: 40 } } }); + fireEvent(thumb, "layout", { nativeEvent: { layout: { width: 20, height: 20 } } }); + return slider.findAll( + node => typeof node.type === "string" && typeof node.props.onResponderGrant === "function" + )[0]; } it("preserves expected style key set", () => { @@ -185,7 +253,11 @@ describe("Color Picker", () => { }); }); -function responderMove(dx: number): any { +function touch(locationX: number): object { + return { nativeEvent: { locationX, locationY: 20 }, touchHistory: { touchBank: [] } }; +} + +function move(dx: number): object { return { touchHistory: { numberActiveTouches: 1, diff --git a/packages/pluggableWidgets/color-picker-native/src/__tests__/__snapshots__/ColorPicker.spec.tsx.snap b/packages/pluggableWidgets/color-picker-native/src/__tests__/__snapshots__/ColorPicker.spec.tsx.snap index 9280198eb..73a664d0f 100644 --- a/packages/pluggableWidgets/color-picker-native/src/__tests__/__snapshots__/ColorPicker.spec.tsx.snap +++ b/packages/pluggableWidgets/color-picker-native/src/__tests__/__snapshots__/ColorPicker.spec.tsx.snap @@ -20,35 +20,14 @@ exports[`Color Picker renders 1`] = ` testID="color-picker-test$preview" /> @@ -909,42 +901,21 @@ exports[`Color Picker renders 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> @@ -1805,42 +1789,21 @@ exports[`Color Picker renders 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> @@ -2701,7 +2677,7 @@ exports[`Color Picker renders 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> @@ -2730,35 +2706,14 @@ exports[`Color Picker renders with alpha slider 1`] = ` testID="color-picker-test$preview" /> @@ -3619,42 +3587,21 @@ exports[`Color Picker renders with alpha slider 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> @@ -4515,42 +4475,21 @@ exports[`Color Picker renders with alpha slider 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> @@ -5411,42 +5363,21 @@ exports[`Color Picker renders with alpha slider 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> @@ -6307,7 +6251,7 @@ exports[`Color Picker renders with alpha slider 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> @@ -6337,35 +6281,14 @@ exports[`Color Picker renders with custom style 1`] = ` testID="color-picker-test$preview" /> @@ -7226,42 +7162,21 @@ exports[`Color Picker renders with custom style 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> @@ -8122,42 +8050,21 @@ exports[`Color Picker renders with custom style 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> @@ -9018,7 +8938,7 @@ exports[`Color Picker renders with custom style 1`] = ` "right": 0, "top": 0, }, - {}, + false, ] } /> diff --git a/packages/pluggableWidgets/color-picker-native/src/components/AlphaGradient.tsx b/packages/pluggableWidgets/color-picker-native/src/components/AlphaGradient.tsx index 79871886b..6fb174f55 100644 --- a/packages/pluggableWidgets/color-picker-native/src/components/AlphaGradient.tsx +++ b/packages/pluggableWidgets/color-picker-native/src/components/AlphaGradient.tsx @@ -1,6 +1,6 @@ import { Component, JSX } from "react"; import { ViewStyle } from "react-native"; -import { Gradient } from "react-native-color"; +import { Gradient } from "./Gradients"; import tinycolor from "tinycolor2"; import HSLA = tinycolor.ColorFormats.HSLA; diff --git a/packages/pluggableWidgets/color-picker-native/src/components/DisabledHueGradient.tsx b/packages/pluggableWidgets/color-picker-native/src/components/DisabledHueGradient.tsx index e1caa14d6..633cf9f78 100644 --- a/packages/pluggableWidgets/color-picker-native/src/components/DisabledHueGradient.tsx +++ b/packages/pluggableWidgets/color-picker-native/src/components/DisabledHueGradient.tsx @@ -1,5 +1,5 @@ import { ReactElement } from "react"; -import { Gradient } from "react-native-color"; +import { Gradient } from "./Gradients"; import tinycolor from "tinycolor2"; import HSLA = tinycolor.ColorFormats.HSLA; diff --git a/packages/pluggableWidgets/color-picker-native/src/components/Gradients.tsx b/packages/pluggableWidgets/color-picker-native/src/components/Gradients.tsx new file mode 100644 index 000000000..32eac95c2 --- /dev/null +++ b/packages/pluggableWidgets/color-picker-native/src/components/Gradients.tsx @@ -0,0 +1,82 @@ +/** + * Gradients ported from react-native-color 0.0.10 (https://github.com/hector/react-native-color) + * Copyright (c) 2017 Hector Garcia, MIT License + */ +import { memo, ReactElement } from "react"; +import { Platform, StyleSheet, View, ViewStyle } from "react-native"; +import tinycolor from "tinycolor2"; +import HSLA = tinycolor.ColorFormats.HSLA; + +interface GradientProps { + style?: ViewStyle; + gradientSteps: number; + maximumValue: number; + getStepColor: (i: number) => string; +} + +interface ColorGradientProps { + style?: ViewStyle; + gradientSteps: number; + color: HSLA; +} + +export const Gradient = ({ style, gradientSteps, maximumValue, getStepColor }: GradientProps): ReactElement => { + const rows = []; + for (let i = 0; i <= gradientSteps; i++) { + rows.push( + + ); + } + return {rows}; +}; + +export const HueGradient = memo( + ({ style, gradientSteps }: Omit): ReactElement => ( + tinycolor({ s: 1, l: 0.5, h: i }).toHslString()} + maximumValue={359} + /> + ) +); + +export const SaturationGradient = memo( + ({ style, color, gradientSteps }: ColorGradientProps): ReactElement => ( + tinycolor({ ...color, s: i }).toHslString()} + maximumValue={1} + /> + ), + (previous, next) => previous.color.h === next.color.h && previous.color.l === next.color.l +); + +export const LightnessGradient = memo( + ({ style, color, gradientSteps }: ColorGradientProps): ReactElement => ( + tinycolor({ ...color, l: i }).toHslString()} + maximumValue={1} + /> + ), + (previous, next) => previous.color.h === next.color.h && previous.color.s === next.color.s +); + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: "row", + justifyContent: "center", + alignItems: "stretch" + } +}); diff --git a/packages/pluggableWidgets/color-picker-native/src/components/PickerSlider.tsx b/packages/pluggableWidgets/color-picker-native/src/components/PickerSlider.tsx index 0ebd2bdb8..bb91f9b6f 100644 --- a/packages/pluggableWidgets/color-picker-native/src/components/PickerSlider.tsx +++ b/packages/pluggableWidgets/color-picker-native/src/components/PickerSlider.tsx @@ -1,6 +1,6 @@ -import { Component, createRef, PropsWithChildren, JSX } from "react"; -import { GestureResponderEvent, Platform, StyleSheet, TouchableWithoutFeedback, View, ViewStyle } from "react-native"; -import Slider from "react-native-slider"; +import { PropsWithChildren, ReactElement } from "react"; +import { Platform, StyleSheet, View, ViewStyle } from "react-native"; +import { Slider } from "@mendix/piw-native-utils-internal"; interface PickerSlidersProps { value: number; @@ -16,79 +16,28 @@ interface PickerSlidersProps { testID?: string; } -export class PickerSlider extends Component> { - private readonly onTapHandler = this.onTap.bind(this); - private readonly onChangeHandler = this.onChange.bind(this); - private readonly onSlidingCompleteHandler = this.onSlidingComplete.bind(this); - private readonly viewRef = createRef(); - private isSliding = false; - - render(): JSX.Element { - return ( - - - {this.props.children} - - - - ); - } - - private onChange(value: number): void { - this.isSliding = true; - this.props.onValueChange(value); - } - - private onSlidingComplete(): void { - this.isSliding = false; - this.props.onValueChangeComplete(); - } - - private onTap(event: GestureResponderEvent): void { - if (!this.viewRef.current || this.props.disabled) { - return; - } - - const { step, maximumValue, minimumValue } = this.props; - - this.viewRef.current.measure((_x, _y, width) => { - if (this.isSliding) { - return; - } - const positionFraction = event.nativeEvent.locationX / width; - const value = (maximumValue || 1) * positionFraction; - const roundedValue = this.roundToMultiple(value, step); - if (roundedValue >= (minimumValue || 0) && roundedValue <= (maximumValue || 1)) { - this.props.onValueChange(roundedValue); - this.props.onValueChangeComplete(); - } - }); - } - - private readonly roundToMultiple = (value: number, multiple: number): number => - Math.round(value / multiple) * multiple; +export function PickerSlider(props: PropsWithChildren): ReactElement { + return ( + + {props.children} + + + ); } const styles = StyleSheet.create({ diff --git a/packages/pluggableWidgets/color-picker-native/src/package.xml b/packages/pluggableWidgets/color-picker-native/src/package.xml index 485152a58..a15fe6417 100644 --- a/packages/pluggableWidgets/color-picker-native/src/package.xml +++ b/packages/pluggableWidgets/color-picker-native/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/color-picker-native/typings/ReactNativeColor.d.ts b/packages/pluggableWidgets/color-picker-native/typings/ReactNativeColor.d.ts deleted file mode 100644 index 5a761964e..000000000 --- a/packages/pluggableWidgets/color-picker-native/typings/ReactNativeColor.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -declare module "react-native-color" { - import HSL = tinycolor.ColorFormats.HSL; - import { ViewStyle } from "react-native"; - - export interface ColorPickerProps { - gradientSteps: number; - maximumValue?: number; - color?: HSL; - getStepColor?: (i: number) => string; - style?: ViewStyle; - } - - export const Gradient: (props: ColorPickerProps) => JSX.Element; - export const HueGradient: (props: ColorPickerProps) => JSX.Element; - export const LightnessGradient: (props: ColorPickerProps) => JSX.Element; - export const SaturationGradient: (props: ColorPickerProps) => JSX.Element; -} diff --git a/packages/pluggableWidgets/color-picker-native/typings/ReactNativeSlider.d.ts b/packages/pluggableWidgets/color-picker-native/typings/ReactNativeSlider.d.ts deleted file mode 100644 index bb7919baa..000000000 --- a/packages/pluggableWidgets/color-picker-native/typings/ReactNativeSlider.d.ts +++ /dev/null @@ -1,76 +0,0 @@ -declare module "react-native-slider" { - import { ViewStyle } from "react-native"; - - export interface SliderProps { - /** - * Value of the slider. Default 0 - */ - value?: number; - /** - * Disables the slider. Default false - */ - disabled?: boolean; - /** - * Ste value of the slider. Default 0 - */ - step?: number; - /** - * Animates the transitions. Default false - */ - animateTransitions?: boolean; - /** - * Animation type, default: spring - */ - animationType?: "spring" | "timing"; - /** - * Size of touchable area. Default 40x40 - */ - thumbTouchSize?: { - width: number; - height: number; - }; - /** - * Initial minimum value. Default 0 - */ - minimumValue?: number; - /** - * Initial maximum value. Default 1 - */ - maximumValue?: number; - /** - * Callback continuously called while the user is dragging the slider - * @param value - */ - onValueChange?: (value: number) => void; - /** - * Callback called when the user starts changing the value - */ - onSlidingStart?: () => void; - /** - * Callback called when the user finishes changing the value - */ - onSlidingComplete?: () => void; - /** - * The color used for the track to the left of the button - */ - minimumTrackTintColor?: string; - /** - * The color used for the track to the right of the button - */ - maximumTrackTintColor?: string; - /** - * Style of the container - */ - style?: ViewStyle | ViewStyle[]; - /** - * Style of track - */ - trackStyle?: ViewStyle | ViewStyle[]; - /** - * Style of handlers - */ - thumbStyle?: ViewStyle | ViewStyle[]; - } - const Slider: (props: SliderProps) => JSX.Element; - export default Slider; -} diff --git a/packages/pluggableWidgets/range-slider-native/CHANGELOG.md b/packages/pluggableWidgets/range-slider-native/CHANGELOG.md index bb4f25c6c..7a669aca8 100644 --- a/packages/pluggableWidgets/range-slider-native/CHANGELOG.md +++ b/packages/pluggableWidgets/range-slider-native/CHANGELOG.md @@ -8,7 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed -- We pinned @miblanchard/react-native-slider to 2.6.0. +- We replaced @miblanchard/react-native-slider with a built-in slider component. + +### Fixed + +- We fixed the range slider thumbs not announcing their values or being adjustable with VoiceOver and TalkBack. ## [2.3.0] - 2024-12-3 diff --git a/packages/pluggableWidgets/range-slider-native/package.json b/packages/pluggableWidgets/range-slider-native/package.json index 9b413ec6e..4d74852a6 100644 --- a/packages/pluggableWidgets/range-slider-native/package.json +++ b/packages/pluggableWidgets/range-slider-native/package.json @@ -1,7 +1,7 @@ { "name": "range-slider-native", "widgetName": "RangeSlider", - "version": "3.0.1", + "version": "3.1.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -20,8 +20,7 @@ }, "dependencies": { "@mendix/piw-native-utils-internal": "*", - "@mendix/piw-utils-internal": "*", - "@miblanchard/react-native-slider": "2.6.0" + "@mendix/piw-utils-internal": "*" }, "devDependencies": { "@mendix/pluggable-widgets-tools": "*" diff --git a/packages/pluggableWidgets/range-slider-native/src/RangeSlider.tsx b/packages/pluggableWidgets/range-slider-native/src/RangeSlider.tsx index cbacf55ca..e5d6c2266 100644 --- a/packages/pluggableWidgets/range-slider-native/src/RangeSlider.tsx +++ b/packages/pluggableWidgets/range-slider-native/src/RangeSlider.tsx @@ -1,5 +1,11 @@ -import { available, flattenStyles, toNumber, unavailable } from "@mendix/piw-native-utils-internal"; -import { Slider } from "@miblanchard/react-native-slider"; +import { + available, + flattenStyles, + RangeSlider as NativeRangeSlider, + RangeSliderValue, + toNumber, + unavailable +} from "@mendix/piw-native-utils-internal"; import { ReactElement, useCallback, useRef } from "react"; import { Text, View } from "react-native"; import { Big } from "big.js"; @@ -26,10 +32,7 @@ export function RangeSlider(props: Props): ReactElement { const isEnabled = enabledLower || enabledUpper; const onValueChange = useCallback( - (values: number[]): void => { - if (values[0] == null || values[1] == null) { - return; - } + (values: RangeSliderValue): void => { props.lowerValueAttribute.setValue(new Big(values[0])); props.upperValueAttribute.setValue(new Big(values[1])); }, @@ -37,12 +40,8 @@ export function RangeSlider(props: Props): ReactElement { ); const onSlidingComplete = useCallback( - (values: number[]): void => { - if ( - values[0] == null || - values[1] == null || - (lastLowerValue.current === values[0] && lastUpperValue.current === values[1]) - ) { + (values: RangeSliderValue): void => { + if (lastLowerValue.current === values[0] && lastUpperValue.current === values[1]) { return; } @@ -58,12 +57,14 @@ export function RangeSlider(props: Props): ReactElement { return ( - { - const { View } = require("react-native"); - return { - Slider: (props: any) => ( - props.onValueChange?.(values)} - onSlidingComplete={(values: number[]) => props.onSlidingComplete?.(values)} - /> - ) - }; -}); - describe("RangeSlider", () => { const noValue: DynamicValue = { status: ValueStatus.Unavailable, value: undefined }; let defaultProps: Props; @@ -123,64 +110,98 @@ describe("RangeSlider", () => { expect(component.getAllByText("Invalid")).toHaveLength(2); }); + // Values 70 and 210 of 0-280 put the thumbs at left 67.5 and 202.5, so their centres in the touch area are at + // 9 + 67.5 + 15 and 9 + 202.5 + 15 + const lowerCentre = 91.5; + const upperCentre = 226.5; + const sliderId = "range-slider-test$slider"; + it("renders as disabled when editable is never", () => { - const component = render(); - const slider = component.getByTestId("mocked-slider"); - expect(slider.props.disabled).toBe(true); + const component = renderMeasured(, sliderId); + + expect(component.getByTestId(`${sliderId}$thumb0`).props.accessibilityState).toEqual({ disabled: true }); + expect(component.getByTestId(`${sliderId}$thumb1`).props.accessibilityState).toEqual({ disabled: true }); + expect(drag(component, sliderId, lowerCentre, 27)).toBe(false); }); it("renders as enabled when editable is default", () => { - const component = render(); - const slider = component.getByTestId("mocked-slider"); - expect(slider.props.disabled).toBe(false); + const component = renderMeasured(, sliderId); + + expect(component.getByTestId(`${sliderId}$thumb0`).props.accessibilityState).toEqual({ disabled: false }); }); - it("passes correct min/max/step to the slider", () => { - const component = render(); - const slider = component.getByTestId("mocked-slider"); - expect(slider.props.minimumValue).toBe(0); - expect(slider.props.maximumValue).toBe(280); - expect(slider.props.step).toBe(1); + it("announces each value as adjustable", () => { + const component = renderMeasured(, sliderId); + const lower = component.getByTestId(`${sliderId}$thumb0`); + const upper = component.getByTestId(`${sliderId}$thumb1`); + + expect(lower.props.accessibilityRole).toBe("adjustable"); + expect(lower.props.accessibilityValue).toEqual({ min: 0, max: 100, now: 25 }); + expect(upper.props.accessibilityRole).toBe("adjustable"); + expect(upper.props.accessibilityValue).toEqual({ min: 0, max: 100, now: 75 }); }); - it("passes range values as array", () => { - const component = render(); - const slider = component.getByTestId("mocked-slider"); - expect(slider.props.value).toEqual([70, 210]); + it("adjusts a value with a screen reader", () => { + const onChangeAction = actionValue(); + const component = renderMeasured(, sliderId); + + fireEvent(component.getByTestId(`${sliderId}$thumb1`), "accessibilityAction", { + nativeEvent: { actionName: "decrement" } + }); + + expect(defaultProps.lowerValueAttribute.setValue).toHaveBeenLastCalledWith(new Big(70)); + expect(defaultProps.upperValueAttribute.setValue).toHaveBeenLastCalledWith(new Big(182)); + expect(onChangeAction.execute).toHaveBeenCalledTimes(1); }); it("calls onValueChange when sliding", () => { - const component = render(); - const slider = component.getByTestId("mocked-slider"); + const component = renderMeasured(, sliderId); - fireEvent(slider, "onValueChange", [100, 210]); + drag(component, sliderId, lowerCentre, 27, false); - expect(defaultProps.lowerValueAttribute.setValue).toHaveBeenCalledWith(new Big(100)); + expect(defaultProps.lowerValueAttribute.setValue).toHaveBeenCalledWith(new Big(98)); expect(defaultProps.upperValueAttribute.setValue).toHaveBeenCalledWith(new Big(210)); }); it("calls onChange action on sliding complete", () => { const onChangeAction = actionValue(); - const component = render(); - const slider = component.getByTestId("mocked-slider"); + const component = renderMeasured(, sliderId); - fireEvent(slider, "onSlidingComplete", [100, 250]); + drag(component, sliderId, upperCentre, 27); - expect(defaultProps.lowerValueAttribute.setValue).toHaveBeenCalledWith(new Big(100)); - expect(defaultProps.upperValueAttribute.setValue).toHaveBeenCalledWith(new Big(250)); + expect(defaultProps.lowerValueAttribute.setValue).toHaveBeenLastCalledWith(new Big(70)); + expect(defaultProps.upperValueAttribute.setValue).toHaveBeenLastCalledWith(new Big(238)); expect(onChangeAction.execute).toHaveBeenCalledTimes(1); }); it("does not call onChange when values haven't changed", () => { const onChangeAction = actionValue(); - const component = render(); - const slider = component.getByTestId("mocked-slider"); + const component = renderMeasured(, sliderId); - fireEvent(slider, "onSlidingComplete", [70, 210]); + drag(component, sliderId, lowerCentre, 0); expect(onChangeAction.execute).not.toHaveBeenCalled(); }); + it("does not move a thumb past the other", () => { + const component = renderMeasured(, sliderId); + + drag(component, sliderId, lowerCentre, 1000); + + expect(defaultProps.lowerValueAttribute.setValue).toHaveBeenLastCalledWith(new Big(210)); + expect(defaultProps.upperValueAttribute.setValue).toHaveBeenLastCalledWith(new Big(210)); + }); + + it("moves the closest thumb to a pressed position on the track", () => { + const component = renderMeasured(, sliderId); + + // Value 250 is at left 241.07 + drag(component, sliderId, 9 + 241.07 + 15, 0); + + expect(defaultProps.lowerValueAttribute.setValue).toHaveBeenLastCalledWith(new Big(70)); + expect(defaultProps.upperValueAttribute.setValue).toHaveBeenLastCalledWith(new Big(250)); + }); + it("renders with the width of the parent view", () => { const component = render( { expect(container.props.style).toEqual(expect.objectContaining({ width: 100 })); }); - it("changes the lower value when swiping", () => { - const onChangeAction = actionValue(); - const component = render(); - const slider = component.getByTestId("mocked-slider"); - - fireEvent(slider, "onValueChange", [120, 210]); - - expect(onChangeAction.execute).not.toHaveBeenCalled(); - - fireEvent(slider, "onSlidingComplete", [120, 210]); - - expect(defaultProps.lowerValueAttribute.setValue).toHaveBeenCalledWith(new Big(120)); - expect(defaultProps.upperValueAttribute.setValue).toHaveBeenCalledWith(new Big(210)); - expect(onChangeAction.execute).toHaveBeenCalledTimes(1); - }); - it("applies custom styles", () => { const component = render( { expect(component.toJSON()).toMatchSnapshot("with custom styles"); }); }); + +// A 300 wide container and the 30 wide default marker leave a 270px track; the 48px touch area adds 9px on each side +function renderMeasured(element: ReactElement, testID: string): RenderAPI { + const component = render(element); + const [container, thumb] = component + .getByTestId(testID) + .findAll(node => typeof node.type === "string" && typeof node.props.onLayout === "function"); + fireEvent(container, "layout", { nativeEvent: { layout: { width: 300, height: 40 } } }); + fireEvent(thumb, "layout", { nativeEvent: { layout: { width: 30, height: 30 } } }); + return component; +} + +function drag(component: RenderAPI, testID: string, locationX: number, dx: number, release = true): boolean { + const touchArea = component + .getByTestId(testID) + .findAll(node => typeof node.type === "string" && typeof node.props.onResponderGrant === "function")[0]; + const touch = { nativeEvent: { locationX, locationY: 20 }, touchHistory: { touchBank: [] } }; + if (!touchArea.props.onStartShouldSetResponder(touch)) { + return false; + } + fireEvent(touchArea, "responderGrant", touch); + const gesture = { + touchHistory: { + numberActiveTouches: 1, + indexOfSingleActiveTouch: 0, + touchBank: [ + { + touchActive: true, + currentTimeStamp: Date.now(), + currentPageX: dx, + currentPageY: 0, + previousPageX: 0, + previousPageY: 0, + startPageX: 0, + startPageY: 0 + } + ] + } + }; + fireEvent(touchArea, "responderMove", gesture); + if (release) { + fireEvent(touchArea, "responderRelease", gesture); + } + return true; +} diff --git a/packages/pluggableWidgets/range-slider-native/src/__tests__/__snapshots__/RangeSlider.spec.tsx.snap b/packages/pluggableWidgets/range-slider-native/src/__tests__/__snapshots__/RangeSlider.spec.tsx.snap index 78fb13181..130e4630c 100644 --- a/packages/pluggableWidgets/range-slider-native/src/__tests__/__snapshots__/RangeSlider.spec.tsx.snap +++ b/packages/pluggableWidgets/range-slider-native/src/__tests__/__snapshots__/RangeSlider.spec.tsx.snap @@ -10,55 +10,201 @@ exports[`RangeSlider applies custom styles: with custom styles 1`] = ` testID="range-slider-test" > + + + + accessibilityRole="adjustable" + accessibilityState={ + { + "disabled": false, + } + } + accessibilityValue={ + { + "max": 100, + "min": 0, + "now": 25, + } + } + accessible={true} + collapsable={false} + onAccessibilityAction={[Function]} + onLayout={[Function]} + renderToHardwareTextureAndroid={true} + style={ + { + "backgroundColor": "#FFFFFF", + "borderColor": "#DDDDDD", + "borderRadius": 30, + "borderWidth": 1, + "elevation": 3, + "height": 30, + "opacity": 0, + "position": "absolute", + "shadowColor": "#000000", + "shadowOffset": { + "height": 3, + "width": 0, + }, + "shadowOpacity": 0.2, + "shadowRadius": 1, + "transform": [ + { + "translateX": 0, + }, + { + "translateY": 0, + }, + ], + "width": 30, + } + } + testID="range-slider-test$slider$thumb0" + /> + + + `; @@ -68,54 +214,200 @@ exports[`RangeSlider renders 1`] = ` testID="range-slider-test" > + + + + accessibilityRole="adjustable" + accessibilityState={ + { + "disabled": false, + } + } + accessibilityValue={ + { + "max": 100, + "min": 0, + "now": 25, + } + } + accessible={true} + collapsable={false} + onAccessibilityAction={[Function]} + onLayout={[Function]} + renderToHardwareTextureAndroid={true} + style={ + { + "backgroundColor": "#FFFFFF", + "borderColor": "#DDDDDD", + "borderRadius": 30, + "borderWidth": 1, + "elevation": 3, + "height": 30, + "opacity": 0, + "position": "absolute", + "shadowColor": "#000000", + "shadowOffset": { + "height": 3, + "width": 0, + }, + "shadowOpacity": 0.2, + "shadowRadius": 1, + "transform": [ + { + "translateX": 0, + }, + { + "translateY": 0, + }, + ], + "width": 30, + } + } + testID="range-slider-test$slider$thumb0" + /> + + + `; diff --git a/packages/pluggableWidgets/range-slider-native/src/package.xml b/packages/pluggableWidgets/range-slider-native/src/package.xml index 22d3db3d9..53e1fecac 100644 --- a/packages/pluggableWidgets/range-slider-native/src/package.xml +++ b/packages/pluggableWidgets/range-slider-native/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/slider-native/CHANGELOG.md b/packages/pluggableWidgets/slider-native/CHANGELOG.md index 3bd6a9dc8..cb845f556 100644 --- a/packages/pluggableWidgets/slider-native/CHANGELOG.md +++ b/packages/pluggableWidgets/slider-native/CHANGELOG.md @@ -8,7 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed -- We pinned @miblanchard/react-native-slider to 2.6.0. +- We replaced @miblanchard/react-native-slider with a built-in slider component. + +### Fixed + +- We fixed the slider not announcing its value or being adjustable with VoiceOver and TalkBack. ## [3.1.0] - 2024-12-3 diff --git a/packages/pluggableWidgets/slider-native/package.json b/packages/pluggableWidgets/slider-native/package.json index 2c64aed6c..7cb6add1a 100644 --- a/packages/pluggableWidgets/slider-native/package.json +++ b/packages/pluggableWidgets/slider-native/package.json @@ -1,7 +1,7 @@ { "name": "slider-native", "widgetName": "Slider", - "version": "4.0.1", + "version": "4.1.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -20,8 +20,7 @@ }, "dependencies": { "@mendix/piw-native-utils-internal": "*", - "@mendix/piw-utils-internal": "*", - "@miblanchard/react-native-slider": "2.6.0" + "@mendix/piw-utils-internal": "*" }, "devDependencies": { "@mendix/pluggable-widgets-tools": "*" diff --git a/packages/pluggableWidgets/slider-native/src/Slider.tsx b/packages/pluggableWidgets/slider-native/src/Slider.tsx index fb6818c12..a312d1ac5 100644 --- a/packages/pluggableWidgets/slider-native/src/Slider.tsx +++ b/packages/pluggableWidgets/slider-native/src/Slider.tsx @@ -1,7 +1,12 @@ -import { available, flattenStyles, toNumber, unavailable } from "@mendix/piw-native-utils-internal"; +import { + available, + flattenStyles, + Slider as NativeSlider, + toNumber, + unavailable +} from "@mendix/piw-native-utils-internal"; import { executeAction } from "@mendix/piw-utils-internal"; import { ValueStatus, Option } from "mendix"; -import { Slider as RNSlider } from "@miblanchard/react-native-slider"; import { ReactElement, useCallback, useRef } from "react"; import { Text, View } from "react-native"; import { Big } from "big.js"; @@ -23,27 +28,23 @@ export function Slider(props: Props): ReactElement { const decimalCount = useCallback((val: Option): number => val?.toString().split(".")?.[1]?.length || 0, []); const onValueChange = useCallback( - (values: number[]): void => { - if (values[0] === null || values[0] === undefined) { - return; - } - + (value: number): void => { if (props.stepSize.status === ValueStatus.Available) { - props.valueAttribute.setValue(new Big(values[0].toFixed(decimalCount(props.stepSize.value)))); + props.valueAttribute.setValue(new Big(value.toFixed(decimalCount(props.stepSize.value)))); } }, [props.valueAttribute, props.stepSize, decimalCount] ); const onSlidingComplete = useCallback( - (values: number[]): void => { - if (values[0] === null || values[0] === undefined || lastValue.current === values[0]) { + (value: number): void => { + if (lastValue.current === value) { return; } - lastValue.current = values[0]; + lastValue.current = value; if (props.stepSize.status === ValueStatus.Available) { - props.valueAttribute.setValue(new Big(values[0].toFixed(decimalCount(props.stepSize.value)))); + props.valueAttribute.setValue(new Big(value.toFixed(decimalCount(props.stepSize.value)))); } executeAction(props.onChange); @@ -53,12 +54,14 @@ export function Slider(props: Props): ReactElement { return ( - { - const { View } = require("react-native"); - return { - Slider: (props: any) => ( - props.onValueChange?.(values)} - onSlidingComplete={(values: number[]) => props.onSlidingComplete?.(values)} - /> - ) - }; -}); - describe("Slider", () => { const noValue: DynamicValue = { status: ValueStatus.Unavailable, value: undefined }; let defaultProps: Props; @@ -112,52 +99,88 @@ describe("Slider", () => { expect(component.queryByText("Invalid")).not.toBeNull(); }); + // The value 140 of 0-280 puts the thumb at left 135, so its centre in the touch area is at 9 + 135 + 15 + const thumbCentre = 159; + const sliderId = "slider-test$slider"; + it("renders as disabled when editable is never", () => { - const component = render(); - const slider = component.getByTestId("mocked-slider"); - expect(slider.props.disabled).toBe(true); + const component = renderMeasured(, sliderId); + + expect(component.getByTestId(sliderId).props.accessibilityState).toEqual({ disabled: true }); + expect(drag(component, sliderId, thumbCentre, 27)).toBe(false); + expect(defaultProps.valueAttribute.setValue).not.toHaveBeenCalled(); }); it("renders as enabled when editable is default", () => { - const component = render(); - const slider = component.getByTestId("mocked-slider"); - expect(slider.props.disabled).toBe(false); + const component = renderMeasured(, sliderId); + + expect(component.getByTestId(sliderId).props.accessibilityState).toEqual({ disabled: false }); }); - it("passes correct min/max/step to the slider", () => { - const component = render(); - const slider = component.getByTestId("mocked-slider"); - expect(slider.props.minimumValue).toBe(0); - expect(slider.props.maximumValue).toBe(280); - expect(slider.props.step).toBe(1); + it("announces the value as adjustable", () => { + const component = renderMeasured(, sliderId); + const slider = component.getByTestId(sliderId); + + expect(slider.props.accessibilityRole).toBe("adjustable"); + expect(slider.props.accessibilityValue).toEqual({ min: 0, max: 100, now: 50 }); + }); + + it("adjusts the value with a screen reader", () => { + const onChangeAction = actionValue(); + const component = renderMeasured(, sliderId); + + fireEvent(component.getByTestId(sliderId), "accessibilityAction", { nativeEvent: { actionName: "increment" } }); + + expect(defaultProps.valueAttribute.setValue).toHaveBeenLastCalledWith(new Big(168)); + expect(onChangeAction.execute).toHaveBeenCalledTimes(1); }); it("calls onValueChange when sliding", () => { - const component = render(); - const slider = component.getByTestId("mocked-slider"); + const onChangeAction = actionValue(); + const component = renderMeasured(, sliderId); - fireEvent(slider, "onValueChange", [200]); + drag(component, sliderId, thumbCentre, 27, false); - expect(defaultProps.valueAttribute.setValue).toHaveBeenCalledWith(new Big(200)); + expect(defaultProps.valueAttribute.setValue).toHaveBeenCalledWith(new Big(168)); + expect(onChangeAction.execute).not.toHaveBeenCalled(); }); it("calls onChange action on sliding complete", () => { const onChangeAction = actionValue(); - const component = render(); - const slider = component.getByTestId("mocked-slider"); + const component = renderMeasured(, sliderId); - fireEvent(slider, "onSlidingComplete", [200]); + drag(component, sliderId, thumbCentre, 27); - expect(defaultProps.valueAttribute.setValue).toHaveBeenCalledWith(new Big(200)); + expect(defaultProps.valueAttribute.setValue).toHaveBeenLastCalledWith(new Big(168)); expect(onChangeAction.execute).toHaveBeenCalledTimes(1); }); + it("rounds the value to the precision of the step size", () => { + const component = renderMeasured( + (new Big(0.5))} />, + sliderId + ); + + // 10px is 10.37 of 0-280, which snaps to 150.5 + drag(component, sliderId, thumbCentre, 10); + + expect(defaultProps.valueAttribute.setValue).toHaveBeenLastCalledWith(new Big("150.5")); + }); + + it("moves the thumb to a pressed position on the track", () => { + const component = renderMeasured(, sliderId); + + // Value 70 is at left 67.5 + drag(component, sliderId, 9 + 67.5 + 15, 0); + + expect(defaultProps.valueAttribute.setValue).toHaveBeenLastCalledWith(new Big(70)); + }); + it("does not call onChange when value hasn't changed", () => { const onChangeAction = actionValue(); - const component = render(); - const slider = component.getByTestId("mocked-slider"); + const component = renderMeasured(, sliderId); - fireEvent(slider, "onSlidingComplete", [140]); + drag(component, sliderId, thumbCentre, 0); expect(onChangeAction.execute).not.toHaveBeenCalled(); }); @@ -190,3 +213,48 @@ describe("Slider", () => { expect(component.toJSON()).toMatchSnapshot("with custom styles"); }); }); + +// A 300 wide container and the 30 wide default marker leave a 270px track; the 48px touch area adds 9px on each side +function renderMeasured(element: ReactElement, testID: string): RenderAPI { + const component = render(element); + const [container, thumb] = component + .getByTestId(testID) + .findAll(node => typeof node.type === "string" && typeof node.props.onLayout === "function"); + fireEvent(container, "layout", { nativeEvent: { layout: { width: 300, height: 40 } } }); + fireEvent(thumb, "layout", { nativeEvent: { layout: { width: 30, height: 30 } } }); + return component; +} + +function drag(component: RenderAPI, testID: string, locationX: number, dx: number, release = true): boolean { + const touchArea = component + .getByTestId(testID) + .findAll(node => typeof node.type === "string" && typeof node.props.onResponderGrant === "function")[0]; + const touch = { nativeEvent: { locationX, locationY: 20 }, touchHistory: { touchBank: [] } }; + if (!touchArea.props.onStartShouldSetResponder(touch)) { + return false; + } + fireEvent(touchArea, "responderGrant", touch); + const gesture = { + touchHistory: { + numberActiveTouches: 1, + indexOfSingleActiveTouch: 0, + touchBank: [ + { + touchActive: true, + currentTimeStamp: Date.now(), + currentPageX: dx, + currentPageY: 0, + previousPageX: 0, + previousPageY: 0, + startPageX: 0, + startPageY: 0 + } + ] + } + }; + fireEvent(touchArea, "responderMove", gesture); + if (release) { + fireEvent(touchArea, "responderRelease", gesture); + } + return true; +} diff --git a/packages/pluggableWidgets/slider-native/src/__tests__/__snapshots__/Slider.spec.tsx.snap b/packages/pluggableWidgets/slider-native/src/__tests__/__snapshots__/Slider.spec.tsx.snap index c43ed15da..dfc6a5864 100644 --- a/packages/pluggableWidgets/slider-native/src/__tests__/__snapshots__/Slider.spec.tsx.snap +++ b/packages/pluggableWidgets/slider-native/src/__tests__/__snapshots__/Slider.spec.tsx.snap @@ -10,50 +10,141 @@ exports[`Slider applies custom styles: with custom styles 1`] = ` testID="slider-test" > + + /> + + + + `; @@ -63,49 +154,140 @@ exports[`Slider renders 1`] = ` testID="slider-test" > + + /> + + + + `; diff --git a/packages/pluggableWidgets/slider-native/src/package.xml b/packages/pluggableWidgets/slider-native/src/package.xml index 88c35ccca..387ff5066 100644 --- a/packages/pluggableWidgets/slider-native/src/package.xml +++ b/packages/pluggableWidgets/slider-native/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/tools/piw-native-utils-internal/package.json b/packages/tools/piw-native-utils-internal/package.json index 373b559bd..563db5e6c 100644 --- a/packages/tools/piw-native-utils-internal/package.json +++ b/packages/tools/piw-native-utils-internal/package.json @@ -10,14 +10,18 @@ "url": "https://github.com/mendix/native-widgets/" }, "private": true, + "widgetName": "piwNativeUtilsInternal", + "packagePath": "com.mendix.utils", "main": "dist/index.js", "types": "dist/index.d.ts", + "sideEffects": false, "scripts": { "start": "tsc --watch", "prebuild": "rimraf dist", "build": "tsc", "lint": "eslint --ext .jsx,.js,.ts,.tsx src/", "prepare": "pnpm build", + "test": "pluggable-widgets-tools test:unit:native", "postinstall": "pnpm build" }, "devDependencies": { diff --git a/packages/tools/piw-native-utils-internal/src/components/Slider.tsx b/packages/tools/piw-native-utils-internal/src/components/Slider.tsx new file mode 100644 index 000000000..e80dcde35 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/components/Slider.tsx @@ -0,0 +1,525 @@ +import { createElement, ReactElement, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + AccessibilityActionEvent, + Animated, + GestureResponderEvent, + I18nManager, + LayoutChangeEvent, + PanResponder, + PanResponderGestureState, + StyleProp, + StyleSheet, + View, + ViewStyle +} from "react-native"; + +const TRACK_SIZE = 4; +const THUMB_SIZE = 20; +// Minimum touch target on both platforms (Android 48dp, iOS 44pt) +const MIN_TOUCH_SIZE = 48; +// Both platforms truncate accessibilityValue min/max/now to integers, and iOS reads it as now / (max - min), +// so the value is always reported on a 0-100 scale to be announced correctly for any range. +const ACCESSIBILITY_SCALE = 100; + +interface Size { + width: number; + height: number; +} + +interface SliderBaseProps { + minimumValue?: number; + maximumValue?: number; + /** Step between values; 0 means continuous. */ + step?: number; + disabled?: boolean; + /** When true, pressing the track moves the closest thumb to the pressed position and continues as a drag. */ + trackClickable?: boolean; + minimumTrackTintColor?: string; + maximumTrackTintColor?: string; + thumbTintColor?: string; + style?: StyleProp; + trackStyle?: StyleProp; + /** Style of the highlighted part of the track: up to the thumb, or between the thumbs of a range. */ + minimumTrackStyle?: StyleProp; + maximumTrackStyle?: StyleProp; + thumbStyle?: StyleProp; + /** Touchable area around each thumb; never smaller than 48x48. */ + thumbTouchSize?: Size; + /** Amount a screen reader increment/decrement changes a value. Defaults to a tenth of the range. */ + accessibilityIncrement?: number; + testID?: string; +} + +export interface SliderProps extends SliderBaseProps { + value: number; + onValueChange?: (value: number) => void; + onSlidingStart?: (value: number) => void; + onSlidingComplete?: (value: number) => void; + accessibilityLabel?: string; +} + +export type RangeSliderValue = [number, number]; + +export interface RangeSliderProps extends SliderBaseProps { + /** Lower and upper value. The thumbs can meet but not pass each other. */ + value: RangeSliderValue; + /** Called with both values and the index of the thumb that changed. */ + onValueChange?: (value: RangeSliderValue, thumbIndex: number) => void; + onSlidingStart?: (value: RangeSliderValue, thumbIndex: number) => void; + onSlidingComplete?: (value: RangeSliderValue, thumbIndex: number) => void; + /** Screen reader labels of the lower and upper thumb. */ + accessibilityLabels?: [string | undefined, string | undefined]; +} + +export function Slider(props: SliderProps): ReactElement { + const { value, onValueChange, onSlidingStart, onSlidingComplete } = props; + return ( + onValueChange(values[0]))} + onSlidingStart={onSlidingStart && (values => onSlidingStart(values[0]))} + onSlidingComplete={onSlidingComplete && (values => onSlidingComplete(values[0]))} + accessibilityLabels={[props.accessibilityLabel]} + /> + ); +} + +export function RangeSlider(props: RangeSliderProps): ReactElement { + const { value, onValueChange, onSlidingStart, onSlidingComplete } = props; + const toRange = (values: number[]): RangeSliderValue => [values[0], values[1]]; + return ( + onValueChange(toRange(values), index))} + onSlidingStart={onSlidingStart && ((values, index) => onSlidingStart(toRange(values), index))} + onSlidingComplete={onSlidingComplete && ((values, index) => onSlidingComplete(toRange(values), index))} + /> + ); +} + +type SlidingEvent = "onValueChange" | "onSlidingStart" | "onSlidingComplete"; + +interface MultiSliderProps extends SliderBaseProps, Partial> { + /** One value per thumb; the number of thumbs must not change between renders. */ + values: number[]; + accessibilityLabels?: Array; +} + +type SlidingCallback = (values: number[], thumbIndex: number) => void; + +interface Layout { + container: Size; + thumb: Size; + measured: boolean; +} + +const emptySize: Size = { width: 0, height: 0 }; + +function MultiSlider(props: MultiSliderProps): ReactElement { + const { + minimumValue = 0, + maximumValue = 1, + disabled = false, + minimumTrackTintColor = "#3f3f3f", + maximumTrackTintColor = "#b3b3b3", + thumbTintColor = "#343434", + accessibilityLabels = [], + testID + } = props; + const values = normalizeValues(props.values, props); + const single = values.length === 1; + + const [animatedValues] = useState(() => values.map(value => new Animated.Value(value))); + const [layout, setLayout] = useState({ container: emptySize, thumb: emptySize, measured: false }); + + // PanResponder handlers are created once, so they read everything through this ref + const latest = useRef({ + props, + layout, + propValues: values, + currentValues: values, + activeIndex: 0, + // Thumbs sharing the pressed position; the first move decides which one is dragged + tiedIndexes: [] as number[], + previousLeft: 0, + grantedOnThumb: true + }); + + const setCurrentValue = (index: number, next: number): void => { + const currentValues = [...latest.current.currentValues]; + currentValues[index] = next; + latest.current.currentValues = currentValues; + animatedValues[index].setValue(next); + }; + + useLayoutEffect(() => { + latest.current.props = props; + latest.current.layout = layout; + // Only follow the props when they change, so a re-render during a drag doesn't reset the thumb + if (values.some((value, index) => value !== latest.current.propValues[index])) { + latest.current.propValues = values; + values.forEach((value, index) => setCurrentValue(index, value)); + } + }); + + const panResponder = useMemo(() => { + const fire = (event: SlidingEvent): void => { + latest.current.props[event]?.(latest.current.currentValues, latest.current.activeIndex); + }; + + const setActiveValueForLeft = (left: number): void => { + const { props: current, layout: currentLayout, currentValues, activeIndex } = latest.current; + const value = clampToNeighbours(getValueForLeft(left, currentLayout, current), activeIndex, currentValues); + setCurrentValue(activeIndex, value); + }; + + // eslint-disable-next-line react-hooks/refs -- the ref is only read inside gesture handlers, never during render + return PanResponder.create({ + onStartShouldSetPanResponder: (event: GestureResponderEvent) => { + const { props: current, layout: currentLayout, currentValues } = latest.current; + if (current.disabled) { + return false; + } + // Testing libraries call this without an event to check whether the responder is enabled + if (!event?.nativeEvent) { + return true; + } + const hit = getThumbAt(event, currentLayout, currentValues, current); + if (!hit.onThumb && !current.trackClickable) { + return false; + } + latest.current.grantedOnThumb = hit.onThumb; + latest.current.activeIndex = hit.tiedIndexes[0]; + latest.current.tiedIndexes = hit.tiedIndexes; + return true; + }, + onMoveShouldSetPanResponder: () => false, + onPanResponderGrant: (event: GestureResponderEvent) => { + const { props: current, layout: currentLayout, currentValues } = latest.current; + if (!latest.current.grantedOnThumb) { + const overflow = getTouchOverflowSize(currentLayout, current); + const touchX = event.nativeEvent.locationX - overflow.width / 2; + const left = toLogicalLeft(touchX - currentLayout.thumb.width / 2, currentLayout); + const thumbLeft = getThumbLeft(currentValues[latest.current.activeIndex], currentLayout, current); + resolveTie(left - thumbLeft); + setActiveValueForLeft(left); + } + latest.current.previousLeft = getThumbLeft( + latest.current.currentValues[latest.current.activeIndex], + currentLayout, + current + ); + fire("onSlidingStart"); + if (!latest.current.grantedOnThumb) { + fire("onValueChange"); + } + }, + onPanResponderMove: (_event: GestureResponderEvent, gestureState: PanResponderGestureState) => { + if (latest.current.props.disabled) { + return; + } + const dx = toLogicalDx(gestureState.dx); + resolveTie(dx); + setActiveValueForLeft(latest.current.previousLeft + dx); + fire("onValueChange"); + }, + onPanResponderTerminationRequest: () => false, + onPanResponderRelease: (_event: GestureResponderEvent, gestureState: PanResponderGestureState) => + end(gestureState), + onPanResponderTerminate: (_event: GestureResponderEvent, gestureState: PanResponderGestureState) => + end(gestureState) + }); + + // Moving up takes the highest of the tied thumbs, moving down the lowest, so they can always be separated + function resolveTie(direction: number): void { + const { tiedIndexes } = latest.current; + if (tiedIndexes.length > 1 && direction !== 0) { + latest.current.activeIndex = direction > 0 ? tiedIndexes[tiedIndexes.length - 1] : tiedIndexes[0]; + latest.current.tiedIndexes = [latest.current.activeIndex]; + } + } + + function end(gestureState: PanResponderGestureState): void { + latest.current.grantedOnThumb = true; + latest.current.tiedIndexes = []; + if (latest.current.props.disabled) { + return; + } + setActiveValueForLeft(latest.current.previousLeft + toLogicalDx(gestureState.dx)); + fire("onSlidingComplete"); + } + }, []); + + const onAccessibilityAction = (index: number, event: AccessibilityActionEvent): void => { + const direction = + event.nativeEvent.actionName === "increment" ? 1 : event.nativeEvent.actionName === "decrement" ? -1 : 0; + if (disabled || direction === 0) { + return; + } + const { currentValues } = latest.current; + const current = currentValues[index]; + const next = clampToNeighbours( + snapToStep(current + direction * getAccessibilityIncrement(props), props), + index, + currentValues + ); + if (next === current) { + return; + } + latest.current.activeIndex = index; + props.onSlidingStart?.(currentValues, index); + setCurrentValue(index, next); + props.onValueChange?.(latest.current.currentValues, index); + props.onSlidingComplete?.(latest.current.currentValues, index); + }; + + const accessibilityProps = values.map((value, index) => ({ + accessible: true, + accessibilityRole: "adjustable" as const, + accessibilityLabel: accessibilityLabels[index], + accessibilityState: { disabled }, + accessibilityValue: { + min: 0, + max: ACCESSIBILITY_SCALE, + now: Math.round(getRatio(value, props) * ACCESSIBILITY_SCALE) + }, + accessibilityActions: [{ name: "increment" }, { name: "decrement" }] + })); + + const measure = + (name: "container" | "thumb") => + (event: LayoutChangeEvent): void => { + const { width, height } = event.nativeEvent.layout; + setLayout(previous => { + if (previous[name].width === width && previous[name].height === height) { + return previous; + } + const next = { ...previous, [name]: { width, height } }; + return { ...next, measured: next.container.width > 0 && next.thumb.width > 0 }; + }); + }; + + const length = layout.container.width - layout.thumb.width; + const inputRange = [minimumValue, maximumValue > minimumValue ? maximumValue : minimumValue + 1]; + const trackLefts = animatedValues.map(value => + value.interpolate({ inputRange, outputRange: [0, length], extrapolate: "clamp" }) + ); + const visibility = layout.measured ? undefined : styles.hidden; + const overflow = getTouchOverflowSize(layout, props); + const halfThumb = layout.thumb.width / 2; + + return ( + onAccessibilityAction(0, event) : undefined + } + > + + + {trackLefts.map((left, index) => ( + onAccessibilityAction(index, event) + } + style={[ + { backgroundColor: thumbTintColor }, + styles.thumb, + props.thumbStyle, + { + transform: [ + { translateX: I18nManager.isRTL ? Animated.multiply(left, -1) : left }, + { translateY: 0 } + ] + }, + visibility + ]} + /> + ))} + + + ); +} + +function normalizeValues(values: number[], { minimumValue = 0, maximumValue = 1 }: SliderBaseProps): number[] { + return values.map(value => Math.max(minimumValue, Math.min(maximumValue, value))).sort((a, b) => a - b); +} + +function getRatio(value: number, { minimumValue = 0, maximumValue = 1 }: SliderBaseProps): number { + const range = maximumValue - minimumValue; + return range > 0 ? Math.max(0, Math.min(1, (value - minimumValue) / range)) : 0; +} + +function getTrackLength(layout: Layout): number { + return layout.container.width - layout.thumb.width; +} + +/** Distance of the thumb from the start of the track, which is the right edge in right-to-left layouts. */ +function getThumbLeft(value: number, layout: Layout, props: SliderBaseProps): number { + return getRatio(value, props) * getTrackLength(layout); +} + +/** Converts between a distance from the left edge, as touches report it, and from the start of the track. */ +function toLogicalLeft(left: number, layout: Layout): number { + return I18nManager.isRTL ? getTrackLength(layout) - left : left; +} + +function toLogicalDx(dx: number): number { + return I18nManager.isRTL ? -dx : dx; +} + +function getValueForLeft(left: number, layout: Layout, props: SliderBaseProps): number { + const { minimumValue = 0, maximumValue = 1 } = props; + const length = getTrackLength(layout); + if (length <= 0) { + return snapToStep(minimumValue, props); + } + return snapToStep((left / length) * (maximumValue - minimumValue) + minimumValue, props); +} + +function clampToNeighbours(value: number, index: number, values: number[]): number { + const lower = index > 0 ? values[index - 1] : -Infinity; + const upper = index < values.length - 1 ? values[index + 1] : Infinity; + return Math.max(lower, Math.min(upper, value)); +} + +function snapToStep(value: number, { minimumValue = 0, maximumValue = 1, step = 0 }: SliderBaseProps): number { + const snapped = + step > 0 + ? // Round to the precision of the step to avoid floating point noise such as 0.35000000000000003 + Number( + (minimumValue + Math.round((value - minimumValue) / step) * step).toFixed( + Math.max(decimalPlaces(step), decimalPlaces(minimumValue)) + ) + ) + : value; + return Math.max(minimumValue, Math.min(maximumValue, snapped)); +} + +function decimalPlaces(value: number): number { + const [coefficient, exponent = "0"] = String(value).toLowerCase().split("e"); + const fractionalDigits = (coefficient.split(".")[1] ?? "").length; + return Math.max(0, fractionalDigits - Number(exponent)); +} + +function getAccessibilityIncrement(props: SliderBaseProps): number { + const { minimumValue = 0, maximumValue = 1, step = 0, accessibilityIncrement } = props; + const increment = accessibilityIncrement ?? (maximumValue - minimumValue) / 10; + return step > 0 ? Math.max(step, Math.round(increment / step) * step) : increment; +} + +function getThumbTouchSize({ thumbTouchSize }: SliderBaseProps): Size { + return { + width: Math.max(MIN_TOUCH_SIZE, thumbTouchSize?.width ?? 0), + height: Math.max(MIN_TOUCH_SIZE, thumbTouchSize?.height ?? 0) + }; +} + +function getTouchOverflowSize(layout: Layout, props: SliderBaseProps): Size { + const touchSize = getThumbTouchSize(props); + return { + width: Math.max(0, touchSize.width - layout.thumb.width), + height: Math.max(0, touchSize.height - layout.container.height) + }; +} + +/** + * Finds the thumb closest to a touch and whether the touch is within its touch area. Returns every thumb at that + * position, so thumbs on top of each other can be told apart once the drag direction is known. + */ +function getThumbAt( + event: GestureResponderEvent, + layout: Layout, + values: number[], + props: SliderBaseProps +): { onThumb: boolean; tiedIndexes: number[] } { + const touchSize = getThumbTouchSize(props); + const overflow = getTouchOverflowSize(layout, props); + const { locationX, locationY } = event.nativeEvent; + const centres = values.map( + value => overflow.width / 2 + toLogicalLeft(getThumbLeft(value, layout, props), layout) + layout.thumb.width / 2 + ); + const distances = centres.map(centre => Math.abs(locationX - centre)); + const closest = distances.indexOf(Math.min(...distances)); + const top = overflow.height / 2 + (layout.container.height - touchSize.height) / 2; + const onThumb = + distances[closest] <= touchSize.width / 2 && locationY >= top && locationY <= top + touchSize.height; + return { + onThumb, + tiedIndexes: values.map((_, index) => index).filter(index => values[index] === values[closest]) + }; +} + +const styles = StyleSheet.create({ + container: { + height: 40, + justifyContent: "center" + }, + track: { + height: TRACK_SIZE, + borderRadius: TRACK_SIZE / 2 + }, + thumb: { + position: "absolute", + width: THUMB_SIZE, + height: THUMB_SIZE, + borderRadius: THUMB_SIZE / 2 + }, + touchArea: { + position: "absolute", + backgroundColor: "transparent", + top: 0, + left: 0, + right: 0, + bottom: 0 + }, + hidden: { + opacity: 0 + } +}); diff --git a/packages/tools/piw-native-utils-internal/src/components/__tests__/Slider.spec.tsx b/packages/tools/piw-native-utils-internal/src/components/__tests__/Slider.spec.tsx new file mode 100644 index 000000000..efe557274 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/components/__tests__/Slider.spec.tsx @@ -0,0 +1,459 @@ +import { createElement, ReactElement } from "react"; +import { I18nManager, StyleSheet } from "react-native"; +import { fireEvent, render, RenderAPI } from "@testing-library/react-native"; +import { ReactTestInstance } from "react-test-renderer"; +import { RangeSlider, RangeSliderProps, Slider, SliderProps } from "../Slider"; + +// Container 300 wide and thumb 20 wide gives a 280px track, so a value of 0.5 puts the thumb at left 140. +const CONTAINER = { width: 300, height: 40 }; +const THUMB = { width: 20, height: 20 }; + +describe("Slider", () => { + let defaultProps: SliderProps; + + beforeEach(() => { + defaultProps = { + testID: "slider", + value: 0.5, + step: 0.01, + onSlidingStart: jest.fn(), + onValueChange: jest.fn(), + onSlidingComplete: jest.fn() + }; + }); + + describe("accessibility", () => { + it("is an adjustable element announcing its value on a 0-100 scale", () => { + const slider = renderMeasured(defaultProps).getByTestId("slider"); + + expect(slider.props.accessible).toBe(true); + expect(slider.props.accessibilityRole).toBe("adjustable"); + expect(slider.props.accessibilityValue).toEqual({ min: 0, max: 100, now: 50 }); + expect(slider.props.accessibilityActions).toEqual([{ name: "increment" }, { name: "decrement" }]); + }); + + it("normalizes ranges that do not start at zero", () => { + const slider = renderMeasured({ + ...defaultProps, + minimumValue: 10, + maximumValue: 20, + value: 15 + }).getByTestId("slider"); + + expect(slider.props.accessibilityValue).toEqual({ min: 0, max: 100, now: 50 }); + }); + + it("reports the disabled state", () => { + const slider = renderMeasured({ ...defaultProps, disabled: true }).getByTestId("slider"); + + expect(slider.props.accessibilityState).toEqual({ disabled: true }); + }); + + it("increments and decrements by a tenth of the range, snapped to the step", () => { + const component = renderMeasured({ + ...defaultProps, + minimumValue: 0, + maximumValue: 359, + step: 1, + value: 100 + }); + + fireEvent(component.getByTestId("slider"), "accessibilityAction", { + nativeEvent: { actionName: "increment" } + }); + expect(defaultProps.onValueChange).toHaveBeenLastCalledWith(136); + expect(defaultProps.onSlidingComplete).toHaveBeenLastCalledWith(136); + + fireEvent(component.getByTestId("slider"), "accessibilityAction", { + nativeEvent: { actionName: "decrement" } + }); + expect(defaultProps.onValueChange).toHaveBeenLastCalledWith(100); + }); + + it("uses a custom increment", () => { + const component = renderMeasured({ ...defaultProps, value: 0.5, accessibilityIncrement: 0.25 }); + + fireEvent(component.getByTestId("slider"), "accessibilityAction", { + nativeEvent: { actionName: "increment" } + }); + + expect(defaultProps.onValueChange).toHaveBeenCalledWith(0.75); + }); + + it("does not go beyond the bounds", () => { + const component = renderMeasured({ ...defaultProps, value: 1 }); + + fireEvent(component.getByTestId("slider"), "accessibilityAction", { + nativeEvent: { actionName: "increment" } + }); + + expect(defaultProps.onValueChange).not.toHaveBeenCalled(); + expect(defaultProps.onSlidingComplete).not.toHaveBeenCalled(); + }); + + it("ignores actions when disabled", () => { + const component = renderMeasured({ ...defaultProps, disabled: true }); + + fireEvent(component.getByTestId("slider"), "accessibilityAction", { + nativeEvent: { actionName: "increment" } + }); + + expect(defaultProps.onValueChange).not.toHaveBeenCalled(); + }); + }); + + describe("dragging", () => { + it("drags the thumb and completes on release", () => { + const touchArea = getTouchArea(renderMeasured(defaultProps)); + + expect(press(touchArea, 150)).toBe(true); + fireEvent(touchArea, "responderGrant", touch(150)); + expect(defaultProps.onSlidingStart).toHaveBeenCalledWith(0.5); + expect(defaultProps.onValueChange).not.toHaveBeenCalled(); + + fireEvent(touchArea, "responderMove", move(70)); + expect(defaultProps.onValueChange).toHaveBeenLastCalledWith(0.75); + expect(defaultProps.onSlidingComplete).not.toHaveBeenCalled(); + + fireEvent(touchArea, "responderRelease", move(70)); + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith(0.75); + }); + + it("clamps to the bounds", () => { + const touchArea = getTouchArea(renderMeasured(defaultProps)); + + fireEvent(touchArea, "responderGrant", touch(150)); + fireEvent(touchArea, "responderMove", move(-1000)); + fireEvent(touchArea, "responderRelease", move(-1000)); + expect(defaultProps.onSlidingComplete).toHaveBeenLastCalledWith(0); + + fireEvent(touchArea, "responderGrant", touch(0)); + fireEvent(touchArea, "responderMove", move(1000)); + fireEvent(touchArea, "responderRelease", move(1000)); + expect(defaultProps.onSlidingComplete).toHaveBeenLastCalledWith(1); + }); + + it("keeps a touch area of at least 48x48 around the thumb", () => { + const touchArea = getTouchArea( + renderMeasured({ ...defaultProps, thumbTouchSize: { width: 10, height: 10 } }) + ); + + // Thumb centre is at 150 in the container; the touch area is offset by half the 28px overflow + expect(press(touchArea, 14 + 150 + 23)).toBe(true); + expect(press(touchArea, 14 + 150 + 25)).toBe(false); + }); + + it("starts dragging from a value updated through props", () => { + const component = renderMeasured(defaultProps); + component.rerender(); + const touchArea = getTouchArea(component); + + fireEvent(touchArea, "responderGrant", touch(0)); + fireEvent(touchArea, "responderMove", move(28)); + fireEvent(touchArea, "responderRelease", move(28)); + + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith(0.35); + }); + + it("does not respond when disabled", () => { + const touchArea = getTouchArea(renderMeasured({ ...defaultProps, disabled: true })); + + expect(press(touchArea, 150)).toBe(false); + }); + }); + + it("rounds values to the precision of the step", () => { + const touchArea = getTouchArea( + renderMeasured({ ...defaultProps, minimumValue: 0.5, maximumValue: 2, step: 0.1 }) + ); + + fireEvent(touchArea, "responderGrant", touch(0)); + fireEvent(touchArea, "responderMove", move(131)); + + expect(defaultProps.onValueChange).toHaveBeenLastCalledWith(1.2); + }); + + it("rounds values when the step uses scientific notation", () => { + const touchArea = getTouchArea( + renderMeasured({ ...defaultProps, minimumValue: 0, maximumValue: 1e-6, step: 1e-7, value: 0 }) + ); + + fireEvent(touchArea, "responderGrant", touch(24)); + fireEvent(touchArea, "responderMove", move(112)); + + expect(defaultProps.onValueChange).toHaveBeenLastCalledWith(4e-7); + }); + + describe("track", () => { + it("ignores presses outside the thumb by default", () => { + const touchArea = getTouchArea(renderMeasured(defaultProps)); + + expect(press(touchArea, 20)).toBe(false); + }); + + it("moves the thumb to the pressed position when clickable", () => { + const touchArea = getTouchArea(renderMeasured({ ...defaultProps, trackClickable: true })); + + // Thumb centre at 3/4 of the track: 10 + 210 in the container, plus the 14px touch overflow + expect(press(touchArea, 14 + 220)).toBe(true); + fireEvent(touchArea, "responderGrant", touch(14 + 220)); + expect(defaultProps.onValueChange).toHaveBeenCalledWith(0.75); + + fireEvent(touchArea, "responderRelease", move(0)); + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith(0.75); + }); + + it("continues as a drag after pressing the track", () => { + const touchArea = getTouchArea(renderMeasured({ ...defaultProps, trackClickable: true })); + + press(touchArea, 14 + 220); + fireEvent(touchArea, "responderGrant", touch(14 + 220)); + fireEvent(touchArea, "responderMove", move(-140)); + fireEvent(touchArea, "responderRelease", move(-140)); + + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith(0.25); + }); + + it("does not respond when disabled", () => { + const touchArea = getTouchArea(renderMeasured({ ...defaultProps, trackClickable: true, disabled: true })); + + expect(press(touchArea, 20)).toBe(false); + }); + }); + + it("lets the minimum track style override the tint color", () => { + const component = renderMeasured( + + ); + const [, minimumTrack] = component + .getByTestId("slider") + .findAll(node => typeof node.type === "string" && node.props.renderToHardwareTextureAndroid); + + expect(StyleSheet.flatten(minimumTrack.props.style).backgroundColor).toBe("blue"); + }); + + describe("in a right-to-left layout", () => { + beforeEach(() => { + jest.replaceProperty(I18nManager, "isRTL", true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("starts the track on the right", () => { + const touchArea = getTouchArea(renderMeasured({ ...defaultProps, value: 0.25 })); + + // Thumb at a quarter of the track from the right edge: 280 - 70 = 210 from the left + expect(press(touchArea, 14 + 210 + 10)).toBe(true); + expect(press(touchArea, 14 + 70 + 10)).toBe(false); + + fireEvent(touchArea, "responderGrant", touch(14 + 210 + 10)); + fireEvent(touchArea, "responderMove", move(-70)); + fireEvent(touchArea, "responderRelease", move(-70)); + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith(0.5); + }); + + it("moves the thumb to the pressed position when clickable", () => { + const touchArea = getTouchArea(renderMeasured({ ...defaultProps, trackClickable: true })); + + press(touchArea, 14 + 70 + 10); + fireEvent(touchArea, "responderGrant", touch(14 + 70 + 10)); + + expect(defaultProps.onValueChange).toHaveBeenCalledWith(0.75); + }); + }); +}); + +// Values 20 and 80 of 0-100 put the thumbs at left 56 and 224, so their centres in the touch area are at 80 and 248 +describe("RangeSlider", () => { + let defaultProps: RangeSliderProps; + + beforeEach(() => { + defaultProps = { + testID: "slider", + value: [20, 80], + minimumValue: 0, + maximumValue: 100, + step: 1, + accessibilityLabels: ["Minimum", "Maximum"], + onSlidingStart: jest.fn(), + onValueChange: jest.fn(), + onSlidingComplete: jest.fn() + }; + }); + + describe("accessibility", () => { + it("makes each thumb an adjustable element with its own label and value", () => { + const component = renderMeasured(); + const lower = component.getByTestId("slider$thumb0"); + const upper = component.getByTestId("slider$thumb1"); + + expect(component.getByTestId("slider").props.accessible).toBeUndefined(); + expect(lower.props.accessibilityRole).toBe("adjustable"); + expect(lower.props.accessibilityLabel).toBe("Minimum"); + expect(lower.props.accessibilityValue).toEqual({ min: 0, max: 100, now: 20 }); + expect(upper.props.accessibilityLabel).toBe("Maximum"); + expect(upper.props.accessibilityValue).toEqual({ min: 0, max: 100, now: 80 }); + }); + + it("orders values given in the wrong order", () => { + const component = renderMeasured(); + + expect(component.getByTestId("slider$thumb0").props.accessibilityValue.now).toBe(20); + }); + + it("adjusts a thumb without passing the other", () => { + const component = renderMeasured(); + + fireEvent(component.getByTestId("slider$thumb0"), "accessibilityAction", { + nativeEvent: { actionName: "increment" } + }); + expect(defaultProps.onSlidingComplete).toHaveBeenLastCalledWith([25, 25], 0); + + fireEvent(component.getByTestId("slider$thumb1"), "accessibilityAction", { + nativeEvent: { actionName: "increment" } + }); + expect(defaultProps.onSlidingComplete).toHaveBeenLastCalledWith([25, 35], 1); + }); + }); + + describe("dragging", () => { + it("drags the lower thumb", () => { + const touchArea = getTouchArea(renderMeasured()); + + expect(press(touchArea, 80)).toBe(true); + fireEvent(touchArea, "responderGrant", touch(80)); + expect(defaultProps.onSlidingStart).toHaveBeenCalledWith([20, 80], 0); + fireEvent(touchArea, "responderMove", move(70)); + expect(defaultProps.onValueChange).toHaveBeenLastCalledWith([45, 80], 0); + fireEvent(touchArea, "responderRelease", move(70)); + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith([45, 80], 0); + }); + + it("drags the upper thumb", () => { + const touchArea = getTouchArea(renderMeasured()); + + press(touchArea, 248); + fireEvent(touchArea, "responderGrant", touch(248)); + fireEvent(touchArea, "responderMove", move(-70)); + fireEvent(touchArea, "responderRelease", move(-70)); + + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith([20, 55], 1); + }); + + it("ignores presses between the thumbs by default", () => { + const touchArea = getTouchArea(renderMeasured()); + + expect(press(touchArea, 164)).toBe(false); + }); + + it("stops a thumb at the other thumb", () => { + const touchArea = getTouchArea(renderMeasured()); + + press(touchArea, 80); + fireEvent(touchArea, "responderGrant", touch(80)); + fireEvent(touchArea, "responderMove", move(1000)); + fireEvent(touchArea, "responderRelease", move(1000)); + + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith([80, 80], 0); + }); + + it.each([ + ["down", -28, [40, 50], 0], + ["up", 28, [50, 60], 1] + ])("separates thumbs on top of each other when dragging %s", (_, dx, expected, index) => { + const touchArea = getTouchArea(renderMeasured()); + + expect(press(touchArea, 164)).toBe(true); + fireEvent(touchArea, "responderGrant", touch(164)); + fireEvent(touchArea, "responderMove", move(dx as number)); + fireEvent(touchArea, "responderRelease", move(dx as number)); + + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith(expected, index); + }); + + it("separates thumbs on top of each other at the minimum", () => { + const touchArea = getTouchArea(renderMeasured()); + + press(touchArea, 24); + fireEvent(touchArea, "responderGrant", touch(24)); + fireEvent(touchArea, "responderMove", move(28)); + fireEvent(touchArea, "responderRelease", move(28)); + + expect(defaultProps.onSlidingComplete).toHaveBeenCalledWith([0, 10], 1); + }); + }); + + describe("track", () => { + it("moves the closest thumb to the pressed position", () => { + const touchArea = getTouchArea(renderMeasured()); + + // Value 90 is at left 252 + expect(press(touchArea, 14 + 252 + 10)).toBe(true); + fireEvent(touchArea, "responderGrant", touch(14 + 252 + 10)); + expect(defaultProps.onValueChange).toHaveBeenLastCalledWith([20, 90], 1); + fireEvent(touchArea, "responderRelease", move(0)); + + press(touchArea, 14 + 10); + fireEvent(touchArea, "responderGrant", touch(14 + 10)); + expect(defaultProps.onValueChange).toHaveBeenLastCalledWith([0, 90], 0); + }); + + it("highlights the track between the thumbs", () => { + const component = renderMeasured(); + const [, minimumTrack] = component + .getByTestId("slider") + .findAll(node => typeof node.type === "string" && node.props.renderToHardwareTextureAndroid); + const style = StyleSheet.flatten(minimumTrack.props.style); + + expect(style.start).toBe(56 + 10); + expect(style.width).toBe(224 - 56); + }); + }); +}); + +function renderMeasured(slider: SliderProps | ReactElement): RenderAPI { + const component = render("value" in slider ? : slider); + const [container, thumb] = component + .getByTestId("slider") + .findAll(node => typeof node.type === "string" && typeof node.props.onLayout === "function"); + fireEvent(container, "layout", { nativeEvent: { layout: CONTAINER } }); + fireEvent(thumb, "layout", { nativeEvent: { layout: THUMB } }); + return component; +} + +function getTouchArea(component: RenderAPI): ReactTestInstance { + return component + .getByTestId("slider") + .findAll(node => typeof node.type === "string" && typeof node.props.onResponderGrant === "function")[0]; +} + +function press(touchArea: ReactTestInstance, locationX: number): boolean { + return touchArea.props.onStartShouldSetResponder(touch(locationX)); +} + +function touch(locationX: number): object { + return { nativeEvent: { locationX, locationY: 20 }, touchHistory: { touchBank: [] } }; +} + +function move(dx: number): object { + return { + touchHistory: { + numberActiveTouches: 1, + indexOfSingleActiveTouch: 0, + touchBank: [ + { + touchActive: true, + currentTimeStamp: Date.now(), + currentPageX: dx, + currentPageY: 0, + previousPageX: 0, + previousPageY: 0, + startPageX: 0, + startPageY: 0 + } + ] + } + }; +} diff --git a/packages/tools/piw-native-utils-internal/src/index.ts b/packages/tools/piw-native-utils-internal/src/index.ts index 1fc8e356c..b51a411d7 100644 --- a/packages/tools/piw-native-utils-internal/src/index.ts +++ b/packages/tools/piw-native-utils-internal/src/index.ts @@ -1,3 +1,4 @@ export { flattenStyles, Style } from "./styles"; export { only, exclude } from "./objectFilters"; export { available, toNumber, unavailable } from "./properties"; +export { Slider, SliderProps, RangeSlider, RangeSliderProps, RangeSliderValue } from "./components/Slider"; diff --git a/patches/react-native-slider+0.11.0.patch b/patches/react-native-slider+0.11.0.patch deleted file mode 100644 index 43b7cb7b0..000000000 --- a/patches/react-native-slider+0.11.0.patch +++ /dev/null @@ -1,55 +0,0 @@ -diff --git a/lib/Slider.js b/lib/Slider.js -index c640410..5b56a09 100644 ---- a/lib/Slider.js -+++ b/lib/Slider.js -@@ -5,6 +5,7 @@ var _react=require("react");var _react2=_interopRequireDefault(_react); - - - var _reactNative=require("react-native"); -+var _reactNativePropTypes=require("deprecated-react-native-prop-types"); - - - -@@ -549,13 +550,13 @@ return false;}}]);return Slider;}(_react.PureComponent);Slider.propTypes={ /** - * the slider is released). - */onSlidingComplete:_propTypes2.default.func, /** - * The style applied to the slider container. -- */style:_reactNative.ViewPropTypes.style, /** -+ */style:_reactNativePropTypes.ViewPropTypes.style, /** - * The style applied to the track. -- */trackStyle:_reactNative.ViewPropTypes.style, /** -+ */trackStyle:_reactNativePropTypes.ViewPropTypes.style, /** - * The style applied to the thumb. -- */thumbStyle:_reactNative.ViewPropTypes.style, /** -+ */thumbStyle:_reactNativePropTypes.ViewPropTypes.style, /** - * Sets an image for the thumb. -- */thumbImage:_reactNative.Image.propTypes.source, /** -+ */thumbImage:_reactNativePropTypes.ImagePropTypes.source, /** - * Set this to true to visually see the thumb touch rect in green. - */debugTouchArea:_propTypes2.default.bool, /** - * Set to true to animate values with default 'timing' animation type -diff --git a/src/Slider.js b/src/Slider.js -index 37deee5..b8a21a3 100644 ---- a/src/Slider.js -+++ b/src/Slider.js -@@ -11,9 +11,10 @@ import { - PanResponder, - View, - Easing, -- ViewPropTypes - } from "react-native"; - -+import { ViewPropTypes, ImagePropTypes } from "deprecated-react-native-prop-types"; -+ - import PropTypes from 'prop-types'; - - var TRACK_SIZE = 4; -@@ -146,7 +147,7 @@ export default class Slider extends PureComponent { - /** - * Sets an image for the thumb. - */ -- thumbImage: Image.propTypes.source, -+ thumbImage: ImagePropTypes.source, - - /** - * Set this to true to visually see the thumb touch rect in green. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bb461204..a4256dcc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,9 +36,6 @@ patchedDependencies: react-native-gesture-handler@2.31.2: hash: 5be6c2cce8be6bd8afdbbf6c289d8b210686eafce0b41bf8cae2c29388aaa3b8 path: patches/react-native-gesture-handler+2.31.2.patch - react-native-slider@0.11.0: - hash: 899d0bc0de45e25eb7731c4e61ae23a5223c2e1654b7d020dfae1c70e61b32c6 - path: patches/react-native-slider+0.11.0.patch react-native-snap-carousel@3.9.1: hash: 3a4c1b0f17629becc5d99ef1958df5a2f55c46e45f45670862aaeda12ab2f4e1 path: patches/react-native-snap-carousel+3.9.1.patch @@ -426,15 +423,6 @@ importers: '@mendix/piw-utils-internal': specifier: '*' version: link:../../tools/piw-utils-internal - deprecated-react-native-prop-types: - specifier: 4.2.3 - version: 4.2.3 - react-native-color: - specifier: 0.0.10 - version: 0.0.10(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-slider: - specifier: 0.11.0 - version: 0.11.0(patch_hash=899d0bc0de45e25eb7731c4e61ae23a5223c2e1654b7d020dfae1c70e61b32c6) tinycolor2: specifier: 1.6.0 version: 1.6.0 @@ -753,9 +741,6 @@ importers: '@mendix/piw-utils-internal': specifier: '*' version: link:../../tools/piw-utils-internal - '@miblanchard/react-native-slider': - specifier: 2.6.0 - version: 2.6.0(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 @@ -842,9 +827,6 @@ importers: '@mendix/piw-utils-internal': specifier: '*' version: link:../../tools/piw-utils-internal - '@miblanchard/react-native-slider': - specifier: 2.6.0 - version: 2.6.0(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 @@ -2041,12 +2023,6 @@ packages: engines: {node: '>=20'} hasBin: true - '@miblanchard/react-native-slider@2.6.0': - resolution: {integrity: sha512-o7hk/f/8vkqh6QNR5L52m+ws846fQeD/qNCC9CCSRdBqjq66KiCgbxzlhRzKM/gbtxcvMYMIEEJ1yes5cr6I3A==} - peerDependencies: - react: 19.2.3 - react-native: 0.84.1 - '@napi-rs/lzma-android-arm-eabi@1.5.1': resolution: {integrity: sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -6522,12 +6498,6 @@ packages: react: 19.2.3 react-native: 0.84.1 - react-native-color@0.0.10: - resolution: {integrity: sha512-MrYIaWH/fiRXBhfJBVG7vtNF5cQQKg1rK16pU8xtlKne2Ipu+Inki1Gb/oFvaOu+zNXb5/6tss467k8zYIvMwA==} - peerDependencies: - react: 19.2.3 - react-native: 0.84.1 - react-native-device-info@15.0.2: resolution: {integrity: sha512-dd71eXG2l3Cwp66IvKNadMTB8fhU3PEjyVddI97sYan+D4bgIAUmgGDhbSOFvHcGavksb2U17kiQYaDiK2WK2g==} peerDependencies: @@ -6673,9 +6643,6 @@ packages: peerDependencies: react-native-webview: ^11.0.3 - react-native-slider@0.11.0: - resolution: {integrity: sha512-jV9K87eu9uWr0uJIyrSpBLnCKvVlOySC2wynq9TFCdV9oGgjt7Niq8Q1A8R8v+5GHsuBw/s8vEj1AAkkUi+u+w==} - react-native-snap-carousel@3.9.1: resolution: {integrity: sha512-xWEGusacIgK1YaDXLi7Gao2+ISLoGPVEBR8fcMf4tOOJQufutlNwkoLu0l6B8Qgsrre0nTxoVZikRgGRDWlLaQ==} peerDependencies: @@ -9535,11 +9502,6 @@ snapshots: - tslib - utf-8-validate - '@miblanchard/react-native-slider@2.6.0(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: - 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) - '@napi-rs/lzma-android-arm-eabi@1.5.1': optional: true @@ -14778,14 +14740,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-color@0.0.10(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: 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-slider: 0.11.0(patch_hash=899d0bc0de45e25eb7731c4e61ae23a5223c2e1654b7d020dfae1c70e61b32c6) - tinycolor2: 1.6.0 - react-native-device-info@15.0.2(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)): dependencies: 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) @@ -14907,10 +14861,6 @@ snapshots: dependencies: react-native-webview: 13.16.1(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-slider@0.11.0(patch_hash=899d0bc0de45e25eb7731c4e61ae23a5223c2e1654b7d020dfae1c70e61b32c6): - dependencies: - prop-types: 15.8.1 - react-native-snap-carousel@3.9.1(patch_hash=3a4c1b0f17629becc5d99ef1958df5a2f55c46e45f45670862aaeda12ab2f4e1)(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