diff --git a/package-lock.json b/package-lock.json index 62dd9da..bd3dd02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9598,7 +9598,7 @@ "typescript": "^5.4.2" }, "peerDependencies": { - "@microbit/ui": "^0.4.0", + "@microbit/ui": "^0.5.0", "@pandacss/dev": "^1.11.4", "react": "^18.3.1", "react-aria": "^3.50.0", @@ -9634,7 +9634,7 @@ "vitest": "^4.1.10" }, "peerDependencies": { - "@microbit/ui": "^0.4.0", + "@microbit/ui": "^0.5.0", "@pandacss/dev": "^1.11.4", "react": "^18.3.1", "react-icons": "^4.12.0 || ^5.0.0", diff --git a/packages/ui-carousel/src/SwiperCarousel.tsx b/packages/ui-carousel/src/SwiperCarousel.tsx index 46f5627..7eccc6c 100644 --- a/packages/ui-carousel/src/SwiperCarousel.tsx +++ b/packages/ui-carousel/src/SwiperCarousel.tsx @@ -48,6 +48,21 @@ const SwiperCarousel = ({ const { direction } = useLocale(); const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const swiperRef = useRef(); + const pointerModality = useRef(false); + useEffect(() => { + const onPointerDown = () => { + pointerModality.current = true; + }; + const onKeyDown = () => { + pointerModality.current = false; + }; + window.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKeyDown, true); + return () => { + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKeyDown, true); + }; + }, []); const handleSlideFocus = useCallback( (e: React.FocusEvent) => { const swiper = swiperRef.current; @@ -57,7 +72,11 @@ const SwiperCarousel = ({ // overflow-hidden container desyncs the view. swiper.el.scrollLeft = 0; swiper.slides.forEach((slide, i) => { - if (slide.contains(e.target)) { + if ( + slide.contains(e.target) && + !pointerModality.current && + !slide.classList.contains("swiper-slide-fully-visible") + ) { swiper.activeIndex = i; swiper.updateSlidesClasses(); swiper.slideTo(i); diff --git a/packages/ui-carousel/stories/Carousel.stories.tsx b/packages/ui-carousel/stories/Carousel.stories.tsx index 1e841e4..39db33c 100644 --- a/packages/ui-carousel/stories/Carousel.stories.tsx +++ b/packages/ui-carousel/stories/Carousel.stories.tsx @@ -5,7 +5,7 @@ */ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Carousel } from "../src"; -import { exampleCards, linkCards } from "./cards"; +import { exampleCards, linkCards, menuCards } from "./cards"; const meta = { title: "Carousel/Carousel", @@ -42,6 +42,20 @@ export const LinkCards: Story = { }, }; +/** + * Cards with a "…" actions menu, as the apps' project cards have. The menu + * trigger focuses itself on press, driving focus-follows-slide: opening a + * wholly visible card's menu (or tabbing to it) must not move the carousel. + * Most visible at narrow widths, where the carousel can be at a position + * that isn't the focused card's own snap point. + */ +export const MenuCards: Story = { + args: { + carouselItems: menuCards(12), + containerLabel: "Example menu cards", + }, +}; + /** Buttons off, e.g. for touch-only native platforms. */ export const NoNavigation: Story = { args: { diff --git a/packages/ui-carousel/stories/cards.tsx b/packages/ui-carousel/stories/cards.tsx index 446ec94..a8af563 100644 --- a/packages/ui-carousel/stories/cards.tsx +++ b/packages/ui-carousel/stories/cards.tsx @@ -3,7 +3,17 @@ * * SPDX-License-Identifier: MIT */ -import { css, cx, LinkBox, LinkOverlay, LinkOverlayButton } from "@microbit/ui"; +import { + css, + cx, + IconButton, + LinkBox, + LinkOverlay, + LinkOverlayButton, + MenuItem, + MenuList, + MenuTrigger, +} from "@microbit/ui"; import { ReactElement } from "react"; /** @@ -70,6 +80,60 @@ export const exampleCards = (count: number): ReactElement[] => )); +/** + * Cards with a "…" actions menu in the corner, like the apps' project cards. + * React-aria menu triggers focus themselves on press, so these exercise + * focus-follows-slide staying put: opening a wholly visible card's menu must + * not move the carousel. + */ +export const menuCards = (count: number): ReactElement[] => + Array.from({ length: count }, (_, i) => ( + +
+ + + ⋮ + + + alert(`open ${i + 1}`)}>Open + alert(`delete ${i + 1}`)}>Delete + + +
+

+ + Card {i + 1} + +

+

+ {descriptions[i % descriptions.length]} +

+
+ + )); + const tintFills = ["#bee3f8", "#b2f5ea", "#e9d8fd", "#feebcb"]; const tintImageSrc = (i: number): string =>