diff --git a/.changelog/5968-fix-carousel-without-react-film.md b/.changelog/5968-fix-carousel-without-react-film.md new file mode 100644 index 0000000000..c24daf9073 --- /dev/null +++ b/.changelog/5968-fix-carousel-without-react-film.md @@ -0,0 +1,3 @@ +### Changed + +- attachment carousel no longer uses `react-film`, related to [#5552](https://github.com/microsoft/BotFramework-WebChat/issues/5552), in PR [#5968](https://github.com/microsoft/BotFramework-WebChat/pull/5968), by [@OEvgeny](https://github.com/OEvgeny) diff --git a/__tests__/html2/carousel/autoCenter.html b/__tests__/html2/carousel/autoCenter.html new file mode 100644 index 0000000000..64b32643e3 --- /dev/null +++ b/__tests__/html2/carousel/autoCenter.html @@ -0,0 +1,97 @@ + + +
+ + + + + + + + diff --git a/__tests__/html2/carousel/autoCenter.html.snap-1.png b/__tests__/html2/carousel/autoCenter.html.snap-1.png new file mode 100644 index 0000000000..d36b43f83c Binary files /dev/null and b/__tests__/html2/carousel/autoCenter.html.snap-1.png differ diff --git a/__tests__/html2/carousel/flipperButton.focusVisible.html b/__tests__/html2/carousel/flipperButton.focusVisible.html index 3b4342239f..758e3d1bd4 100644 --- a/__tests__/html2/carousel/flipperButton.focusVisible.html +++ b/__tests__/html2/carousel/flipperButton.focusVisible.html @@ -69,7 +69,7 @@ const getFocusIndicatorStyle = flipper => { const { boxShadow, outlineColor, outlineOffset, outlineStyle, outlineWidth } = getComputedStyle( - flipper.querySelector('.react-film__flipper__body') + flipper.querySelector('.carousel-flipper__body') ); return { boxShadow, outlineColor, outlineOffset, outlineStyle, outlineWidth }; @@ -77,7 +77,7 @@ // The flipper is shown when its body has slid into the center of the flipper button. const getFlipperBodyOffset = flipper => { - const bodyRect = flipper.querySelector('.react-film__flipper__body').getBoundingClientRect(); + const bodyRect = flipper.querySelector('.carousel-flipper__body').getBoundingClientRect(); const buttonRect = flipper.getBoundingClientRect(); return Math.round(bodyRect.left + bodyRect.width / 2 - (buttonRect.left + buttonRect.width / 2)); @@ -96,7 +96,7 @@ // GIVEN: The carousel is scrolled to the start, the "Previous" flipper is hidden. expect(document.querySelector('.carousel-filmstrip').scrollLeft).toBe(0); - expect(previousFlipper.querySelector('.react-film__main__slider--hide')).toBeTruthy(); + expect(previousFlipper.querySelector('.carousel-flipper__slider--at-edge')).toBeTruthy(); // WHEN: Focus on the first attachment of the carousel via keyboard. pageObjects.focusTranscript(); @@ -105,7 +105,7 @@ // THEN: The first attachment should have the focus, both flippers should stay hidden. await waitFor(() => expect(document.activeElement).toBe(pageElements.activityAttachments()[0][0])); expect(getFlipperBodyOffset(nextFlipper)).toBe(50); - expect(previousFlipper.querySelector('.react-film__main__slider--hide')).toBeTruthy(); + expect(previousFlipper.querySelector('.carousel-flipper__slider--at-edge')).toBeTruthy(); // THEN: The first card shows the focus indicator, no flipper is shown. await host.snapshot('local'); diff --git a/__tests__/html2/carousel/flipperButton.resize.html b/__tests__/html2/carousel/flipperButton.resize.html new file mode 100644 index 0000000000..c9b51bdb06 --- /dev/null +++ b/__tests__/html2/carousel/flipperButton.resize.html @@ -0,0 +1,98 @@ + + + + + + + + + + + diff --git a/__tests__/html2/carousel/flipperButton.resize.html.snap-1.png b/__tests__/html2/carousel/flipperButton.resize.html.snap-1.png new file mode 100644 index 0000000000..aaffb3cdd8 Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.resize.html.snap-1.png differ diff --git a/__tests__/html2/carousel/flipperButton.resize.html.snap-2.png b/__tests__/html2/carousel/flipperButton.resize.html.snap-2.png new file mode 100644 index 0000000000..0933cf01af Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.resize.html.snap-2.png differ diff --git a/__tests__/html2/carousel/flipperButton.resize.html.snap-3.png b/__tests__/html2/carousel/flipperButton.resize.html.snap-3.png new file mode 100644 index 0000000000..d48dba437f Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.resize.html.snap-3.png differ diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.html b/__tests__/html2/carousel/flipperButton.tabOrder.html new file mode 100644 index 0000000000..59e89e433b --- /dev/null +++ b/__tests__/html2/carousel/flipperButton.tabOrder.html @@ -0,0 +1,109 @@ + + + + + + + + + + + diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-1.png b/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-1.png new file mode 100644 index 0000000000..597a0bef3c Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-1.png differ diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-2.png b/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-2.png new file mode 100644 index 0000000000..00d51e81f3 Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-2.png differ diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-3.png b/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-3.png new file mode 100644 index 0000000000..d7ae6fd84f Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-3.png differ diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-4.png b/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-4.png new file mode 100644 index 0000000000..463adb9d78 Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.tabOrder.html.snap-4.png differ diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html new file mode 100644 index 0000000000..2ae1b1f19e --- /dev/null +++ b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html @@ -0,0 +1,9 @@ + + + + + + + diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-1.png b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-1.png new file mode 100644 index 0000000000..c4c7695a03 Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-1.png differ diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-2.png b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-2.png new file mode 100644 index 0000000000..258a0d5b81 Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-2.png differ diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-3.png b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-3.png new file mode 100644 index 0000000000..a8e53ef95b Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-3.png differ diff --git a/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-4.png b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-4.png new file mode 100644 index 0000000000..9411d5f2a5 Binary files /dev/null and b/__tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-4.png differ diff --git a/__tests__/html2/carousel/navigation.arrowKeys.button.html b/__tests__/html2/carousel/navigation.arrowKeys.button.html index 6000b3291f..a9b14e9fa7 100644 --- a/__tests__/html2/carousel/navigation.arrowKeys.button.html +++ b/__tests__/html2/carousel/navigation.arrowKeys.button.html @@ -83,7 +83,7 @@ const carouselLayout = document.querySelector('.carousel-layout'); const carouselFilmstrip = carouselLayout.querySelector('.carousel-filmstrip'); const cards = Array.from(carouselFilmstrip.querySelectorAll('[aria-roledescription="attachment"]')); - const flippers = Array.from(carouselLayout.querySelectorAll('.react-film__flipper')); + const flippers = Array.from(carouselLayout.querySelectorAll('.carousel-flipper')); const secondCardButton = cards[1].querySelector('button'); const isCardInView = card => { @@ -95,7 +95,7 @@ // The flipper is hidden when its body has slid out of the flipper button (1s delay after scroll stops, then 300ms slide). const isFlipperHidden = flipper => { - const bodyRect = flipper.querySelector('.react-film__flipper__body').getBoundingClientRect(); + const bodyRect = flipper.querySelector('.carousel-flipper__body').getBoundingClientRect(); const buttonRect = flipper.getBoundingClientRect(); return bodyRect.right <= buttonRect.left || bodyRect.left >= buttonRect.right; @@ -104,7 +104,7 @@ const waitForCarouselSettled = () => waitFor( () => { - expect(carouselLayout.querySelector('.react-film__main--scrolling')).toBeNull(); + expect(carouselLayout.querySelector('.carousel-layout__main--scrolling')).toBeNull(); expect(flippers.every(isFlipperHidden)).toBe(true); }, { timeout: 5000 } diff --git a/__tests__/html2/carousel/navigation.tab.html b/__tests__/html2/carousel/navigation.tab.html index ea97187fef..6ee1d93257 100644 --- a/__tests__/html2/carousel/navigation.tab.html +++ b/__tests__/html2/carousel/navigation.tab.html @@ -6,7 +6,7 @@ diff --git a/__tests__/html2/carousel/navigation.tab.scrollIntoView.html b/__tests__/html2/carousel/navigation.tab.scrollIntoView.html index 48636ec437..30f60fcfc6 100644 --- a/__tests__/html2/carousel/navigation.tab.scrollIntoView.html +++ b/__tests__/html2/carousel/navigation.tab.scrollIntoView.html @@ -58,7 +58,7 @@ const carouselLayout = document.querySelector('.carousel-layout'); const carouselFilmstrip = carouselLayout.querySelector('.carousel-filmstrip'); const cards = Array.from(carouselFilmstrip.querySelectorAll('[aria-roledescription="attachment"]')); - const flippers = Array.from(carouselLayout.querySelectorAll('.react-film__flipper')); + const flippers = Array.from(carouselLayout.querySelectorAll('.carousel-flipper')); const buttons = cards.map(card => card.querySelector('button')); const isCardInView = card => { @@ -70,7 +70,7 @@ // The flipper is hidden when its body has slid out of the flipper button (1s delay after scroll stops, then 300ms slide). const isFlipperHidden = flipper => { - const bodyRect = flipper.querySelector('.react-film__flipper__body').getBoundingClientRect(); + const bodyRect = flipper.querySelector('.carousel-flipper__body').getBoundingClientRect(); const buttonRect = flipper.getBoundingClientRect(); return bodyRect.right <= buttonRect.left || bodyRect.left >= buttonRect.right; @@ -82,7 +82,7 @@ // Wait for the scroll and the flippers to settle. await waitFor( () => { - expect(carouselLayout.querySelector('.react-film__main--scrolling')).toBeNull(); + expect(carouselLayout.querySelector('.carousel-layout__main--scrolling')).toBeNull(); expect(flippers.every(isFlipperHidden)).toBe(true); }, { timeout: 5000 } diff --git a/__tests__/html2/carousel/navigation.tab.scrollIntoView.pointer.html b/__tests__/html2/carousel/navigation.tab.scrollIntoView.pointer.html index 56c9d401e2..3c54b0131a 100644 --- a/__tests__/html2/carousel/navigation.tab.scrollIntoView.pointer.html +++ b/__tests__/html2/carousel/navigation.tab.scrollIntoView.pointer.html @@ -58,7 +58,7 @@ const carouselLayout = document.querySelector('.carousel-layout'); const carouselFilmstrip = carouselLayout.querySelector('.carousel-filmstrip'); const cards = Array.from(carouselFilmstrip.querySelectorAll('[aria-roledescription="attachment"]')); - const flippers = Array.from(carouselLayout.querySelectorAll('.react-film__flipper')); + const flippers = Array.from(carouselLayout.querySelectorAll('.carousel-flipper')); const isCardInView = card => { const cardRect = card.getBoundingClientRect(); @@ -69,7 +69,7 @@ // The flipper is hidden when its body has slid out of the flipper button (1s delay after scroll stops, then 300ms slide). const isFlipperHidden = flipper => { - const bodyRect = flipper.querySelector('.react-film__flipper__body').getBoundingClientRect(); + const bodyRect = flipper.querySelector('.carousel-flipper__body').getBoundingClientRect(); const buttonRect = flipper.getBoundingClientRect(); return bodyRect.right <= buttonRect.left || bodyRect.left >= buttonRect.right; @@ -81,7 +81,7 @@ // Wait for the scroll and the flippers to settle. await waitFor( () => { - expect(carouselLayout.querySelector('.react-film__main--scrolling')).toBeNull(); + expect(carouselLayout.querySelector('.carousel-layout__main--scrolling')).toBeNull(); expect(flippers.every(isFlipperHidden)).toBe(true); }, { timeout: 5000 } diff --git a/__tests__/html2/rtl/carousel.html b/__tests__/html2/rtl/carousel.html index 0bf2efab82..b7cdb7b7cc 100644 --- a/__tests__/html2/rtl/carousel.html +++ b/__tests__/html2/rtl/carousel.html @@ -41,7 +41,8 @@ await pageConditions.allImagesLoaded(); await pageConditions.allOutgoingActivitiesSent(); - const leftFlipper = document.querySelector('.react-film__flipper--left'); + // In RTL, the next flipper is on the left side. + const leftFlipper = document.querySelector('.carousel-flipper--next'); await leftFlipper.click(); await leftFlipper.click(); diff --git a/lint-staged.config.js b/lint-staged.config.js index 568a8bda63..53ccbb8520 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -14,6 +14,7 @@ module.exports = { 'packages/bundle/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:bundle'], 'packages/component/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:component'], 'packages/component-better-link/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:component-better-link'], + 'packages/component-carousel/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:component-carousel'], 'packages/core/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:core'], 'packages/core-debug-api/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:core-debug-api'], 'packages/core-graph/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:core-graph'], diff --git a/package-lock.json b/package-lock.json index 9541b0699c..95dce61f2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "packages/isomorphic-react", "packages/isomorphic-react-dom", "packages/component-better-link", + "packages/component-carousel", "packages/component", "packages/repack/adaptivecards", "packages/repack/base64-js", @@ -3968,6 +3969,10 @@ "resolved": "packages/component-better-link", "link": true }, + "node_modules/@msinternal/botframework-webchat-component-carousel": { + "resolved": "packages/component-carousel", + "link": true + }, "node_modules/@msinternal/botframework-webchat-core-debug-api": { "resolved": "packages/core-debug-api", "link": true @@ -22065,6 +22070,7 @@ "@babel/preset-typescript": "^7.29.7", "@msinternal/botframework-webchat-base": "0.0.0-0", "@msinternal/botframework-webchat-component-better-link": "0.0.0-0", + "@msinternal/botframework-webchat-component-carousel": "0.0.0-0", "@msinternal/botframework-webchat-react-hooks": "0.0.0-0", "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", "@msinternal/botframework-webchat-styles": "0.0.0-0", @@ -22095,6 +22101,40 @@ "typescript": "^6.0.3" } }, + "packages/component-carousel": { + "name": "@msinternal/botframework-webchat-component-carousel", + "version": "0.0.0-0", + "license": "MIT", + "dependencies": { + "valibot": "1.4.1" + }, + "devDependencies": { + "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", + "@msinternal/botframework-webchat-tsconfig": "0.0.0-0", + "@testduet/given-when-then": "^0.1.0", + "@types/jest": "^29.5.14", + "@types/react": "^16.14.70", + "tsup": "^8.5.1", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "react": ">= 16.8.6" + } + }, + "packages/component-carousel/node_modules/valibot": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.1.tgz", + "integrity": "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "packages/component/node_modules/type-fest": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", diff --git a/package.json b/package.json index 9a8f2f01eb..035ffc91e8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "packages/isomorphic-react", "packages/isomorphic-react-dom", "packages/component-better-link", + "packages/component-carousel", "packages/component", "packages/repack/adaptivecards", "packages/repack/base64-js", @@ -85,6 +86,7 @@ "precommit:eslint:bundle": "cd packages && cd bundle && npm run precommit:eslint", "precommit:eslint:component": "cd packages && cd component && npm run precommit:eslint", "precommit:eslint:component-better-link": "cd packages && cd component-better-link && npm run precommit:eslint", + "precommit:eslint:component-carousel": "cd packages && cd component-carousel && npm run precommit:eslint", "precommit:eslint:core": "cd packages && cd core && npm run precommit:eslint", "precommit:eslint:core-debug-api": "cd packages && cd core-debug-api && npm run precommit:eslint", "precommit:eslint:core-graph": "cd packages && cd core-graph && npm run precommit:eslint", @@ -124,6 +126,7 @@ "precommit:typecheck:bundle": "cd packages && cd bundle && npm run precommit:typecheck", "precommit:typecheck:component": "cd packages && cd component && npm run precommit:typecheck", "precommit:typecheck:component-better-link": "cd packages && cd component-better-link && npm run precommit:typecheck", + "precommit:typecheck:component-carousel": "cd packages && cd component-carousel && npm run precommit:typecheck", "precommit:typecheck:core": "cd packages && cd core && npm run precommit:typecheck", "precommit:typecheck:core-debug-api": "cd packages && cd core-debug-api && npm run precommit:typecheck", "precommit:typecheck:core-graph": "cd packages && cd core-graph && npm run precommit:typecheck", @@ -155,6 +158,7 @@ "start:bundle": "cd packages && cd bundle && npm start", "start:component": "cd packages && cd component && npm start", "start:component-better-link": "cd packages && cd component-better-link && npm start", + "start:component-carousel": "cd packages && cd component-carousel && npm start", "start:core": "cd packages && cd core && npm start", "start:core-debug-api": "cd packages && cd core-debug-api && npm start", "start:core-graph": "cd packages && cd core-graph && npm start", diff --git a/packages/component-carousel/.eslintrc.yml b/packages/component-carousel/.eslintrc.yml new file mode 100644 index 0000000000..6ba5244054 --- /dev/null +++ b/packages/component-carousel/.eslintrc.yml @@ -0,0 +1,14 @@ +extends: + - ../../.eslintrc.production.yml + - ../../.eslintrc.react.yml + +# This package is compatible with web browser. +env: + browser: true + +rules: + # React functional component is better in function style than arrow style. + prefer-arrow-callback: off + + # React already deprecated default props. + react/require-default-props: off diff --git a/packages/component-carousel/.gitignore b/packages/component-carousel/.gitignore new file mode 100644 index 0000000000..52536cb56d --- /dev/null +++ b/packages/component-carousel/.gitignore @@ -0,0 +1,4 @@ +/*.tgz +/dist/ +/node_modules/ +/tsup.config.bundled_*.mjs diff --git a/packages/component-carousel/README.md b/packages/component-carousel/README.md new file mode 100644 index 0000000000..f8fc16bdfa --- /dev/null +++ b/packages/component-carousel/README.md @@ -0,0 +1,42 @@ +# @msinternal/botframework-webchat-component-carousel + +Private, headless carousel for Web Chat: behavior only, no markup and no styles. It is bundled into `botframework-webchat-component` and never published. + +## Usage + +```tsx +function Carousel() { + const [{ atEnd, atStart, overflowing, scrolling }] = useCarouselState(); + const itemContainerRef = useCarouselItemContainerRef(); + const scrollableRef = useCarouselScrollableRef(); + const scrollBy = useCarouselScrollBy(); + + // Render the scrollable element, the item container (its element children are the items), + // and flippers calling scrollBy('previous') or scrollBy('next'). +} + +