diff --git a/package.json b/package.json index 97faa5dae..d96a3d6f2 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,6 @@ }, "patchedDependencies": { "react-native-gesture-handler@2.31.2": "patches/react-native-gesture-handler+2.31.2.patch", - "react-native-snap-carousel@3.9.1": "patches/react-native-snap-carousel+3.9.1.patch", "@mendix/pluggable-widgets-tools@11.12.0": "patches/@mendix__pluggable-widgets-tools@11.12.0.patch" } }, diff --git a/packages/pluggableWidgets/carousel-native/CHANGELOG.md b/packages/pluggableWidgets/carousel-native/CHANGELOG.md index e0f3a11bd..978724650 100644 --- a/packages/pluggableWidgets/carousel-native/CHANGELOG.md +++ b/packages/pluggableWidgets/carousel-native/CHANGELOG.md @@ -8,8 +8,11 @@ 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-snap-carousel to 3.9.1. +- We replaced react-native-snap-carousel with a built-in carousel component, removing the dependency on it and on deprecated-react-native-prop-types. + +### Fixed + +- Carousel is now accessible: TalkBack and VoiceOver users can jump straight to a slide via its pagination dot, or step through slides one at a time; only the active slide is ever read out, instead of all of them at once. ## [3.1.0] - 2024-12-3 diff --git a/packages/pluggableWidgets/carousel-native/package.json b/packages/pluggableWidgets/carousel-native/package.json index 4a5cd167b..7b36f2ccf 100644 --- a/packages/pluggableWidgets/carousel-native/package.json +++ b/packages/pluggableWidgets/carousel-native/package.json @@ -1,7 +1,7 @@ { "name": "carousel-native", "widgetName": "Carousel", - "version": "3.3.0", + "version": "3.4.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -20,12 +20,9 @@ }, "dependencies": { "@mendix/piw-native-utils-internal": "*", - "@mendix/piw-utils-internal": "*", - "deprecated-react-native-prop-types": "4.2.3", - "react-native-snap-carousel": "3.9.1" + "@mendix/piw-utils-internal": "*" }, "devDependencies": { - "@mendix/pluggable-widgets-tools": "*", - "@types/react-native-snap-carousel": "^3.7.4" + "@mendix/pluggable-widgets-tools": "*" } } diff --git a/packages/pluggableWidgets/carousel-native/src/Carousel.tsx b/packages/pluggableWidgets/carousel-native/src/Carousel.tsx index 2f948311b..cb4f6f5a0 100644 --- a/packages/pluggableWidgets/carousel-native/src/Carousel.tsx +++ b/packages/pluggableWidgets/carousel-native/src/Carousel.tsx @@ -1,11 +1,24 @@ -import { Fragment, ReactElement, useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, LayoutChangeEvent, Platform, Text, View } from "react-native"; +import { Fragment, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + AccessibilityActionEvent, + ActivityIndicator, + Animated, + FlatList, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + Text, + View +} from "react-native"; import { CarouselProps } from "../typings/CarouselProps"; import { CarouselStyle, defaultCarouselStyle, LayoutStyle } from "./ui/styles"; -import { default as NativeCarousel, Pagination } from "react-native-snap-carousel"; +import { Pagination } from "./components/Pagination"; import deepmerge from "deepmerge"; import { ObjectItem, ValueStatus } from "mendix"; +// Minimum drag distance, in points, that steps to the neighbouring slide even when native snapping would not. +const SWIPE_THRESHOLD = 20; + export const Carousel = (props: CarouselProps): ReactElement => { const [sliderDimensions, setSliderDimensions] = useState({ slider: { width: 0, height: 0 }, @@ -19,9 +32,14 @@ export const Carousel = (props: CarouselProps): ReactElement => { const layoutSpecificStyle: LayoutStyle = props.layout === "fullWidth" ? styles.fullWidthLayout! : styles.cardLayout!; - const [carouselRef, setCarouselRef] = useState(undefined); + const listRef = useRef>(null); + // Drives the per-item scale/opacity below; native-driven, so it never touches the JS thread while scrolling. + const [scrollX] = useState(() => new Animated.Value(0)); - const [activeSlide, setActiveSlide] = useState(0); + const [activeSlideState, setActiveSlide] = useState(0); + // The datasource can shrink under the selected slide without any scroll event reporting it, so clamp here; + // otherwise no slide would match and every one would be hidden from screen readers. + const activeSlide = Math.min(activeSlideState, Math.max((props.contentSource.items?.length ?? 0) - 1, 0)); const [loading, setLoading] = useState(true); @@ -32,29 +50,174 @@ export const Carousel = (props: CarouselProps): ReactElement => { } }, [props.contentSource]); - const onSnap = useCallback((index: number) => { - setActiveSlide(index); - }, []); + const itemWidth = sliderDimensions.slide.width; + const sliderWidth = sliderDimensions.slider.width; + // Centering leaves equal space on both sides so the neighbouring cards peek in; "start" flushes the active + // card against the leading edge instead. Either way the trailing space is topped up so the last card can + // still reach the same resting position as every other one. + const sidePadding = props.activeSlideAlignment === "center" ? Math.max(0, (sliderWidth - itemWidth) / 2) : 0; + const endPadding = Math.max(0, sliderWidth - itemWidth - sidePadding); - const renderItem = useCallback(({ item, index }: { item: ObjectItem; index: number }) => { - let viewStyle = layoutSpecificStyle.slideItem; - if (viewStyle) { - // We don't want to pass the already processed width to the item container - // Create new object without width property instead of mutating - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { width, ...styleWithoutWidth } = viewStyle; - viewStyle = styleWithoutWidth; - } + const inactiveScale = layoutSpecificStyle.inactiveSlideItem?.scale ?? 1; + const inactiveOpacity = layoutSpecificStyle.inactiveSlideItem?.opacity ?? 1; - return ( - - {props.content.get(item)} - - ); + const onScroll = useMemo( + () => Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], { useNativeDriver: true }), + [scrollX] + ); + + // Reads the settled scroll position directly, rather than relying on onSnapToItem-style callbacks, so it + // works the same whether the list stopped on its own (onScrollEndDrag) or after momentum (onMomentumScrollEnd). + const updateActiveSlide = useCallback( + (event: NativeSyntheticEvent) => { + const itemCount = props.contentSource.items?.length; + if (itemWidth <= 0 || !itemCount) { + return; + } + const index = Math.min( + Math.max(Math.round(event.nativeEvent.contentOffset.x / itemWidth), 0), + itemCount - 1 + ); + setActiveSlide(previous => (previous === index ? previous : index)); + }, + [itemWidth, props.contentSource.items] + ); + + // Where the current drag started, so a short drag that native snapping would bounce back can still step a slide. + const dragStart = useRef({ offset: 0, index: 0 }); + + const onScrollBeginDrag = useCallback( + (event: NativeSyntheticEvent) => { + const offset = event.nativeEvent.contentOffset.x; + dragStart.current = { offset, index: itemWidth > 0 ? Math.round(offset / itemWidth) : 0 }; + }, + [itemWidth] + ); + + // snapToInterval only leaves the current slide once a drag passes half a card or flings with enough velocity, + // so a short, slow drag bounces back. The library we replaced moved a slide once a drag passed + // SWIPE_THRESHOLD instead (on iOS, where it ran without momentum), so we keep that behaviour on top. + const onScrollEndDrag = useCallback( + (event: NativeSyntheticEvent) => { + const itemCount = props.contentSource.items?.length; + if (itemWidth > 0 && itemCount) { + const offset = event.nativeEvent.contentOffset.x; + const delta = offset - dragStart.current.offset; + const startIndex = dragStart.current.index; + if (Math.round(offset / itemWidth) === startIndex && Math.abs(delta) > SWIPE_THRESHOLD) { + const next = Math.min(Math.max(startIndex + Math.sign(delta), 0), itemCount - 1); + if (next !== startIndex) { + listRef.current?.scrollToIndex({ index: next, animated: true }); + setActiveSlide(next); + return; + } + } + } + updateActiveSlide(event); + }, + [itemWidth, props.contentSource.items, updateActiveSlide] + ); + + const renderItem = useCallback( + ({ item, index }: { item: ObjectItem; index: number }) => { + // slideItem's own width is a percentage string ("70%"); unlike the library we replaced, nothing + // else sizes this cell on the scroll axis, so we must set it ourselves, from the pixel value onLayout + // already resolved that percentage to. + let viewStyle = layoutSpecificStyle.slideItem; + if (viewStyle) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { width, ...styleWithoutWidth } = viewStyle; + viewStyle = { ...styleWithoutWidth, width: itemWidth }; + } + + // Fades and shrinks this card as it moves a full item-width away from the active position in + // either direction, so the transition tracks the finger instead of jumping on snap. + const position = index * itemWidth; + const inputRange = [position - itemWidth, position, position + itemWidth]; + const animatedStyle = { + opacity: scrollX.interpolate({ + inputRange, + outputRange: [inactiveOpacity, 1, inactiveOpacity], + extrapolate: "clamp" as const + }), + transform: [ + { + scale: scrollX.interpolate({ + inputRange, + outputRange: [inactiveScale, 1, inactiveScale], + extrapolate: "clamp" as const + }) + } + ] + }; + + // Only the active card is reachable by a screen reader: TalkBack/VoiceOver can't perform the swipe + // gesture (it's reinterpreted as their own navigation), so cards off to the side are not actually + // reachable by the user and would otherwise read out ahead of or behind the one actually on screen. + const isActive = index === activeSlide; + + return ( + + {props.content.get(item)} + + ); + }, + [ + layoutSpecificStyle.slideItem, + itemWidth, + scrollX, + inactiveOpacity, + inactiveScale, + activeSlide, + props.content, + props.name + ] + ); + + const getItemLayout = useCallback( + (_data: ArrayLike | undefined, index: number) => ({ + length: itemWidth, + offset: index * itemWidth, + index + }), + [itemWidth] + ); + + const onDotPress = useCallback((index: number) => { + listRef.current?.scrollToIndex({ index, animated: true }); }, []); + // Steps one slide forward/backward on a TalkBack/VoiceOver increment or decrement gesture. The dots already + // let a screen reader jump straight to any slide by its own label, but the "N/M" counter shown once there + // are too many dots to be usable has no other way to move at all, so it needs this to be navigable. + const onPaginationAccessibilityAction = useCallback( + (event: AccessibilityActionEvent) => { + const itemCount = props.contentSource.items?.length; + if (!itemCount) { + return; + } + const { actionName } = event.nativeEvent; + const next = + actionName === "increment" + ? Math.min(activeSlide + 1, itemCount - 1) + : actionName === "decrement" + ? Math.max(activeSlide - 1, 0) + : undefined; + // Already at that end: skip the no-op scroll-to-self rather than replaying the same slide. + if (next !== undefined && next !== activeSlide) { + onDotPress(next); + } + }, + [activeSlide, onDotPress, props.contentSource.items] + ); + const renderPagination = useCallback(() => { - if (!props.showPagination || carouselRef === undefined) { + if (!props.showPagination) { return null; } @@ -62,12 +225,19 @@ export const Carousel = (props: CarouselProps): ReactElement => { const paginationOverflow = contentLength > 5; const { pagination } = layoutSpecificStyle; - const a11yProps = { accessibilityLabel: `${props.name}$pagination` }; - if (paginationOverflow) { return ( - - + + {activeSlide + 1}/{contentLength} @@ -84,6 +254,7 @@ export const Carousel = (props: CarouselProps): ReactElement => { return ( ): ReactElement => { inactiveDotColor={inActiveDotColor} inactiveDotOpacity={inActiveDotOpacity} inactiveDotScale={inActiveDotScale} - carouselRef={carouselRef} - tappableDots - {...a11yProps} + onDotPress={onDotPress} /> ); - }, [activeSlide, carouselRef, props.contentSource, props.showPagination]); + }, [activeSlide, onDotPress, props.contentSource, props.showPagination, props.name, layoutSpecificStyle]); const onLayout = (event: LayoutChangeEvent) => { let viewHeight = event.nativeEvent.layout.height; const viewWidth = event.nativeEvent.layout.width; - let itemWidth = 0; - let itemHeight = 0; + let newItemWidth = 0; + let newItemHeight = 0; if (layoutSpecificStyle.slideItem) { const { width: slideItemWidth, height: slideItemHeight } = layoutSpecificStyle.slideItem; @@ -115,25 +284,25 @@ export const Carousel = (props: CarouselProps): ReactElement => { if (typeof slideItemWidth === "string" && slideItemWidth.includes("%")) { const percentage = +slideItemWidth.replace("%", ""); - itemWidth = (viewWidth * percentage) / 100; + newItemWidth = (viewWidth * percentage) / 100; } else { - itemWidth = Number(slideItemWidth); + newItemWidth = Number(slideItemWidth); } if (typeof slideItemHeight === "string" && slideItemHeight.includes("%")) { const percentage = +slideItemHeight.replace("%", ""); - itemHeight = (viewWidth * percentage) / 100; + newItemHeight = (viewWidth * percentage) / 100; } else { - itemHeight = Number(slideItemHeight); + newItemHeight = Number(slideItemHeight); } - if (styles.container?.height === undefined && itemHeight > 0) { - viewHeight = itemHeight; + if (styles.container?.height === undefined && newItemHeight > 0) { + viewHeight = newItemHeight; } } setSliderDimensions({ slider: { width: viewWidth, height: viewHeight }, - slide: { width: itemWidth, height: itemHeight } + slide: { width: newItemWidth, height: newItemHeight } }); }; @@ -142,32 +311,31 @@ export const Carousel = (props: CarouselProps): ReactElement => { {loading ? ( ) : ( - /* library ignores width/height if its vertical/horizontal */ + // A horizontal FlatList needs an explicit width/height; it won't infer either from its parent. sliderDimensions.slide.width > 0 && sliderDimensions.slider.width > 0 && props.contentSource && props.contentSource.items && props.contentSource.items?.length > 0 && ( - + ref={listRef} testID={`${props.name}$carousel`} - activeSlideAlignment={props.activeSlideAlignment} - layout="default" - firstItem={0} - useScrollView - enableSnap + horizontal + showsHorizontalScrollIndicator={false} + style={{ width: sliderWidth, height: sliderDimensions.slider.height }} + contentContainerStyle={{ paddingLeft: sidePadding, paddingRight: endPadding }} data={props.contentSource.items} + keyExtractor={item => item.id} renderItem={renderItem} - sliderWidth={sliderDimensions.slider.width} - sliderHeight={sliderDimensions.slider.height} - itemWidth={sliderDimensions.slide.width} - itemHeight={sliderDimensions.slide.height} - inactiveSlideScale={layoutSpecificStyle.inactiveSlideItem?.scale} - inactiveSlideOpacity={layoutSpecificStyle.inactiveSlideItem?.opacity} - onSnapToItem={onSnap} - ref={(r: any) => setCarouselRef(r)} - enableMomentum={Platform.OS === "android"} + getItemLayout={getItemLayout} + snapToInterval={itemWidth} decelerationRate={0.9} + onScroll={onScroll} + scrollEventThrottle={16} + onMomentumScrollEnd={updateActiveSlide} + onScrollBeginDrag={onScrollBeginDrag} + onScrollEndDrag={onScrollEndDrag} /> {renderPagination()} diff --git a/packages/pluggableWidgets/carousel-native/src/__tests__/Carousel.spec.tsx b/packages/pluggableWidgets/carousel-native/src/__tests__/Carousel.spec.tsx index 3702096d1..192090f63 100644 --- a/packages/pluggableWidgets/carousel-native/src/__tests__/Carousel.spec.tsx +++ b/packages/pluggableWidgets/carousel-native/src/__tests__/Carousel.spec.tsx @@ -1,7 +1,7 @@ import { CarouselProps } from "../../typings/CarouselProps"; import { CarouselStyle, defaultCarouselStyle } from "../ui/styles"; -import { render } from "@testing-library/react-native"; -import { Text, View } from "react-native"; +import { fireEvent, render } from "@testing-library/react-native"; +import { FlatList, Text, View } from "react-native"; import { buildWidgetValue, ListValueBuilder } from "@mendix/piw-utils-internal"; import { Carousel } from "../Carousel"; @@ -24,29 +24,246 @@ describe("Carousel", () => { }; }); - /* TODO: enzyme or react-native-testing-lib still not allowing mocking states with hooks - Thus we can't snapshot test the actual carousel - */ + // Carousel.tsx renders nothing but a loading indicator until it has measured its own width via onLayout, so + // every test beyond "renders loading" needs to fire that event first, same as Gallery's tests do. + const fireContainerLayout = ( + carousel: ReturnType, + layout: { width: number; height: number } + ): void => { + fireEvent(carousel.getByTestId(defaultProps.name), "layout", { + nativeEvent: { layout: { x: 0, y: 0, ...layout } } + }); + }; it("renders loading", () => { expect(render().toJSON()).toMatchSnapshot(); }); - // it("renders without pagination", () => { - // expect(render().toJSON()).toMatchSnapshot(); - // }); - // - // it("renders full width", () => { - // expect(render().toJSON()).toMatchSnapshot(); - // }); - // - // it("renders numbered pagination if item size is more than 5", () => { - // const props = { - // ...defaultProps, - // contentSource: listValueBuilder.withAmountOfItems(6) - // }; - // expect(render().toJSON()).toMatchSnapshot(); - // }); + it("renders cards with dot pagination once it has measured its width", () => { + const carousel = render(); + fireContainerLayout(carousel, { width: 300, height: 150 }); + + expect(carousel.getByTestId("carousel$carousel")).toBeDefined(); + expect(carousel.getAllByRole("button")).toHaveLength(2); + expect(carousel.getByTestId("carousel$pagination")).toBeDefined(); + }); + + it("renders without pagination", () => { + const carousel = render(); + fireContainerLayout(carousel, { width: 300, height: 150 }); + + expect(carousel.queryByTestId("carousel$pagination")).toBeNull(); + }); + + it("renders full width", () => { + const carousel = render(); + fireContainerLayout(carousel, { width: 300, height: 150 }); + + expect(carousel.toJSON()).toMatchSnapshot(); + }); + + it("renders numbered pagination if item count is more than 5", () => { + const carousel = render(); + fireContainerLayout(carousel, { width: 300, height: 150 }); + + // The "N/M" text itself is hidden from screen readers (see the accessibility describe block below); the + // count still has to be shown somewhere, so it's asserted via the pagination container's own a11y label. + const pagination = carousel.getByTestId("carousel$pagination"); + expect(pagination.props.accessibilityLabel).toBe("Slide 1 of 6"); + expect(carousel.queryAllByRole("button")).toHaveLength(0); + }); + + it("tracks the active slide once the list settles on it", () => { + const carousel = render(); + fireContainerLayout(carousel, { width: 300, height: 150 }); + // Card layout sizes each item to 70% of the measured width. + const itemWidth = 300 * 0.7; + + fireEvent(carousel.getByTestId("carousel$carousel"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: itemWidth } } + }); + + expect(carousel.getByTestId("carousel$pagination").props.accessibilityLabel).toBe("Slide 2 of 6"); + }); + + it("scrolls to the slide behind a tapped dot", () => { + const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined); + const carousel = render(); + fireContainerLayout(carousel, { width: 300, height: 150 }); + + fireEvent.press(carousel.getAllByRole("button")[1]); + + expect(scrollToIndex).toHaveBeenCalledWith({ index: 1, animated: true }); + scrollToIndex.mockRestore(); + }); + + describe("short drags", () => { + // Card layout sizes each item to 70% of the measured width. + const itemWidth = 300 * 0.7; + + const drag = (carousel: ReturnType, from: number, to: number): void => { + const list = carousel.getByTestId("carousel$carousel"); + fireEvent(list, "scrollBeginDrag", { nativeEvent: { contentOffset: { x: from } } }); + fireEvent(list, "scrollEndDrag", { nativeEvent: { contentOffset: { x: to } } }); + }; + + it("steps to the neighbouring slide once a drag passes the threshold, even short of half a card", () => { + const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined); + const carousel = render( + + ); + fireContainerLayout(carousel, { width: 300, height: 150 }); + + drag(carousel, 0, 40); + expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, animated: true }); + expect(carousel.getByTestId("carousel$pagination").props.accessibilityLabel).toBe("Slide 2 of 6"); + + drag(carousel, itemWidth, itemWidth - 40); + expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 0, animated: true }); + expect(carousel.getByTestId("carousel$pagination").props.accessibilityLabel).toBe("Slide 1 of 6"); + scrollToIndex.mockRestore(); + }); + + it("leaves the slide alone for a drag under the threshold or past either end", () => { + const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined); + const carousel = render(); + fireContainerLayout(carousel, { width: 300, height: 150 }); + + drag(carousel, 0, 10); + drag(carousel, 0, -40); + drag(carousel, itemWidth, itemWidth + 40); + + expect(scrollToIndex).not.toHaveBeenCalled(); + scrollToIndex.mockRestore(); + }); + }); + + describe("accessibility", () => { + it("labels each dot by its slide number, with the active one marked selected", () => { + const carousel = render(); + fireContainerLayout(carousel, { width: 300, height: 150 }); + + const dots = carousel.getAllByRole("button"); + expect(dots.map(dot => dot.props.accessibilityLabel)).toEqual(["Go to slide 1", "Go to slide 2"]); + expect(dots[0].props.accessibilityState).toEqual({ selected: true }); + expect(dots[1].props.accessibilityState).toEqual({ selected: false }); + }); + + it("hides every slide but the active one from screen readers", () => { + const carousel = render(); + fireContainerLayout(carousel, { width: 300, height: 150 }); + + // Hidden elements are excluded by default - as a screen reader would skip them too - so finding the + // (intentionally) hidden one back has to opt in. + const active = carousel.getByTestId("carousel$content$0"); + const inactive = carousel.getByTestId("carousel$content$1", { includeHiddenElements: true }); + expect(active.props.accessibilityElementsHidden).toBe(false); + expect(active.props.importantForAccessibility).toBe("auto"); + expect(inactive.props.accessibilityElementsHidden).toBe(true); + expect(inactive.props.importantForAccessibility).toBe("no-hide-descendants"); + }); + + it("moves the hidden-from-screen-readers slide once the active one changes", () => { + const carousel = render( + + ); + fireContainerLayout(carousel, { width: 300, height: 150 }); + const itemWidth = 300 * 0.7; + + fireEvent(carousel.getByTestId("carousel$carousel"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: itemWidth } } + }); + + expect( + carousel.getByTestId("carousel$content$0", { includeHiddenElements: true }).props + .accessibilityElementsHidden + ).toBe(true); + expect(carousel.getByTestId("carousel$content$1").props.accessibilityElementsHidden).toBe(false); + }); + + it("keeps the last slide reachable when the datasource shrinks under the active one", () => { + const carousel = render( + + ); + fireContainerLayout(carousel, { width: 300, height: 150 }); + const itemWidth = 300 * 0.7; + + fireEvent(carousel.getByTestId("carousel$carousel"), "momentumScrollEnd", { + nativeEvent: { contentOffset: { x: 4 * itemWidth } } + }); + carousel.rerender(); + + expect(carousel.getByTestId("carousel$content$1").props.accessibilityElementsHidden).toBe(false); + const dots = carousel.getAllByRole("button"); + expect(dots[1].props.accessibilityState).toEqual({ selected: true }); + }); + + it("steps forward and backward on a TalkBack/VoiceOver increment or decrement action", () => { + const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined); + const carousel = render( + + ); + fireContainerLayout(carousel, { width: 300, height: 150 }); + const itemWidth = 300 * 0.7; + const list = carousel.getByTestId("carousel$carousel"); + // scrollToIndex is mocked, so nothing actually moves the list; each step settles it manually at + // the index the previous action asked for, same as a real scroll settling before the next gesture. + const step = (actionName: "increment" | "decrement"): void => { + const callsBefore = scrollToIndex.mock.calls.length; + fireEvent(carousel.getByTestId("carousel$pagination"), "accessibilityAction", { + nativeEvent: { actionName } + }); + // Already at that end: the action was a no-op, so there is nothing to settle. + if (scrollToIndex.mock.calls.length === callsBefore) { + return; + } + const index = scrollToIndex.mock.lastCall![0].index as number; + fireEvent(list, "momentumScrollEnd", { nativeEvent: { contentOffset: { x: index * itemWidth } } }); + }; + + step("increment"); + expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, animated: true }); + + step("decrement"); + expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 0, animated: true }); + + scrollToIndex.mockRestore(); + }); + + it("does not step past either end of the carousel", () => { + const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined); + const carousel = render( + + ); + fireContainerLayout(carousel, { width: 300, height: 150 }); + const itemWidth = 300 * 0.7; + const list = carousel.getByTestId("carousel$carousel"); + const step = (actionName: "increment" | "decrement"): void => { + const callsBefore = scrollToIndex.mock.calls.length; + fireEvent(carousel.getByTestId("carousel$pagination"), "accessibilityAction", { + nativeEvent: { actionName } + }); + // Already at that end: the action was a no-op, so there is nothing to settle. + if (scrollToIndex.mock.calls.length === callsBefore) { + return; + } + const index = scrollToIndex.mock.lastCall![0].index as number; + fireEvent(list, "momentumScrollEnd", { nativeEvent: { contentOffset: { x: index * itemWidth } } }); + }; + + // Already on the first slide: decrementing has nowhere to go, so the no-op scroll is skipped. + step("decrement"); + expect(scrollToIndex).not.toHaveBeenCalled(); + + // Incrementing past the last slide (index 5 of 6) stays put rather than running off the end. + for (let i = 0; i < 6; i++) { + step("increment"); + } + expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 5, animated: true }); + + scrollToIndex.mockRestore(); + }); + }); it("preserves expected style key set", () => { const keys = Object.keys(defaultCarouselStyle).sort(); diff --git a/packages/pluggableWidgets/carousel-native/src/__tests__/__snapshots__/Carousel.spec.tsx.snap b/packages/pluggableWidgets/carousel-native/src/__tests__/__snapshots__/Carousel.spec.tsx.snap index c69df4b5c..f7da2c45e 100644 --- a/packages/pluggableWidgets/carousel-native/src/__tests__/__snapshots__/Carousel.spec.tsx.snap +++ b/packages/pluggableWidgets/carousel-native/src/__tests__/__snapshots__/Carousel.spec.tsx.snap @@ -1,5 +1,290 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`Carousel renders full width 1`] = ` + + + + + + + + MyContent + + + + + + + + + MyContent + + + + + + + + + + + + + + + +`; + exports[`Carousel renders loading 1`] = ` void; + testID?: string; +} + +/** A row of tappable dots indicating which slide of the carousel is active. */ +export function Pagination(props: PaginationProps): ReactElement { + const { + dotColor = DEFAULT_DOT_COLOR, + inactiveDotColor = dotColor, + inactiveDotOpacity = DEFAULT_INACTIVE_OPACITY, + inactiveDotScale = DEFAULT_INACTIVE_SCALE + } = props; + + return ( + + {Array.from({ length: props.dotsLength }, (_, index) => { + const active = index === props.activeDotIndex; + return ( + props.onDotPress(index)} + style={[styles.dotContainer, props.dotContainerStyle]} + accessibilityRole="button" + accessibilityLabel={`Go to slide ${index + 1}`} + accessibilityState={{ selected: active }} + > + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center" + }, + dotContainer: { + alignItems: "center", + justifyContent: "center" + }, + dot: { + width: DOT_SIZE, + height: DOT_SIZE, + borderRadius: DOT_SIZE / 2 + } +}); diff --git a/packages/pluggableWidgets/carousel-native/src/package.xml b/packages/pluggableWidgets/carousel-native/src/package.xml index 8a7b5e824..17a179b16 100644 --- a/packages/pluggableWidgets/carousel-native/src/package.xml +++ b/packages/pluggableWidgets/carousel-native/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/carousel-native/src/ui/styles.ts b/packages/pluggableWidgets/carousel-native/src/ui/styles.ts index f957e52af..4d9172a20 100644 --- a/packages/pluggableWidgets/carousel-native/src/ui/styles.ts +++ b/packages/pluggableWidgets/carousel-native/src/ui/styles.ts @@ -65,6 +65,11 @@ export const defaultCarouselStyle: CarouselStyle = { slideItem: { width: "70%", height: "100%" + }, + // Dims and shrinks the cards on either side of the active one, so it stands out against its neighbours. + inactiveSlideItem: { + opacity: 0.7, + scale: 0.9 } }, fullWidthLayout: { diff --git a/patches/react-native-snap-carousel+3.9.1.patch b/patches/react-native-snap-carousel+3.9.1.patch deleted file mode 100644 index 4e523aece..000000000 --- a/patches/react-native-snap-carousel+3.9.1.patch +++ /dev/null @@ -1,50 +0,0 @@ -diff --git a/src/carousel/Carousel.js b/src/carousel/Carousel.js -index dae71a3..4dd36a2 100644 ---- a/src/carousel/Carousel.js -+++ b/src/carousel/Carousel.js -@@ -1,5 +1,6 @@ - import React, { Component } from 'react'; --import { Animated, Easing, FlatList, I18nManager, Platform, ScrollView, View, ViewPropTypes } from 'react-native'; -+import { Animated, Easing, FlatList, I18nManager, Platform, ScrollView, View } from 'react-native'; -+import { ViewPropTypes } from 'deprecated-react-native-prop-types'; - import PropTypes from 'prop-types'; - import shallowCompare from 'react-addons-shallow-compare'; - import { -diff --git a/src/pagination/Pagination.js b/src/pagination/Pagination.js -index 5c021cf..d300dce 100644 ---- a/src/pagination/Pagination.js -+++ b/src/pagination/Pagination.js -@@ -1,5 +1,6 @@ - import React, { PureComponent } from 'react'; --import { I18nManager, Platform, View, ViewPropTypes } from 'react-native'; -+import { I18nManager, Platform, View } from 'react-native'; -+import { ViewPropTypes } from 'deprecated-react-native-prop-types'; - import PropTypes from 'prop-types'; - import PaginationDot from './PaginationDot'; - import styles from './Pagination.style'; -diff --git a/src/pagination/PaginationDot.js b/src/pagination/PaginationDot.js -index e59d196..d2c8dcc 100644 ---- a/src/pagination/PaginationDot.js -+++ b/src/pagination/PaginationDot.js -@@ -1,5 +1,6 @@ - import React, { PureComponent } from 'react'; --import { View, Animated, Easing, TouchableOpacity, ViewPropTypes } from 'react-native'; -+import { View, Animated, Easing, TouchableOpacity } from 'react-native'; -+import { ViewPropTypes } from 'deprecated-react-native-prop-types'; - import PropTypes from 'prop-types'; - import styles from './Pagination.style'; - -diff --git a/src/parallaximage/ParallaxImage.js b/src/parallaximage/ParallaxImage.js -index 8bc774a..d6d9de3 100644 ---- a/src/parallaximage/ParallaxImage.js -+++ b/src/parallaximage/ParallaxImage.js -@@ -1,7 +1,8 @@ - // Parallax effect inspired by https://github.com/oblador/react-native-parallax/ - - import React, { Component } from 'react'; --import { View, ViewPropTypes, Image, Animated, Easing, ActivityIndicator, findNodeHandle } from 'react-native'; -+import { View, Image, Animated, Easing, ActivityIndicator, findNodeHandle } from 'react-native'; -+import { ViewPropTypes } from 'deprecated-react-native-prop-types'; - import PropTypes from 'prop-types'; - import styles from './ParallaxImage.style'; - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b310e2ab..dc835a1cb 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-snap-carousel@3.9.1: - hash: 3a4c1b0f17629becc5d99ef1958df5a2f55c46e45f45670862aaeda12ab2f4e1 - path: patches/react-native-snap-carousel+3.9.1.patch importers: @@ -401,19 +398,10 @@ 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-snap-carousel: - specifier: 3.9.1 - version: 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) devDependencies: '@mendix/pluggable-widgets-tools': specifier: 11.12.0 version: 11.12.0(patch_hash=6c126ccd843c0d2171a692235aff82990be64a7edbd70905cc90482bfd5c4389)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@types/babel__core@7.20.5)(@types/node@20.19.41)(jest-util@30.4.1)(jiti@2.6.1)(picomatch@4.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(tslib@2.8.1) - '@types/react-native-snap-carousel': - specifier: ^3.7.4 - version: 3.8.12(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)) packages/pluggableWidgets/color-picker-native: dependencies: @@ -2415,9 +2403,6 @@ packages: resolution: {integrity: sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/normalize-colors@0.72.0': - resolution: {integrity: sha512-285lfdqSXaqKuBbbtP9qL2tDrfxdOFtIMvkKadtleRQkdOxx+uzGvFr82KHmc/sSiMtfXGp7JnFYWVh4sFl7Yw==} - '@react-native/normalize-colors@0.84.1': resolution: {integrity: sha512-/UPaQ4jl95soXnLDEJ6Cs6lnRXhwbxtT4KbZz+AFDees7prMV2NOLcHfCnzmTabf5Y3oxENMVBL666n4GMLcTA==} @@ -2994,11 +2979,6 @@ packages: resolution: {integrity: sha512-Synwj+cdwhHOi9SFNmu7voLv3AQM24e3Hy78Qz7nNb6spkftkv0gtaZlIWMJ5G0pywcs08JFX/cve8vCWUX6xw==} deprecated: This is a stub types definition for react-native-modal (https://github.com/react-native-community/react-native-modal). react-native-modal provides its own type definitions, so you don't need @types/react-native-modal installed! - '@types/react-native-snap-carousel@3.8.12': - resolution: {integrity: sha512-J840OqAvfeZpNgBO9dDyJi8rUgC6UzMRBhfPSkqV4EpkQsBXB8EYgRNCQmT282yGO6HLywKql1Wijvuuf9Zx8w==} - peerDependencies: - react-native: 0.84.1 - '@types/react-native-star-rating@1.1.6': resolution: {integrity: sha512-h07PH07Oqc2E8Xy272Svpj9lEibqfsTFYVUelY6hKMIgDedeuq/bdeDnc/02lMUUfOMalYDbLFcNRBfeMr2S0Q==} @@ -3748,10 +3728,6 @@ packages: core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} - core-js@1.2.7: - resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} - deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. - core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} @@ -3995,9 +3971,6 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - deprecated-react-native-prop-types@4.2.3: - resolution: {integrity: sha512-2rLTiMKidIFFYpIVM69UnQKngLqQfL6I11Ch8wGSBftS18FUXda+o2we2950X+1dmbgps28niI3qwyH4eX3Z1g==} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4363,9 +4336,6 @@ packages: fbjs-css-vars@1.0.2: resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} - fbjs@0.8.18: - resolution: {integrity: sha512-EQaWFK+fEPSoibjNy8IxUtaFOMXcWsY0JaVrQoZR9zC8N2Ygf9iDITPWjUTVIax95b6I742JFLqASHfsag/vKA==} - fbjs@3.0.4: resolution: {integrity: sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ==} @@ -4993,9 +4963,6 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} - isomorphic-fetch@2.2.1: - resolution: {integrity: sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA==} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5869,9 +5836,6 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} - node-fetch@1.7.3: - resolution: {integrity: sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==} - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -6454,9 +6418,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - react-addons-shallow-compare@15.6.2: - resolution: {integrity: sha512-yAV9tOObmKPiohqne1jiMcx6kDjfz7GeL8K9KHgI+HvDsbrRv148uyUzrPc6GwepZnQcJ59Q3lp1ghrkyPwtjg==} - react-devtools-core@6.1.5: resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} @@ -6643,12 +6604,6 @@ packages: peerDependencies: react-native-webview: ^11.0.3 - react-native-snap-carousel@3.9.1: - resolution: {integrity: sha512-xWEGusacIgK1YaDXLi7Gao2+ISLoGPVEBR8fcMf4tOOJQufutlNwkoLu0l6B8Qgsrre0nTxoVZikRgGRDWlLaQ==} - peerDependencies: - react: 19.2.3 - react-native: 0.84.1 - react-native-sound@0.13.0: resolution: {integrity: sha512-SnREzaV0fmpYNuDV1Y8M7FutmaYei0pKBgpldULKKJMkoA3DBv5ppyRxY+oxRQ7HwEpt6LsonrKgM+13GH/tCw==} peerDependencies: @@ -9984,8 +9939,6 @@ snapshots: - supports-color - utf-8-validate - '@react-native/normalize-colors@0.72.0': {} - '@react-native/normalize-colors@0.84.1': {} '@react-native/virtualized-lists@0.84.1(@types/react@19.2.16)(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)': @@ -10511,11 +10464,6 @@ snapshots: - react - react-native - '@types/react-native-snap-carousel@3.8.12(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: - '@types/react': 19.2.16 - 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) - '@types/react-native-star-rating@1.1.6(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react@19.2.3)': dependencies: '@types/react': 19.2.16 @@ -11351,8 +11299,6 @@ snapshots: dependencies: browserslist: 4.28.2 - core-js@1.2.7: {} - core-js@3.49.0: {} cosmiconfig-typescript-loader@6.3.0(@types/node@20.19.41)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): @@ -11611,12 +11557,6 @@ snapshots: depd@2.0.0: {} - deprecated-react-native-prop-types@4.2.3: - dependencies: - '@react-native/normalize-colors': 0.72.0 - invariant: 2.2.4 - prop-types: 15.8.1 - dequal@2.0.3: {} destroy@1.2.0: {} @@ -11687,6 +11627,7 @@ snapshots: encoding@0.1.13: dependencies: iconv-lite: 0.6.3 + optional: true end-of-stream@1.4.5: dependencies: @@ -12076,16 +12017,6 @@ snapshots: fbjs-css-vars@1.0.2: {} - fbjs@0.8.18: - dependencies: - core-js: 1.2.7 - isomorphic-fetch: 2.2.1 - loose-envify: 1.4.0 - object-assign: 4.1.1 - promise: 7.3.1 - setimmediate: 1.0.5 - ua-parser-js: 0.7.41 - fbjs@3.0.4(encoding@0.1.13): dependencies: cross-fetch: 3.2.0(encoding@0.1.13) @@ -12717,11 +12648,6 @@ snapshots: isobject@3.0.1: {} - isomorphic-fetch@2.2.1: - dependencies: - node-fetch: 1.7.3 - whatwg-fetch: 3.6.20 - istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1: @@ -14130,11 +14056,6 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 - node-fetch@1.7.3: - dependencies: - encoding: 0.1.13 - is-stream: 1.1.0 - node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -14693,11 +14614,6 @@ snapshots: range-parser@1.2.1: {} - react-addons-shallow-compare@15.6.2: - dependencies: - fbjs: 0.8.18 - object-assign: 4.1.1 - react-devtools-core@6.1.5: dependencies: shell-quote: 1.8.4 @@ -14861,13 +14777,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-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 - react: 19.2.3 - react-addons-shallow-compare: 15.6.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) - react-native-sound@0.13.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