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'). +} + + + +; +``` + +## API + +| Export | Description | +| ------------------------------- | --------------------------------------------------------------------------------------------------- | +| `` | Tracks the scrollable element and its items; `ponyfill` provides `clearTimeout` and `setTimeout` | +| `useCarouselScrollableRef()` | Ref callback for the element that scrolls horizontally | +| `useCarouselItemContainerRef()` | Ref callback for the element whose element children are the items, in reading order | +| `useCarouselState()` | `readonly [{ atEnd, atStart, overflowing, scrolling }]`, frozen | +| `useCarouselScrollBy()` | `(direction: 'next' \| 'previous') => void`: centers the next/previous item that can move into view | +| `useCarouselScrollToIndex()` | `(index: number) => void`: centers the item, index clamped | + +## Behavior + +- Direction-agnostic: reading order comes from the computed `direction` of the scrollable element, with the standard RTL model (negative `scrollLeft`). +- `atStart`, `atEnd` and `overflowing` have a 1-pixel tolerance, so fractional zoom does not leave them stale. +- Updates on scroll and on resize of the scrollable or the item container (`ResizeObserver`): viewport resize, late image loads, added items. +- `scrolling` is `true` until 500 ms after the last scroll event. +- Scrolls with `behavior: 'smooth'`, or `'auto'` under `prefers-reduced-motion: reduce`. Repeated steps continue from where the running smooth scroll will end. +- Only the scrollable element scrolls (`scrollTo()`, not `scrollIntoView()`), so the transcript does not move. +- Re-centering after a manual scroll is left to CSS, e.g. `scroll-snap-type: x proximity` on the scrollable and `scroll-snap-align: center` on the items. diff --git a/packages/component-carousel/package.json b/packages/component-carousel/package.json new file mode 100644 index 0000000000..7bc748c667 --- /dev/null +++ b/packages/component-carousel/package.json @@ -0,0 +1,82 @@ +{ + "name": "@msinternal/botframework-webchat-component-carousel", + "version": "0.0.0-0", + "description": "botframework-webchat-component/carousel package", + "main": "./dist/botframework-webchat-component-carousel.js", + "types": "./dist/botframework-webchat-component-carousel.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/botframework-webchat-component-carousel.d.mts", + "default": "./dist/botframework-webchat-component-carousel.mjs" + }, + "require": { + "types": "./dist/botframework-webchat-component-carousel.d.ts", + "default": "./dist/botframework-webchat-component-carousel.js" + } + } + }, + "author": "Microsoft Corporation", + "license": "MIT", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/BotFramework-WebChat.git" + }, + "bugs": { + "url": "https://github.com/microsoft/BotFramework-WebChat/issues" + }, + "files": [ + "./dist/**/*", + "./src/**/*" + ], + "homepage": "https://github.com/microsoft/BotFramework-WebChat/tree/main/packages/component-carousel#readme", + "scripts": { + "build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post", + "build:pre": "npm run build:pre:local-dependencies && npm run build:pre:watch", + "build:pre:local-dependencies": "../../scripts/npm/build-local-dependencies.sh", + "build:pre:watch": "../../scripts/npm/build-watch.sh", + "build:run": "tsup", + "bump": "vg bump prod && vg bump dev && vg bump peer && (npm audit fix || exit 0)", + "eslint": "npm run precommit", + "postversion": "../../scripts/npm/postversion.sh", + "precommit": "npm run precommit:eslint -- src && npm run precommit:typecheck", + "precommit:eslint": "../../node_modules/.bin/eslint --report-unused-disable-directives --max-warnings 0", + "precommit:typecheck": "tsc --project ./src --emitDeclarationOnly false --esModuleInterop true --noEmit --pretty false", + "preversion": "../../scripts/npm/preversion.sh", + "start": "../../scripts/npm/notify-build.sh \"src\" \"../react-valibot/package.json\"" + }, + "pinDependencies": { + "@types/jest": [ + "29", + "pinned jest@29" + ], + "@types/react": [ + "16", + "react@16.8.6 is our baseline" + ], + "react": [ + "16.8.6", + "using react@16.8.6 to make sure this is the minimum supported version" + ] + }, + "localDependencies": { + "@msinternal/botframework-webchat-react-valibot": "development", + "@msinternal/botframework-webchat-tsconfig": "development" + }, + "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" + }, + "dependencies": { + "valibot": "1.4.1" + } +} diff --git a/packages/component-carousel/src/CarouselComposer.tsx b/packages/component-carousel/src/CarouselComposer.tsx new file mode 100644 index 0000000000..066a7d25c8 --- /dev/null +++ b/packages/component-carousel/src/CarouselComposer.tsx @@ -0,0 +1,187 @@ +import { reactNode, validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { args, function_, number, object, optional, pipe, readonly, returns, tuple, void_ } from 'valibot'; + +import { + CarouselActionsContext, + CarouselStateContext, + type CarouselActionsContextType +} from './private/CarouselContext'; +import computeEdges from './private/computeEdges'; +import findAdjacentPosition from './private/findAdjacentPosition'; +import measure, { measureScroll, type Measurement } from './private/measure'; +import { type CarouselState } from './types/CarouselState'; + +// Same as react-film: the carousel is "scrolling" until 500 ms after the last scroll event. +const SCROLLING_TIMEOUT = 500; + +const INITIAL_STATE: CarouselState = Object.freeze({ + atEnd: true, + atStart: true, + overflowing: false, + scrolling: false +}); + +const carouselComposerPropsSchema = pipe( + object({ + children: optional(reactNode()), + /** Clock functions, e.g. Web Chat's `usePonyfill()`, so tests can use fake timers. */ + ponyfill: pipe( + object({ + clearTimeout: pipe(function_(), args(tuple([optional(number())])), returns(void_())), + setTimeout: pipe(function_(), args(tuple([pipe(function_(), returns(void_())), number()])), returns(number())) + }), + readonly() + ) + }), + readonly() +); + +type CarouselComposerProps = InferReactProps; + +function patchState(state: CarouselState, patch: Partial): CarouselState { + const nextState = { ...state, ...patch }; + + return nextState.atEnd === state.atEnd && + nextState.atStart === state.atStart && + nextState.overflowing === state.overflowing && + nextState.scrolling === state.scrolling + ? state + : Object.freeze(nextState); +} + +/** + * Headless carousel: tracks the scrollable element and its items, and scrolls item by item. + * + * Markup and styles are up to the consumer: set the scrollable with `useCarouselScrollableRef()`, the element whose + * children are the items with `useCarouselItemContainerRef()`. + */ +function CarouselComposer(props: CarouselComposerProps) { + const { + children, + ponyfill: { clearTimeout, setTimeout } + } = validateProps(carouselComposerPropsSchema, props); + + const [itemContainer, setItemContainer] = useState(null); + const [scrollable, setScrollable] = useState(null); + const [state, setState] = useState(INITIAL_STATE); + // Where a smooth scroll started by `scrollBy()`/`scrollToIndex()` will end, so repeated steps continue from there. + const pendingPositionRef = useRef(undefined); + + useEffect(() => { + if (!scrollable) { + return; + } + + let scrollingTimeout: number | undefined; + + // Browsers fire scroll events and resize observations at most once per frame. + const update = () => { + const { maxPosition, position } = measureScroll(scrollable); + + // The smooth scroll has arrived. + Math.abs(position - (pendingPositionRef.current ?? NaN)) < 1 && (pendingPositionRef.current = undefined); + + setState(state => patchState(state, computeEdges({ maxPosition, position }))); + }; + + const handleScroll = () => { + update(); + setState(state => patchState(state, { scrolling: true })); + + clearTimeout(scrollingTimeout); + + scrollingTimeout = setTimeout(() => { + pendingPositionRef.current = undefined; + setState(state => patchState(state, { scrolling: false })); + }, SCROLLING_TIMEOUT); + }; + + // The user took over the scroll. + const handleUserScroll = () => { + pendingPositionRef.current = undefined; + }; + + // Resizing the scrollable or the item container (viewport resize, late image load, added items) can change the edges. + const resizeObserver = new ResizeObserver(update); + + resizeObserver.observe(scrollable); + itemContainer && resizeObserver.observe(itemContainer); + + scrollable.addEventListener('pointerdown', handleUserScroll, { passive: true }); + scrollable.addEventListener('scroll', handleScroll, { passive: true }); + scrollable.addEventListener('wheel', handleUserScroll, { passive: true }); + + return () => { + clearTimeout(scrollingTimeout); + resizeObserver.disconnect(); + + scrollable.removeEventListener('pointerdown', handleUserScroll); + scrollable.removeEventListener('scroll', handleScroll); + scrollable.removeEventListener('wheel', handleUserScroll); + }; + }, [clearTimeout, itemContainer, scrollable, setTimeout]); + + const scrollToPosition = useCallback( + ({ rtl }: Measurement, position: number) => { + pendingPositionRef.current = position; + + // `scrollTo()` on the scrollable (unlike `scrollIntoView()`) does not scroll its ancestors, e.g. the transcript. + scrollable?.scrollTo({ + behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', + left: rtl ? -position : position + }); + }, + [scrollable] + ); + + const scrollBy = useCallback( + direction => { + if (scrollable && itemContainer) { + const measurement = measure(scrollable, itemContainer); + const { itemPositions, position } = measurement; + + const itemPosition = findAdjacentPosition(itemPositions, pendingPositionRef.current ?? position, direction); + + typeof itemPosition === 'number' && scrollToPosition(measurement, itemPosition); + } + }, + [itemContainer, scrollable, scrollToPosition] + ); + + const scrollToIndex = useCallback( + index => { + if (scrollable && itemContainer) { + const measurement = measure(scrollable, itemContainer); + const { itemPositions } = measurement; + + const itemPosition = itemPositions[Math.max(0, Math.min(itemPositions.length - 1, index))]; + + typeof itemPosition === 'number' && scrollToPosition(measurement, itemPosition); + } + }, + [itemContainer, scrollable, scrollToPosition] + ); + + const actionsContext = useMemo( + () => + Object.freeze({ + itemContainerRef: setItemContainer, + scrollableRef: setScrollable, + scrollBy, + scrollToIndex + }), + [scrollBy, scrollToIndex] + ); + + return ( + + {children} + + ); +} + +CarouselComposer.displayName = 'CarouselComposer'; + +export default memo(CarouselComposer); +export { carouselComposerPropsSchema, type CarouselComposerProps }; diff --git a/packages/component-carousel/src/index.ts b/packages/component-carousel/src/index.ts new file mode 100644 index 0000000000..45e9ab3a64 --- /dev/null +++ b/packages/component-carousel/src/index.ts @@ -0,0 +1,11 @@ +export { + default as CarouselComposer, + carouselComposerPropsSchema, + type CarouselComposerProps +} from './CarouselComposer'; +export { type CarouselState } from './types/CarouselState'; +export { default as useCarouselItemContainerRef } from './useCarouselItemContainerRef'; +export { default as useCarouselScrollableRef } from './useCarouselScrollableRef'; +export { default as useCarouselScrollBy } from './useCarouselScrollBy'; +export { default as useCarouselScrollToIndex } from './useCarouselScrollToIndex'; +export { default as useCarouselState } from './useCarouselState'; diff --git a/packages/component-carousel/src/private/CarouselContext.ts b/packages/component-carousel/src/private/CarouselContext.ts new file mode 100644 index 0000000000..aeead0235e --- /dev/null +++ b/packages/component-carousel/src/private/CarouselContext.ts @@ -0,0 +1,18 @@ +import { createContext, type RefCallback } from 'react'; + +import { type CarouselState } from '../types/CarouselState'; + +type CarouselActionsContextType = { + readonly itemContainerRef: RefCallback; + readonly scrollableRef: RefCallback; + readonly scrollBy: (direction: 'next' | 'previous') => void; + readonly scrollToIndex: (index: number) => void; +}; + +const CarouselActionsContext = createContext(undefined); +const CarouselStateContext = createContext(undefined); + +CarouselActionsContext.displayName = 'CarouselActionsContext'; +CarouselStateContext.displayName = 'CarouselStateContext'; + +export { CarouselActionsContext, CarouselStateContext, type CarouselActionsContextType }; diff --git a/packages/component-carousel/src/private/computeEdges.spec.ts b/packages/component-carousel/src/private/computeEdges.spec.ts new file mode 100644 index 0000000000..b7a9496c2a --- /dev/null +++ b/packages/component-carousel/src/private/computeEdges.spec.ts @@ -0,0 +1,50 @@ +/// + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import computeEdges from './computeEdges'; + +scenario( + 'computeEdges', + bdd => { + bdd + .given('content as wide as the scrollport', () => ({ maxPosition: 0, position: 0 })) + .when('computing edges', init => computeEdges(init)) + .then('should not overflow and be at both edges', (_, actual) => + expect(actual).toEqual({ atEnd: true, atStart: true, overflowing: false }) + ) + .and('should be frozen', (_, actual) => expect(Object.isFrozen(actual)).toBe(true)); + + bdd + .given('content 1 pixel wider than the scrollport', () => ({ maxPosition: 1, position: 0 })) + .when('computing edges', init => computeEdges(init)) + .then('should not overflow, within tolerance', (_, actual) => + expect(actual).toEqual({ atEnd: true, atStart: true, overflowing: false }) + ); + + bdd + .given('overflowing content scrolled to the start', () => ({ maxPosition: 500, position: 0 })) + .when('computing edges', init => computeEdges(init)) + .then('should be at the start only', (_, actual) => + expect(actual).toEqual({ atEnd: false, atStart: true, overflowing: true }) + ); + + bdd + .given('overflowing content scrolled to the middle', () => ({ maxPosition: 500, position: 250 })) + .when('computing edges', init => computeEdges(init)) + .then('should be at neither edge', (_, actual) => + expect(actual).toEqual({ atEnd: false, atStart: false, overflowing: true }) + ); + + bdd + .given('overflowing content scrolled to 0.4 pixels before the end', () => ({ + maxPosition: 500, + position: 499.6 // Fractional zoom can stop a fraction of a pixel before the end. + })) + .when('computing edges', init => computeEdges(init)) + .then('should be at the end, within tolerance', (_, actual) => + expect(actual).toEqual({ atEnd: true, atStart: false, overflowing: true }) + ); + }, + JestGlobals +); diff --git a/packages/component-carousel/src/private/computeEdges.ts b/packages/component-carousel/src/private/computeEdges.ts new file mode 100644 index 0000000000..f9dff3eaf4 --- /dev/null +++ b/packages/component-carousel/src/private/computeEdges.ts @@ -0,0 +1,26 @@ +// Scroll positions are in CSS pixels; fractional zoom can leave the position a fraction of a pixel away from an edge. +const TOLERANCE = 1; + +type ComputeEdgesInit = { + /** Largest scroll position, i.e. `scrollWidth - clientWidth`. */ + readonly maxPosition: number; + + /** Distance scrolled from the inline-start edge, always zero or positive (in RTL, `scrollLeft` is zero or negative). */ + readonly position: number; +}; + +type Edges = { + readonly atEnd: boolean; + readonly atStart: boolean; + readonly overflowing: boolean; +}; + +export default function computeEdges({ maxPosition, position }: ComputeEdgesInit): Edges { + return Object.freeze({ + atEnd: position >= maxPosition - TOLERANCE, + atStart: position <= TOLERANCE, + overflowing: maxPosition > TOLERANCE + }); +} + +export { type ComputeEdgesInit, type Edges }; diff --git a/packages/component-carousel/src/private/computeItemPosition.spec.ts b/packages/component-carousel/src/private/computeItemPosition.spec.ts new file mode 100644 index 0000000000..f07eb26d14 --- /dev/null +++ b/packages/component-carousel/src/private/computeItemPosition.spec.ts @@ -0,0 +1,38 @@ +/// + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import computeItemPosition from './computeItemPosition'; + +scenario( + 'computeItemPosition', + bdd => { + bdd + .given('an item centered at 600 pixels in a 400-pixel scrollport', () => ({ + clientWidth: 400, + itemCenter: 600, + maxPosition: 1000 + })) + .when('computing the position', init => computeItemPosition(init)) + .then('should scroll the item center to the scrollport center', (_, actual) => expect(actual).toBe(400)); + + bdd + .given('the first item centered at 150 pixels in a 400-pixel scrollport', () => ({ + clientWidth: 400, + itemCenter: 150, + maxPosition: 1000 + })) + .when('computing the position', init => computeItemPosition(init)) + .then('should clamp to the start', (_, actual) => expect(actual).toBe(0)); + + bdd + .given('the last item centered at 1350 pixels in a 400-pixel scrollport', () => ({ + clientWidth: 400, + itemCenter: 1350, + maxPosition: 1000 + })) + .when('computing the position', init => computeItemPosition(init)) + .then('should clamp to the end', (_, actual) => expect(actual).toBe(1000)); + }, + JestGlobals +); diff --git a/packages/component-carousel/src/private/computeItemPosition.ts b/packages/component-carousel/src/private/computeItemPosition.ts new file mode 100644 index 0000000000..785ca3818c --- /dev/null +++ b/packages/component-carousel/src/private/computeItemPosition.ts @@ -0,0 +1,22 @@ +/* eslint no-magic-numbers: ["error", { "ignore": [0, 2] }] */ + +type ComputeItemPositionInit = { + /** Width of the scrollport, i.e. `clientWidth`. */ + readonly clientWidth: number; + + /** Distance from the inline-start edge of the scrollable content to the center of the item. */ + readonly itemCenter: number; + + /** Largest scroll position, i.e. `scrollWidth - clientWidth`. */ + readonly maxPosition: number; +}; + +/** + * Computes the scroll position that centers the item, clamped to the scrollable range, as `scroll-snap-align: center` + * does. + */ +export default function computeItemPosition({ clientWidth, itemCenter, maxPosition }: ComputeItemPositionInit): number { + return Math.max(0, Math.min(maxPosition, itemCenter - clientWidth / 2)); +} + +export { type ComputeItemPositionInit }; diff --git a/packages/component-carousel/src/private/findAdjacentPosition.spec.ts b/packages/component-carousel/src/private/findAdjacentPosition.spec.ts new file mode 100644 index 0000000000..fc48b8d609 --- /dev/null +++ b/packages/component-carousel/src/private/findAdjacentPosition.spec.ts @@ -0,0 +1,64 @@ +/// + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import findAdjacentPosition from './findAdjacentPosition'; + +// 5 items, each 200 pixels apart; the first and the last cannot be centered and are clamped to the edges. +const ITEM_POSITIONS = Object.freeze([0, 100, 300, 500, 600]); + +scenario( + 'findAdjacentPosition', + bdd => { + bdd + .given('scrolled to the start', () => 0) + .when('finding the next item', position => findAdjacentPosition(ITEM_POSITIONS, position, 'next')) + .then('should return the second item', (_, actual) => expect(actual).toBe(100)); + + bdd + .given('scrolled to the start', () => 0) + .when('finding the previous item', position => findAdjacentPosition(ITEM_POSITIONS, position, 'previous')) + .then('should return nothing', (_, actual) => expect(actual).toBeUndefined()); + + bdd + .given('the third item centered', () => 300) + .when('finding the next item', position => findAdjacentPosition(ITEM_POSITIONS, position, 'next')) + .then('should return the fourth item', (_, actual) => expect(actual).toBe(500)); + + bdd + .given('the third item centered', () => 300) + .when('finding the previous item', position => findAdjacentPosition(ITEM_POSITIONS, position, 'previous')) + .then('should return the second item', (_, actual) => expect(actual).toBe(100)); + + bdd + .given('scrolled between the third and the fourth item', () => 380) + .when('finding the next item', position => findAdjacentPosition(ITEM_POSITIONS, position, 'next')) + .then('should return the fourth item', (_, actual) => expect(actual).toBe(500)); + + bdd + .given('scrolled between the third and the fourth item', () => 380) + .when('finding the previous item', position => findAdjacentPosition(ITEM_POSITIONS, position, 'previous')) + .then('should return the third item', (_, actual) => expect(actual).toBe(300)); + + bdd + .given('the third item centered, off by 0.5 pixels', () => 300.5) // Within tolerance, e.g. fractional zoom. + .when('finding the previous item', position => findAdjacentPosition(ITEM_POSITIONS, position, 'previous')) + .then('should skip the third item and return the second item', (_, actual) => expect(actual).toBe(100)); + + bdd + .given('scrolled to the end', () => 600) + .when('finding the next item', position => findAdjacentPosition(ITEM_POSITIONS, position, 'next')) + .then('should return nothing', (_, actual) => expect(actual).toBeUndefined()); + + bdd + .given('scrolled to the end', () => 600) + .when('finding the previous item', position => findAdjacentPosition(ITEM_POSITIONS, position, 'previous')) + .then('should return the fourth item', (_, actual) => expect(actual).toBe(500)); + + bdd + .given('no items', () => 0) + .when('finding the next item', position => findAdjacentPosition([], position, 'next')) + .then('should return nothing', (_, actual) => expect(actual).toBeUndefined()); + }, + JestGlobals +); diff --git a/packages/component-carousel/src/private/findAdjacentPosition.ts b/packages/component-carousel/src/private/findAdjacentPosition.ts new file mode 100644 index 0000000000..fda0fafd9c --- /dev/null +++ b/packages/component-carousel/src/private/findAdjacentPosition.ts @@ -0,0 +1,23 @@ +// A step must move by more than this, so items that cannot be centered (clamped at an edge) are skipped. +const TOLERANCE = 1; + +/** + * Finds the scroll position of the previous or next item in reading order. + * + * @param itemPositions scroll positions that center each item, clamped to the scrollable range, in reading order + * @param position current (or pending) scroll position + * @param direction `"next"` or `"previous"` + * @returns scroll position of the item, or `undefined` if there is no item in that direction + */ +export default function findAdjacentPosition( + itemPositions: readonly number[], + position: number, + direction: 'next' | 'previous' +): number | undefined { + return direction === 'next' + ? itemPositions.find(itemPosition => itemPosition > position + TOLERANCE) + : itemPositions.reduce( + (lastPosition, itemPosition) => (itemPosition < position - TOLERANCE ? itemPosition : lastPosition), + undefined + ); +} diff --git a/packages/component-carousel/src/private/measure.ts b/packages/component-carousel/src/private/measure.ts new file mode 100644 index 0000000000..c4207fa9b8 --- /dev/null +++ b/packages/component-carousel/src/private/measure.ts @@ -0,0 +1,65 @@ +/* eslint no-magic-numbers: ["error", { "ignore": [0, 2] }] */ + +import computeItemPosition from './computeItemPosition'; + +type ScrollMeasurement = { + /** Largest scroll position. */ + readonly maxPosition: number; + + /** Distance scrolled from the inline-start edge, always zero or positive. */ + readonly position: number; +}; + +type Measurement = ScrollMeasurement & { + /** Scroll positions that center each item, clamped to the scrollable range, in reading order. */ + readonly itemPositions: readonly number[]; + + /** Whether the scrollable is right-to-left: `scrollLeft` is then zero or negative. */ + readonly rtl: boolean; +}; + +/** + * Measures the scroll position of the scrollable, in LTR and RTL, without forcing a style recalculation. + * + * All supported browsers use the standard RTL model: `scrollLeft` is 0 at the inline-start edge and negative towards + * the inline-end edge. + */ +function measureScroll({ clientWidth, scrollLeft, scrollWidth }: HTMLElement): ScrollMeasurement { + return Object.freeze({ + maxPosition: Math.max(0, scrollWidth - clientWidth), + position: Math.abs(scrollLeft) + }); +} + +/** + * Measures the scrollable and the items in reading order, in LTR and RTL. + */ +export default function measure(scrollable: HTMLElement, itemContainer: HTMLElement): Measurement { + const { clientLeft, clientWidth } = scrollable; + const { left } = scrollable.getBoundingClientRect(); + const { maxPosition, position } = measureScroll(scrollable); + const rtl = getComputedStyle(scrollable).direction === 'rtl'; + + // The inline-start edge of the scrollport, in viewport coordinates. + const scrollportStart = left + clientLeft + (rtl ? clientWidth : 0); + + return Object.freeze({ + itemPositions: Object.freeze( + Array.from(itemContainer.children, item => { + const itemRect = item.getBoundingClientRect(); + const center = (itemRect.left + itemRect.right) / 2; + + return computeItemPosition({ + clientWidth, + itemCenter: position + (rtl ? scrollportStart - center : center - scrollportStart), + maxPosition + }); + }) + ), + maxPosition, + position, + rtl + }); +} + +export { measureScroll, type Measurement }; diff --git a/packages/component-carousel/src/private/useCarouselActionsContext.ts b/packages/component-carousel/src/private/useCarouselActionsContext.ts new file mode 100644 index 0000000000..6c37a92ee7 --- /dev/null +++ b/packages/component-carousel/src/private/useCarouselActionsContext.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; + +import { CarouselActionsContext, type CarouselActionsContextType } from './CarouselContext'; + +export default function useCarouselActionsContext(): CarouselActionsContextType { + const context = useContext(CarouselActionsContext); + + if (!context) { + throw new Error('botframework-webchat: Carousel hooks must be used under .'); + } + + return context; +} diff --git a/packages/component-carousel/src/tsconfig.json b/packages/component-carousel/src/tsconfig.json new file mode 100644 index 0000000000..281599ac3f --- /dev/null +++ b/packages/component-carousel/src/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true + }, + "extends": "@msinternal/botframework-webchat-tsconfig/current" +} diff --git a/packages/component-carousel/src/types/CarouselState.ts b/packages/component-carousel/src/types/CarouselState.ts new file mode 100644 index 0000000000..b49e635a1b --- /dev/null +++ b/packages/component-carousel/src/types/CarouselState.ts @@ -0,0 +1,15 @@ +type CarouselState = { + /** Cannot scroll further towards the inline-end edge (1 pixel tolerance). */ + readonly atEnd: boolean; + + /** Cannot scroll further towards the inline-start edge (1 pixel tolerance). */ + readonly atStart: boolean; + + /** The content is wider than the scrollport (1 pixel tolerance). */ + readonly overflowing: boolean; + + /** A scroll event happened in the last 500 milliseconds. */ + readonly scrolling: boolean; +}; + +export { type CarouselState }; diff --git a/packages/component-carousel/src/useCarouselItemContainerRef.ts b/packages/component-carousel/src/useCarouselItemContainerRef.ts new file mode 100644 index 0000000000..643143e77f --- /dev/null +++ b/packages/component-carousel/src/useCarouselItemContainerRef.ts @@ -0,0 +1,8 @@ +import { type RefCallback } from 'react'; + +import useCarouselActionsContext from './private/useCarouselActionsContext'; + +/** Returns the ref callback for the element whose element children are the items, in reading order. */ +export default function useCarouselItemContainerRef(): RefCallback { + return useCarouselActionsContext().itemContainerRef; +} diff --git a/packages/component-carousel/src/useCarouselScrollBy.ts b/packages/component-carousel/src/useCarouselScrollBy.ts new file mode 100644 index 0000000000..bddfb740a2 --- /dev/null +++ b/packages/component-carousel/src/useCarouselScrollBy.ts @@ -0,0 +1,16 @@ +import { useCallback } from 'react'; +import { parse, picklist } from 'valibot'; + +import useCarouselActionsContext from './private/useCarouselActionsContext'; + +const directionSchema = picklist(['next', 'previous']); + +/** + * Returns a function that scrolls to the next or previous item in reading order and centers it, as far as the scrollable + * range allows. Items that cannot move into the center (clamped at an edge) are skipped. + */ +export default function useCarouselScrollBy(): (direction: 'next' | 'previous') => void { + const { scrollBy } = useCarouselActionsContext(); + + return useCallback(direction => scrollBy(parse(directionSchema, direction)), [scrollBy]); +} diff --git a/packages/component-carousel/src/useCarouselScrollToIndex.ts b/packages/component-carousel/src/useCarouselScrollToIndex.ts new file mode 100644 index 0000000000..093e9509b2 --- /dev/null +++ b/packages/component-carousel/src/useCarouselScrollToIndex.ts @@ -0,0 +1,13 @@ +import { useCallback } from 'react'; +import { integer, number, parse, pipe } from 'valibot'; + +import useCarouselActionsContext from './private/useCarouselActionsContext'; + +const indexSchema = pipe(number(), integer()); + +/** Returns a function that scrolls to the item and centers it, as far as the scrollable range allows. */ +export default function useCarouselScrollToIndex(): (index: number) => void { + const { scrollToIndex } = useCarouselActionsContext(); + + return useCallback(index => scrollToIndex(parse(indexSchema, index)), [scrollToIndex]); +} diff --git a/packages/component-carousel/src/useCarouselScrollableRef.ts b/packages/component-carousel/src/useCarouselScrollableRef.ts new file mode 100644 index 0000000000..5197960425 --- /dev/null +++ b/packages/component-carousel/src/useCarouselScrollableRef.ts @@ -0,0 +1,8 @@ +import { type RefCallback } from 'react'; + +import useCarouselActionsContext from './private/useCarouselActionsContext'; + +/** Returns the ref callback for the element that scrolls horizontally. */ +export default function useCarouselScrollableRef(): RefCallback { + return useCarouselActionsContext().scrollableRef; +} diff --git a/packages/component-carousel/src/useCarouselState.ts b/packages/component-carousel/src/useCarouselState.ts new file mode 100644 index 0000000000..5966f0d7d0 --- /dev/null +++ b/packages/component-carousel/src/useCarouselState.ts @@ -0,0 +1,15 @@ +import { useContext, useMemo } from 'react'; + +import { CarouselStateContext } from './private/CarouselContext'; +import { type CarouselState } from './types/CarouselState'; + +/** Returns whether the carousel overflows, is at either edge, or is scrolling. */ +export default function useCarouselState(): readonly [CarouselState] { + const state = useContext(CarouselStateContext); + + if (!state) { + throw new Error('botframework-webchat: Carousel hooks must be used under .'); + } + + return useMemo(() => Object.freeze([state] as const), [state]); +} diff --git a/packages/component-carousel/tsup.config.ts b/packages/component-carousel/tsup.config.ts new file mode 100644 index 0000000000..4b512cc5f4 --- /dev/null +++ b/packages/component-carousel/tsup.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'tsup'; + +import { applyConfig } from '../../tsup.base.config'; + +const commonConfig = applyConfig(config => ({ + ...config, + entry: { + 'botframework-webchat-component-carousel': './src/index.ts' + } +})); + +export default defineConfig([ + { + ...commonConfig, + format: 'esm', + onSuccess: 'touch ./package.json' + }, + { + ...commonConfig, + format: 'cjs', + target: [...commonConfig.target, 'es2019'] + } +]); diff --git a/packages/component/package.json b/packages/component/package.json index a1d53c50c0..f7f364dbe6 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -126,7 +126,7 @@ "precommit:eslint": "../../node_modules/.bin/eslint --report-unused-disable-directives --max-warnings 0", "precommit:typecheck": "tsc --project ./src --emitDeclarationOnly false --esModuleInterop true --noEmit --pretty false", "preversion": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../component-better-link/package.json\" \"../styles/package.json\" \"../api/package.json\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../component-better-link/package.json\" \"../component-carousel/package.json\" \"../styles/package.json\" \"../api/package.json\"" }, "pinDependencies": { "@types/jest": [ @@ -160,6 +160,7 @@ "localDependencies": { "@msinternal/botframework-webchat-base": "development", "@msinternal/botframework-webchat-component-better-link": "development", + "@msinternal/botframework-webchat-component-carousel": "development", "@msinternal/botframework-webchat-react-hooks": "development", "@msinternal/botframework-webchat-react-valibot": "development", "@msinternal/botframework-webchat-styles": "development", @@ -174,6 +175,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", diff --git a/packages/component/src/Activity/CarouselFilmStrip.module.css b/packages/component/src/Activity/CarouselFilmStrip.module.css index db44ceea78..a13aa150a7 100644 --- a/packages/component/src/Activity/CarouselFilmStrip.module.css +++ b/packages/component/src/Activity/CarouselFilmStrip.module.css @@ -4,9 +4,13 @@ -ms-overflow-style: none; overflow-x: scroll; overflow-y: hidden; + overscroll-behavior-x: contain; position: relative; /* This is to keep screen reader text in the destinated area. */ + /* After a manual scroll, center the nearest card if it is close to the center. */ + scroll-snap-type: x proximity; touch-action: manipulation; -webkit-overflow-scrolling: touch; + z-index: 0; /* Flippers stack above the filmstrip. */ /* Browser quirks: Firefox has no way to hide scrollbar and while keeping it in function */ /* https://developer.mozilla.org/en-US/docs/Web/CSS/overflow */ @@ -54,6 +58,10 @@ list-style-type: none; margin: 0; padding: 0; + + > * { + scroll-snap-align: center; + } } .carousel-filmstrip__avatar { diff --git a/packages/component/src/Activity/CarouselFilmStrip.js b/packages/component/src/Activity/CarouselFilmStrip.tsx similarity index 68% rename from packages/component/src/Activity/CarouselFilmStrip.js rename to packages/component/src/Activity/CarouselFilmStrip.tsx index 39ba9e50fd..e0d9cc1e9d 100644 --- a/packages/component/src/Activity/CarouselFilmStrip.js +++ b/packages/component/src/Activity/CarouselFilmStrip.tsx @@ -1,11 +1,17 @@ /* eslint complexity: ["error", 30] */ -import { hooks } from 'botframework-webchat-api'; +import { + useCarouselItemContainerRef, + useCarouselScrollableRef, + useCarouselScrollToIndex +} from '@msinternal/botframework-webchat-component-carousel'; +import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; import { useStyles } from '@msinternal/botframework-webchat-styles/react'; -import { useItemContainerCallbackRef, useScrollableCallbackRef, useScrollTo } from 'react-film'; +import { hooks } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; import cx from 'classnames'; -import PropTypes from 'prop-types'; -import React, { useCallback } from 'react'; +import React, { memo, useCallback, type FocusEvent, type KeyboardEvent, type ReactNode } from 'react'; +import { boolean, custom, function_, is, literal, object, optional, pipe, readonly, string, union } from 'valibot'; import Bubble from './Bubble'; import CarouselFilmStripAttachment from './CarouselFilmStripAttachment'; @@ -24,15 +30,46 @@ const { useAvatarForBot, useAvatarForUser, useDirection, useLocalizer, useStyleO const CONTROLS_WITHOUT_ARROW_KEYS_SELECTOR = 'a[href], button, input[type="button"], input[type="checkbox"], input[type="reset"], input[type="submit"], [role="button"], [role="link"]'; -const CarouselFilmStrip = ({ - activity, - className = '', - hideTimestamp = false, - renderActivityStatus = false, - renderAttachment, - renderAvatar = false, - showCallout = false -}) => { +const carouselFilmStripPropsSchema = pipe( + object({ + activity: custom(value => is(object({}), value)), + className: optional(string()), + hideTimestamp: optional(boolean()), + // Legacy render functions use `custom()` to keep their signatures, as `createActivityPolymiddlewareFromLegacy.tsx` + // does: `pipe(function_(), args(), returns())` would type them as `(...args: unknown[]) => unknown`. + renderActivityStatus: optional( + union([ + literal(false), + custom<(options: { readonly hideTimestamp: boolean }) => ReactNode>(value => is(function_(), value)) + ]) + ), + renderAttachment: custom< + (options: { readonly activity: WebChatActivity; readonly attachment: unknown }) => ReactNode + >(value => is(function_(), value)), + renderAvatar: optional( + union([ + literal(false), + custom<(options: { readonly activity: WebChatActivity }) => ReactNode>(value => is(function_(), value)) + ]) + ), + showCallout: optional(boolean()) + }), + readonly() +); + +type CarouselFilmStripProps = InferReactProps; + +function CarouselFilmStrip(props: CarouselFilmStripProps) { + const { + activity, + className = '', + hideTimestamp = false, + renderActivityStatus = false, + renderAttachment, + renderAvatar = false, + showCallout = false + } = validateProps(carouselFilmStripPropsSchema, props); + const [{ bubbleNubOffset, bubbleNubSize, bubbleFromUserNubOffset, bubbleFromUserNubSize }] = useStyleOptions(); const [{ initials: botInitials }] = useAvatarForBot(); const [{ initials: userInitials }] = useAvatarForUser(); @@ -41,32 +78,34 @@ const CarouselFilmStrip = ({ const showActivityStatus = typeof renderActivityStatus === 'function'; const classNames = useStyles(styles); - const itemContainerCallbackRef = useItemContainerCallbackRef(); - const scrollableCallbackRef = useScrollableCallbackRef(); - const scrollTo = useScrollTo(); + const itemContainerRef = useCarouselItemContainerRef(); + const scrollableRef = useCarouselScrollableRef(); + const scrollToIndex = useCarouselScrollToIndex(); - // When the keyboard moves the focus into a card from outside of it (TAB or SHIFT-TAB), center the card with react-film, + // When the keyboard moves the focus into a card from outside of it (TAB or SHIFT-TAB), center the card, // as LEFT/RIGHT do, instead of leaving it partially visible. Clicking a card or button is not :focus-visible and does not // scroll; text inputs are, so the card is centered for typing. const handleAttachmentsFocus = useCallback( - ({ currentTarget, relatedTarget, target }) => { + ({ currentTarget, relatedTarget, target }: FocusEvent) => { const cards = Array.from(currentTarget.children); const index = cards.findIndex(card => card.contains(target)); - ~index && !cards[+index].contains(relatedTarget) && target.matches(':focus-visible') && scrollTo(() => index); + ~index && !cards[+index].contains(relatedTarget) && target.matches(':focus-visible') && scrollToIndex(index); }, - [scrollTo] + [scrollToIndex] ); // When a card itself has focus (not a control inside it), LEFT/RIGHT move the focus to the previous/next card // (mirrored in RTL), HOME/END to the first/last card. This is a shortcut: every card is still a tab stop. const handleAttachmentsKeyDown = useCallback( - event => { + (event: KeyboardEvent) => { const { altKey, ctrlKey, currentTarget, key, metaKey, shiftKey, target } = event; - const cards = Array.from(currentTarget.children); + const cards = Array.from(currentTarget.children).filter( + (card): card is HTMLElement => card instanceof HTMLElement + ); - const index = cards.indexOf(target); + const index = cards.findIndex(card => card === target); const rtl = direction === 'rtl'; if (altKey || ctrlKey || metaKey || shiftKey) { @@ -75,8 +114,9 @@ const CarouselFilmStrip = ({ if (!~index) { // On a button, link, or checkbox inside a card, LEFT/RIGHT do nothing. Otherwise, the browser would scroll the - // carousel and react-film would center the card again, or, on a held key, scroll the control out of view. + // carousel, possibly to the next snap position, and scroll the control out of view. (key === 'ArrowLeft' || key === 'ArrowRight') && + target instanceof Element && target.matches(CONTROLS_WITHOUT_ARROW_KEYS_SELECTOR) && event.preventDefault(); @@ -110,23 +150,18 @@ const CarouselFilmStrip = ({ event.preventDefault(); - // Center the card with react-film, as the flippers do, instead of the native scroll on focus. + // Center the card, as the flippers do, instead of the native scroll on focus. cards[+nextIndex].focus({ preventScroll: true }); - scrollTo(() => nextIndex); + scrollToIndex(nextIndex); }, - [direction, scrollTo] + [direction, scrollToIndex] ); - const { - attachments = [], - channelData: { messageBack: { displayText: messageBackDisplayText } = {} } = {}, - from: { role } = {}, - text, - textFormat - } = activity; + const isMessageOrTyping = activity.type === 'message' || activity.type === 'typing'; - const activityDisplayText = messageBackDisplayText || text; - const fromUser = role === 'user'; + const activityDisplayText = isMessageOrTyping && (activity.channelData?.messageBack?.displayText || activity.text); + const attachments = (isMessageOrTyping && activity.attachments) || []; + const fromUser = activity.from?.role === 'user'; const [greetingAlt] = useLocalizeActivityGreetingAlt()(activity); @@ -165,7 +200,7 @@ const CarouselFilmStrip = ({ 'react-film__filmstrip', className )} - ref={scrollableCallbackRef} + ref={scrollableRef} >
@@ -182,13 +217,13 @@ const CarouselFilmStrip = ({ {renderAttachment({ activity, attachment: { content: activityDisplayText, - contentType: textFormatToContentType(textFormat) + contentType: textFormatToContentType('textFormat' in activity ? activity.textFormat : undefined) } })} @@ -202,7 +237,7 @@ const CarouselFilmStrip = ({ className={cx(classNames['carousel-filmstrip__attachments'], 'react-film__filmstrip__list')} onFocus={handleAttachmentsFocus} onKeyDown={handleAttachmentsKeyDown} - ref={itemContainerCallbackRef} + ref={itemContainerRef} > {attachments.map((attachment, index) => ( ); -}; - -CarouselFilmStrip.propTypes = { - activity: PropTypes.shape({ - attachments: PropTypes.array, - channelData: PropTypes.shape({ - messageBack: PropTypes.shape({ - displayText: PropTypes.string - }) - }), - from: PropTypes.shape({ - role: PropTypes.string.isRequired - }).isRequired, - text: PropTypes.string, - textFormat: PropTypes.string, - timestamp: PropTypes.string - }).isRequired, - className: PropTypes.string, - hideTimestamp: PropTypes.bool, - renderActivityStatus: PropTypes.oneOfType([PropTypes.oneOf([false]), PropTypes.func]), - renderAttachment: PropTypes.func.isRequired, - renderAvatar: PropTypes.oneOfType([PropTypes.oneOf([false]), PropTypes.func]), - showCallout: PropTypes.bool -}; - -export default CarouselFilmStrip; +} + +CarouselFilmStrip.displayName = 'CarouselFilmStrip'; + +export default memo(CarouselFilmStrip); +export { carouselFilmStripPropsSchema, type CarouselFilmStripProps }; diff --git a/packages/component/src/Activity/CarouselFlipper.module.css b/packages/component/src/Activity/CarouselFlipper.module.css new file mode 100644 index 0000000000..53e5549349 --- /dev/null +++ b/packages/component/src/Activity/CarouselFlipper.module.css @@ -0,0 +1,114 @@ +/* Geometry and timing are the same as react-film's, which rendered these flippers before. */ +:global(.webchat) .carousel-flipper { + --webchat__carousel-flipper--box-width: 60px; + --webchat__carousel-flipper--size: 40px; + + background: transparent; + border: 0; + height: 100%; + outline: 0; + overflow: hidden; + position: absolute; + top: 0; + touch-action: none; + user-select: none; + width: var(--webchat__carousel-flipper--box-width); + z-index: 1; + + &.carousel-flipper--next { + inset-inline-end: 0; + } + + &.carousel-flipper--previous { + inset-inline-start: 0; + } + + .carousel-flipper__slider { + /* Hidden: slid out of the button, the previous flipper towards inline-start, the next flipper towards inline-end. */ + --webchat__carousel-flipper__slider--offset: calc( + (var(--webchat__carousel-flipper--box-width) + var(--webchat__carousel-flipper--size)) / + -2 + ); + + align-items: center; + display: flex; + height: 100%; + justify-content: center; + position: absolute; + top: 0; + transition-delay: 1s; + transition-duration: 300ms; + transition-property: left, right; + width: 100%; + } + + &.carousel-flipper--next .carousel-flipper__slider { + inset-inline-end: var(--webchat__carousel-flipper__slider--offset); + } + + &.carousel-flipper--previous .carousel-flipper__slider { + inset-inline-start: var(--webchat__carousel-flipper__slider--offset); + } + + /* Focusing a flipper slides it in, unless it is at the edge. */ + &:focus .carousel-flipper__slider:not(.carousel-flipper__slider--at-edge) { + --webchat__carousel-flipper__slider--offset: 0px; + + transition-delay: 0s; + } + + .carousel-flipper__body { + background-color: var(--webchat__background--transcript-overlay-button); + border-radius: 50%; + color: var(--webchat__color--transcript-overlay-button); + font-family: 'Consolas', monospace; + font-size: 16px; + height: var(--webchat__carousel-flipper--size); + line-height: var(--webchat__carousel-flipper--size); + outline: 0; + overflow: hidden; + position: relative; + transition-duration: 100ms; + transition-property: background-color; + width: var(--webchat__carousel-flipper--size); + } + + &:disabled, + &[aria-disabled='true'] { + .carousel-flipper__body { + background-color: var(--webchat__background--transcript-overlay-button-disabled); + color: var(--webchat__color--transcript-overlay-button-disabled); + } + } + + &:focus .carousel-flipper__body { + background-color: var(--webchat__background--transcript-overlay-button-focus); + color: var(--webchat__color--transcript-overlay-button-focus, var(--webchat__color--transcript-overlay-button)); + transition-duration: 0s; + } + + &:hover .carousel-flipper__body { + background-color: var(--webchat__background--transcript-overlay-button-hover); + color: var(--webchat__color--transcript-overlay-button-hover, var(--webchat__color--transcript-overlay-button)); + transition-duration: 0s; + } + + &:focus-visible { + /* Show the focused flipper, including the one at the edge, so keyboard users can see where the focus is. */ + .carousel-flipper__slider { + --webchat__carousel-flipper__slider--offset: 0px; + + transition-delay: 0s; + } + + /* Two-tone focus indicator: visible against both the flipper and any content behind it. */ + .carousel-flipper__body { + box-shadow: 0 0 0 2px + var(--webchat__color--transcript-overlay-button-focus, var(--webchat__color--transcript-overlay-button)); + outline: var(--webchat__border-width--transcript-visual-keyboard-indicator) + var(--webchat__border-style--transcript-visual-keyboard-indicator) + var(--webchat__color--transcript-visual-keyboard-indicator); + outline-offset: 2px; + } + } +} diff --git a/packages/component/src/Activity/CarouselFlipper.tsx b/packages/component/src/Activity/CarouselFlipper.tsx new file mode 100644 index 0000000000..3160de24b3 --- /dev/null +++ b/packages/component/src/Activity/CarouselFlipper.tsx @@ -0,0 +1,79 @@ +import { useCarouselScrollBy } from '@msinternal/botframework-webchat-component-carousel'; +import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; +import { useStyles } from '@msinternal/botframework-webchat-styles/react'; +import { hooks } from 'botframework-webchat-api'; +import cx from 'classnames'; +import React, { memo, useCallback, type MouseEvent } from 'react'; +import { boolean, object, picklist, pipe, readonly, string } from 'valibot'; + +import styles from './CarouselFlipper.module.css'; + +const { useDirection } = hooks; + +const carouselFlipperPropsSchema = pipe( + object({ + 'aria-label': string(), + atEdge: boolean(), + mode: picklist(['next', 'previous']) + }), + readonly() +); + +type CarouselFlipperProps = InferReactProps; + +function CarouselFlipper(props: CarouselFlipperProps) { + const { 'aria-label': ariaLabel, atEdge, mode } = validateProps(carouselFlipperPropsSchema, props); + + const [direction] = useDirection(); + const classNames = useStyles(styles); + const scrollBy = useCarouselScrollBy(); + + const next = mode === 'next'; + + // Deprecated react-film class names are physical: in RTL, "previous" is on the right side. + const left = next === (direction === 'rtl'); + + const handleClick = useCallback( + ({ currentTarget, detail }: MouseEvent) => { + scrollBy(mode); + + // A mouse click moves the focus away, ENTER and SPACE (`detail` is 0) keep it on the button. + detail && currentTarget.blur(); + }, + [mode, scrollBy] + ); + + return ( + + ); +} + +CarouselFlipper.displayName = 'CarouselFlipper'; + +export default memo(CarouselFlipper); +export { carouselFlipperPropsSchema, type CarouselFlipperProps }; diff --git a/packages/component/src/Activity/CarouselLayout.js b/packages/component/src/Activity/CarouselLayout.js deleted file mode 100644 index d5164fffc5..0000000000 --- a/packages/component/src/Activity/CarouselLayout.js +++ /dev/null @@ -1,108 +0,0 @@ -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; -import { hooks } from 'botframework-webchat-api'; -import { - Composer as FilmComposer, - createBasicStyleSet as createBasicStyleSetForReactFilm, - Flipper, - useScrollBarWidth, - useScrolling, - useStyleSetClassNames as useReactFilmStyleSetClassNames -} from 'react-film'; - -import cx from 'classnames'; -import PropTypes from 'prop-types'; -import React, { memo, useMemo } from 'react'; - -import CarouselFilmStrip from './CarouselFilmStrip'; -import useNonce from '../hooks/internal/useNonce'; - -import styles from './CarouselLayout.module.css'; - -const { useDirection, useLocalizer, useStyleOptions } = hooks; - -const CarouselLayoutCore = ({ - activity, - hideTimestamp = false, - renderActivityStatus = false, - renderAttachment, - renderAvatar = false, - showCallout = true -}) => { - const [{ root: filmRootClassName }] = useReactFilmStyleSetClassNames(); - const [direction] = useDirection(); - const [scrollBarWidth] = useScrollBarWidth(); - const [scrolling] = useScrolling(); - const leftSideFlipper = direction === 'rtl' ? '>' : '<'; - const localize = useLocalizer(); - const rightSideFlipper = direction === 'rtl' ? '<' : '>'; - const classNames = useStyles(styles); - - const nextAlt = localize('CAROUSEL_FLIPPER_NEXT_ALT'); - const previousAlt = localize('CAROUSEL_FLIPPER_PREVIOUS_ALT'); - - const leftFlipperAriaLabel = direction === 'rtl' ? nextAlt : previousAlt; - const rightFlipperAriaLabel = direction === 'rtl' ? previousAlt : nextAlt; - - return ( -
-
- - {scrollBarWidth !== '100%' && ( - - - {leftSideFlipper} - - - {rightSideFlipper} - - - )} -
-
- ); -}; - -CarouselLayoutCore.propTypes = { - activity: PropTypes.shape({ - attachments: PropTypes.array - }).isRequired, - hideTimestamp: PropTypes.bool, - renderActivityStatus: PropTypes.oneOfType([PropTypes.oneOf([false]), PropTypes.func]), - renderAttachment: PropTypes.func.isRequired, - renderAvatar: PropTypes.oneOfType([PropTypes.oneOf([false]), PropTypes.func]), - showCallout: PropTypes.bool -}; - -const CarouselLayout = props => { - const { activity: { attachments = [] } = {} } = props; - const [direction] = useDirection(); - const [nonce] = useNonce(); - const filmStyleSet = useMemo(() => createBasicStyleSetForReactFilm({ cursor: null }), []); - const [{ stylesRoot }] = useStyleOptions(); - const styleOptions = useMemo(() => ({ stylesRoot }), [stylesRoot]); - - return ( - - - - ); -}; - -CarouselLayout.propTypes = { - ...CarouselLayoutCore.propTypes -}; - -export default memo(CarouselLayout); diff --git a/packages/component/src/Activity/CarouselLayout.module.css b/packages/component/src/Activity/CarouselLayout.module.css index 33a1e7b5d8..c5405d2186 100644 --- a/packages/component/src/Activity/CarouselLayout.module.css +++ b/packages/component/src/Activity/CarouselLayout.module.css @@ -2,53 +2,19 @@ overflow: hidden; position: relative; - :global(.react-film__flipper) { - :global(.react-film__flipper__body) { - background-color: var(--webchat__background--transcript-overlay-button); - color: var(--webchat__color--transcript-overlay-button); - outline: 0; - } - - &:disabled, - &[aria-disabled='true'] { - :global(.react-film__flipper__body) { - background-color: var(--webchat__background--transcript-overlay-button-disabled); - color: var(--webchat__color--transcript-overlay-button-disabled); - } - } - - &:focus :global(.react-film__flipper__body) { - background-color: var(--webchat__background--transcript-overlay-button-focus); - color: var(--webchat__color--transcript-overlay-button-focus, var(--webchat__color--transcript-overlay-button)); - } - - &:hover :global(.react-film__flipper__body) { - background-color: var(--webchat__background--transcript-overlay-button-hover); - color: var(--webchat__color--transcript-overlay-button-hover, var(--webchat__color--transcript-overlay-button)); - } - - &:focus-visible { - /* Show the focused flipper, including the one hidden at the edge, so keyboard users can see where the focus is. */ - :global(.react-film__main__slider) { + .carousel-layout__main { + overflow: hidden; + position: relative; + + /* While the pointer is over the carousel or it is scrolling, flippers slide in and out without delay, except at the edge. */ + &:hover, + &.carousel-layout__main--scrolling { + :global(.carousel-flipper__slider) { transition-delay: 0s; - } - - :global(.react-film__main__slider--left) { - left: 0; - } - - :global(.react-film__main__slider--right) { - right: 0; - } - /* Two-tone focus indicator: visible against both the flipper and any content behind it. */ - :global(.react-film__flipper__body) { - box-shadow: 0 0 0 2px - var(--webchat__color--transcript-overlay-button-focus, var(--webchat__color--transcript-overlay-button)); - outline: var(--webchat__border-width--transcript-visual-keyboard-indicator) - var(--webchat__border-style--transcript-visual-keyboard-indicator) - var(--webchat__color--transcript-visual-keyboard-indicator); - outline-offset: 2px; + &:not(:global(.carousel-flipper__slider--at-edge)) { + --webchat__carousel-flipper__slider--offset: 0px; + } } } } diff --git a/packages/component/src/Activity/CarouselLayout.tsx b/packages/component/src/Activity/CarouselLayout.tsx new file mode 100644 index 0000000000..b357692cba --- /dev/null +++ b/packages/component/src/Activity/CarouselLayout.tsx @@ -0,0 +1,79 @@ +import { CarouselComposer, useCarouselState } from '@msinternal/botframework-webchat-component-carousel'; +import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; +import { useStyles } from '@msinternal/botframework-webchat-styles/react'; +import { hooks } from 'botframework-webchat-api'; +import cx from 'classnames'; +import React, { Fragment, memo, useMemo } from 'react'; +import { omit, pipe, readonly } from 'valibot'; + +import CarouselFilmStrip, { carouselFilmStripPropsSchema } from './CarouselFilmStrip'; +import CarouselFlipper from './CarouselFlipper'; + +import styles from './CarouselLayout.module.css'; + +const { useLocalizer, usePonyfill } = hooks; + +// Same props as the filmstrip, except `className`. +const carouselLayoutPropsSchema = pipe(omit(carouselFilmStripPropsSchema.pipe[0], ['className']), readonly()); + +type CarouselLayoutProps = InferReactProps; + +function CarouselLayoutCore(props: CarouselLayoutProps) { + const { + activity, + hideTimestamp = false, + renderActivityStatus = false, + renderAttachment, + renderAvatar = false, + showCallout = true + } = validateProps(carouselLayoutPropsSchema, props); + + const [{ atEnd, atStart, overflowing, scrolling }] = useCarouselState(); + const classNames = useStyles(styles); + const localize = useLocalizer(); + + return ( +
+
+ + {overflowing && ( + + + + + )} +
+
+ ); +} + +CarouselLayoutCore.displayName = 'CarouselLayoutCore'; + +function CarouselLayout(props: CarouselLayoutProps) { + const [{ clearTimeout, setTimeout }] = usePonyfill(); + + const ponyfill = useMemo(() => Object.freeze({ clearTimeout, setTimeout }), [clearTimeout, setTimeout]); + + return ( + + + + ); +} + +CarouselLayout.displayName = 'CarouselLayout'; + +export default memo(CarouselLayout); +export { carouselLayoutPropsSchema, type CarouselLayoutProps }; diff --git a/packages/component/src/Styles/StyleSet/CarouselFlipper.ts b/packages/component/src/Styles/StyleSet/CarouselFlipper.ts index 184d21f200..de655948e9 100644 --- a/packages/component/src/Styles/StyleSet/CarouselFlipper.ts +++ b/packages/component/src/Styles/StyleSet/CarouselFlipper.ts @@ -2,7 +2,7 @@ import { StrictStyleOptions } from 'botframework-webchat-api'; /** * @deprecated Not used for rendering since 4.19.0 (PR #5668), customizing it has no effect. - * The live styles are in `packages/component/src/Activity/CarouselLayout.module.css`. + * The live styles are in `packages/component/src/Activity/CarouselFlipper.module.css`. * This style set will be removed on or after 2028-10-16. */ export default function CarouselFlipper({ diff --git a/packages/component/src/Styles/createStyleSet.ts b/packages/component/src/Styles/createStyleSet.ts index 1ffd81555e..5a63d2db37 100644 --- a/packages/component/src/Styles/createStyleSet.ts +++ b/packages/component/src/Styles/createStyleSet.ts @@ -100,7 +100,7 @@ export default function createStyleSet(styleOptions: StyleOptions) { carouselFilmStripAttachment: createCarouselFilmStripAttachment(strictStyleOptions), /** * @deprecated Not used for rendering since 4.19.0 (PR #5668), customizing it has no effect. - * The live styles are in `packages/component/src/Activity/CarouselLayout.module.css`. + * The live styles are in `packages/component/src/Activity/CarouselFlipper.module.css`. * This style set will be removed on or after 2028-10-16. */ carouselFlipper: createCarouselFlipper(strictStyleOptions), diff --git a/packages/test/page-object/src/globals/pageConditions/carouselSettled.js b/packages/test/page-object/src/globals/pageConditions/carouselSettled.js index 5ef1ade641..7e1e8a4ce0 100644 --- a/packages/test/page-object/src/globals/pageConditions/carouselSettled.js +++ b/packages/test/page-object/src/globals/pageConditions/carouselSettled.js @@ -13,8 +13,8 @@ export default function carouselSettled(carouselLayout) { return became( 'carousel stopped scrolling and all flippers are hidden', () => - !carouselLayout.querySelector('.react-film__main--scrolling') && - Array.from(carouselLayout.querySelectorAll('.react-film__flipper')).every(isFlipperHidden), + !carouselLayout.querySelector('.carousel-layout__main--scrolling') && + Array.from(carouselLayout.querySelectorAll('.carousel-flipper')).every(isFlipperHidden), 5000 ); } diff --git a/packages/test/page-object/src/globals/testHelpers/index.js b/packages/test/page-object/src/globals/testHelpers/index.js index cd7d23b028..8521b86016 100644 --- a/packages/test/page-object/src/globals/testHelpers/index.js +++ b/packages/test/page-object/src/globals/testHelpers/index.js @@ -19,6 +19,7 @@ import getFocusedAccessibilityNode from './getFocusedAccessibilityNode'; import getRenderedColor from './getRenderedColor'; import isCardInView from './isCardInView'; import isFlipperHidden from './isFlipperHidden'; +import isFlipperShown from './isFlipperShown'; import iterateAsyncIterable from './iterateAsyncIterable'; import preloadImages from './preloadImages'; import shareObservable from './shareObservable'; @@ -47,6 +48,7 @@ export { getRenderedColor, isCardInView, isFlipperHidden, + isFlipperShown, iterateAsyncIterable, preloadImages, shareObservable, diff --git a/packages/test/page-object/src/globals/testHelpers/isFlipperHidden.js b/packages/test/page-object/src/globals/testHelpers/isFlipperHidden.js index 261e52b3c0..00458afbff 100644 --- a/packages/test/page-object/src/globals/testHelpers/isFlipperHidden.js +++ b/packages/test/page-object/src/globals/testHelpers/isFlipperHidden.js @@ -4,11 +4,11 @@ * The flipper is hidden when its body has slid out of the flipper button: 1 second after scrolling stops, the body slides * out in 300 milliseconds. * - * @param {Element} flipper - The flipper button (`.react-film__flipper`). + * @param {Element} flipper - The flipper button (`.carousel-flipper`). * @returns {boolean} `true` if the flipper is hidden, otherwise `false`. */ export default function 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; diff --git a/packages/test/page-object/src/globals/testHelpers/isFlipperShown.js b/packages/test/page-object/src/globals/testHelpers/isFlipperShown.js new file mode 100644 index 0000000000..e808ef2f8a --- /dev/null +++ b/packages/test/page-object/src/globals/testHelpers/isFlipperShown.js @@ -0,0 +1,15 @@ +/** + * Checks whether a flipper of a carousel has slid in completely: its body is centered in the flipper button. + * + * A flipper slides in within 300 milliseconds, when the pointer is over the carousel, the carousel is scrolling, or the + * flipper has focus. + * + * @param {Element} flipper - The flipper button (`.carousel-flipper`). + * @returns {boolean} `true` if the flipper has slid in completely, otherwise `false`. + */ +export default function isFlipperShown(flipper) { + const bodyRect = flipper.querySelector('.carousel-flipper__body').getBoundingClientRect(); + const buttonRect = flipper.getBoundingClientRect(); + + return Math.abs(bodyRect.left + bodyRect.right - buttonRect.left - buttonRect.right) < 1; +}