From 04d39aac7993154dfac472741705b1e51ae620ae Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Mon, 14 Sep 2026 16:55:08 +0100 Subject: [PATCH 1/3] ui-carousel: Fix unnecessary scroll of carousel items into view --- packages/ui-carousel/package.json | 1 + packages/ui-carousel/src/SwiperCarousel.tsx | 6 +- .../stories/ProjectCards.stories.tsx | 75 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 packages/ui-carousel/stories/ProjectCards.stories.tsx diff --git a/packages/ui-carousel/package.json b/packages/ui-carousel/package.json index 576fa0e..e8aaf53 100644 --- a/packages/ui-carousel/package.json +++ b/packages/ui-carousel/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@microbit/ui": "*", + "@microbit/ui-patterns": "*", "@pandacss/dev": "^1.11.4", "@storybook/react-vite": "^10.5.2", "@types/react": "^18.3.3", diff --git a/packages/ui-carousel/src/SwiperCarousel.tsx b/packages/ui-carousel/src/SwiperCarousel.tsx index 46f5627..abfcafe 100644 --- a/packages/ui-carousel/src/SwiperCarousel.tsx +++ b/packages/ui-carousel/src/SwiperCarousel.tsx @@ -57,7 +57,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) && + // Only scroll the card into view if it is not already fully visible. + !slide.classList.contains("swiper-slide-fully-visible") + ) { swiper.activeIndex = i; swiper.updateSlidesClasses(); swiper.slideTo(i); diff --git a/packages/ui-carousel/stories/ProjectCards.stories.tsx b/packages/ui-carousel/stories/ProjectCards.stories.tsx new file mode 100644 index 0000000..32b871e --- /dev/null +++ b/packages/ui-carousel/stories/ProjectCards.stories.tsx @@ -0,0 +1,75 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Box, css } from "@microbit/ui"; +import { ProjectCard } from "@microbit/ui-patterns"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ReactElement } from "react"; +import { Carousel } from "../src"; + +const meta = { + title: "Carousel/Project cards", + component: Carousel, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Stands in for an app's logo or glyph, as in the ProjectCard stories. */ +const Glyph = () => ( + + + +); + +const names = [ + "Heartbeat monitor", + "Radio messenger", + "Step counter", + "Night light", + "Compass", + "Emotion badge", +]; + +const projectCards = (count: number): ReactElement[] => + Array.from({ length: count }, (_, i) => ( + + alert(`open ${id}`)} + onRename={(id) => alert(`rename ${id}`)} + onDuplicate={(id) => alert(`duplicate ${id}`)} + onDelete={(id) => alert(`delete ${id}`)} + > + + + + )); + +/** + * ProjectCards in the standard carousel, as an app home page lays out recent + * projects. Unlike the example cards, whose only interaction navigates away, + * each card's "…" menu opens an overlay in place — so any carousel movement + * on click is visible under the open menu. + * + * Repro for the menu-click scroll: at a width where the prev/next buttons + * show (≥768px, with more cards than fit), click the "…" menu on any card + * past the current snap group — the carousel animates away while the menu + * opens. + */ +export const ProjectCards: Story = { + args: { + carouselItems: projectCards(12), + containerLabel: "My projects", + }, +}; From 852cf18129c870bdeb47bd340709165fe6935c69 Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Mon, 14 Sep 2026 17:32:27 +0100 Subject: [PATCH 2/3] Use existing cards with menu buttons; drop ui-patterns dep --- package-lock.json | 4 +- packages/ui-carousel/package.json | 1 - .../ui-carousel/stories/Carousel.stories.tsx | 16 +++- .../stories/ProjectCards.stories.tsx | 75 ------------------- packages/ui-carousel/stories/cards.tsx | 66 +++++++++++++++- 5 files changed, 82 insertions(+), 80 deletions(-) delete mode 100644 packages/ui-carousel/stories/ProjectCards.stories.tsx 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/package.json b/packages/ui-carousel/package.json index e8aaf53..576fa0e 100644 --- a/packages/ui-carousel/package.json +++ b/packages/ui-carousel/package.json @@ -40,7 +40,6 @@ }, "devDependencies": { "@microbit/ui": "*", - "@microbit/ui-patterns": "*", "@pandacss/dev": "^1.11.4", "@storybook/react-vite": "^10.5.2", "@types/react": "^18.3.3", 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/ProjectCards.stories.tsx b/packages/ui-carousel/stories/ProjectCards.stories.tsx deleted file mode 100644 index 32b871e..0000000 --- a/packages/ui-carousel/stories/ProjectCards.stories.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/** - * (c) 2026, Micro:bit Educational Foundation and contributors - * - * SPDX-License-Identifier: MIT - */ -import { Box, css } from "@microbit/ui"; -import { ProjectCard } from "@microbit/ui-patterns"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ReactElement } from "react"; -import { Carousel } from "../src"; - -const meta = { - title: "Carousel/Project cards", - component: Carousel, -} satisfies Meta; -export default meta; - -type Story = StoryObj; - -/** Stands in for an app's logo or glyph, as in the ProjectCard stories. */ -const Glyph = () => ( - - - -); - -const names = [ - "Heartbeat monitor", - "Radio messenger", - "Step counter", - "Night light", - "Compass", - "Emotion badge", -]; - -const projectCards = (count: number): ReactElement[] => - Array.from({ length: count }, (_, i) => ( - - alert(`open ${id}`)} - onRename={(id) => alert(`rename ${id}`)} - onDuplicate={(id) => alert(`duplicate ${id}`)} - onDelete={(id) => alert(`delete ${id}`)} - > - - - - )); - -/** - * ProjectCards in the standard carousel, as an app home page lays out recent - * projects. Unlike the example cards, whose only interaction navigates away, - * each card's "…" menu opens an overlay in place — so any carousel movement - * on click is visible under the open menu. - * - * Repro for the menu-click scroll: at a width where the prev/next buttons - * show (≥768px, with more cards than fit), click the "…" menu on any card - * past the current snap group — the carousel animates away while the menu - * opens. - */ -export const ProjectCards: Story = { - args: { - carouselItems: projectCards(12), - containerLabel: "My projects", - }, -}; 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 => From 5882e73e78131beebbbe6db88a7afc330c8f870e Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Tue, 15 Sep 2026 12:07:23 +0100 Subject: [PATCH 3/3] ui-carousel: Don't slide on pointer-initiated focus Pressing a visible card control (e.g. the project menu button) focuses it, which moved the carousel if the card overlapped Swiper's content box by even a pixel. Only keyboard focus now slides a card into view. --- packages/ui-carousel/src/SwiperCarousel.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/ui-carousel/src/SwiperCarousel.tsx b/packages/ui-carousel/src/SwiperCarousel.tsx index abfcafe..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; @@ -59,7 +74,7 @@ const SwiperCarousel = ({ swiper.slides.forEach((slide, i) => { if ( slide.contains(e.target) && - // Only scroll the card into view if it is not already fully visible. + !pointerModality.current && !slide.classList.contains("swiper-slide-fully-visible") ) { swiper.activeIndex = i;