From 61b46bc39515dd5a589f5e7a718802dc1ab06c6b Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 15:53:44 -0700 Subject: [PATCH 1/4] Convert CarouselFilmStrip to TypeScript with valibot props Props are validated with valibot in development builds instead of PropTypes, the activity is narrowed by type before reading message fields, and the component is memoized. No change in rendering. --- ...uselFilmStrip.js => CarouselFilmStrip.tsx} | 119 ++++++++++-------- 1 file changed, 65 insertions(+), 54 deletions(-) rename packages/component/src/Activity/{CarouselFilmStrip.js => CarouselFilmStrip.tsx} (75%) diff --git a/packages/component/src/Activity/CarouselFilmStrip.js b/packages/component/src/Activity/CarouselFilmStrip.tsx similarity index 75% rename from packages/component/src/Activity/CarouselFilmStrip.js rename to packages/component/src/Activity/CarouselFilmStrip.tsx index 39ba9e50fd..3c9d335f52 100644 --- a/packages/component/src/Activity/CarouselFilmStrip.js +++ b/packages/component/src/Activity/CarouselFilmStrip.tsx @@ -1,11 +1,13 @@ /* eslint complexity: ["error", 30] */ -import { hooks } from 'botframework-webchat-api'; +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 { useItemContainerCallbackRef, useScrollableCallbackRef, useScrollTo } from 'react-film'; +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 +26,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(); @@ -49,7 +82,7 @@ const CarouselFilmStrip = ({ // 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)); @@ -62,11 +95,13 @@ const CarouselFilmStrip = ({ // 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) { @@ -77,6 +112,7 @@ const CarouselFilmStrip = ({ // 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. (key === 'ArrowLeft' || key === 'ArrowRight') && + target instanceof Element && target.matches(CONTROLS_WITHOUT_ARROW_KEYS_SELECTOR) && event.preventDefault(); @@ -117,16 +153,11 @@ const CarouselFilmStrip = ({ [direction, scrollTo] ); - 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); @@ -182,13 +213,13 @@ const CarouselFilmStrip = ({ {renderAttachment({ activity, attachment: { content: activityDisplayText, - contentType: textFormatToContentType(textFormat) + contentType: textFormatToContentType('textFormat' in activity ? activity.textFormat : undefined) } })} @@ -235,29 +266,9 @@ const CarouselFilmStrip = ({ )} ); -}; - -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 }; From fc11f6a489e8e96105b557462bc7d1f052b51e78 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 14:47:52 -0700 Subject: [PATCH 2/4] Add private headless carousel package @msinternal/botframework-webchat-component-carousel tracks a horizontally scrolling element and its items (overflow, edges, scrolling flag) and scrolls item by item, in LTR and RTL. It has no markup and no styles and will replace react-film in Web Chat. --- lint-staged.config.js | 1 + package-lock.json | 40 ++++ package.json | 4 + packages/component-carousel/.eslintrc.yml | 14 ++ packages/component-carousel/.gitignore | 4 + packages/component-carousel/README.md | 42 ++++ packages/component-carousel/package.json | 82 ++++++++ .../src/CarouselComposer.tsx | 187 ++++++++++++++++++ packages/component-carousel/src/index.ts | 11 ++ .../src/private/CarouselContext.ts | 18 ++ .../src/private/computeEdges.spec.ts | 50 +++++ .../src/private/computeEdges.ts | 26 +++ .../src/private/computeItemPosition.spec.ts | 38 ++++ .../src/private/computeItemPosition.ts | 22 +++ .../src/private/findAdjacentPosition.spec.ts | 64 ++++++ .../src/private/findAdjacentPosition.ts | 23 +++ .../component-carousel/src/private/measure.ts | 65 ++++++ .../src/private/useCarouselActionsContext.ts | 13 ++ packages/component-carousel/src/tsconfig.json | 6 + .../src/types/CarouselState.ts | 15 ++ .../src/useCarouselItemContainerRef.ts | 8 + .../src/useCarouselScrollBy.ts | 16 ++ .../src/useCarouselScrollToIndex.ts | 13 ++ .../src/useCarouselScrollableRef.ts | 8 + .../src/useCarouselState.ts | 15 ++ packages/component-carousel/tsup.config.ts | 23 +++ packages/component/package.json | 4 +- 27 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 packages/component-carousel/.eslintrc.yml create mode 100644 packages/component-carousel/.gitignore create mode 100644 packages/component-carousel/README.md create mode 100644 packages/component-carousel/package.json create mode 100644 packages/component-carousel/src/CarouselComposer.tsx create mode 100644 packages/component-carousel/src/index.ts create mode 100644 packages/component-carousel/src/private/CarouselContext.ts create mode 100644 packages/component-carousel/src/private/computeEdges.spec.ts create mode 100644 packages/component-carousel/src/private/computeEdges.ts create mode 100644 packages/component-carousel/src/private/computeItemPosition.spec.ts create mode 100644 packages/component-carousel/src/private/computeItemPosition.ts create mode 100644 packages/component-carousel/src/private/findAdjacentPosition.spec.ts create mode 100644 packages/component-carousel/src/private/findAdjacentPosition.ts create mode 100644 packages/component-carousel/src/private/measure.ts create mode 100644 packages/component-carousel/src/private/useCarouselActionsContext.ts create mode 100644 packages/component-carousel/src/tsconfig.json create mode 100644 packages/component-carousel/src/types/CarouselState.ts create mode 100644 packages/component-carousel/src/useCarouselItemContainerRef.ts create mode 100644 packages/component-carousel/src/useCarouselScrollBy.ts create mode 100644 packages/component-carousel/src/useCarouselScrollToIndex.ts create mode 100644 packages/component-carousel/src/useCarouselScrollableRef.ts create mode 100644 packages/component-carousel/src/useCarouselState.ts create mode 100644 packages/component-carousel/tsup.config.ts 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", From 7d09670c942b41dba8a03f3268d40c17f9f11d1c Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 14:47:52 -0700 Subject: [PATCH 3/4] Render the attachment carousel without react-film The carousel uses the new headless carousel package and renders its own flippers (CarouselFlipper). Auto-center after a manual scroll becomes CSS scroll snapping (proximity). Flippers now appear after a resize without hovering, and are in logical order (previous, next) in RTL. The react-film__* class names are still emitted on the same elements, without styles. Tests and test helpers use the new class names. --- __tests__/html2/carousel/autoCenter.html | 97 +++++++++++++++ .../html2/carousel/autoCenter.html.snap-1.png | Bin 0 -> 7817 bytes .../carousel/flipperButton.focusVisible.html | 8 +- .../html2/carousel/flipperButton.resize.html | 98 +++++++++++++++ .../flipperButton.resize.html.snap-1.png | Bin 0 -> 9791 bytes .../flipperButton.resize.html.snap-2.png | Bin 0 -> 8398 bytes .../flipperButton.resize.html.snap-3.png | Bin 0 -> 9402 bytes .../carousel/flipperButton.tabOrder.html | 109 +++++++++++++++++ .../flipperButton.tabOrder.html.snap-1.png | Bin 0 -> 7857 bytes .../flipperButton.tabOrder.html.snap-2.png | Bin 0 -> 7858 bytes .../flipperButton.tabOrder.html.snap-3.png | Bin 0 -> 9940 bytes .../flipperButton.tabOrder.html.snap-4.png | Bin 0 -> 9928 bytes .../carousel/flipperButton.tabOrder.rtl.html | 9 ++ ...flipperButton.tabOrder.rtl.html.snap-1.png | Bin 0 -> 7656 bytes ...flipperButton.tabOrder.rtl.html.snap-2.png | Bin 0 -> 7667 bytes ...flipperButton.tabOrder.rtl.html.snap-3.png | Bin 0 -> 9741 bytes ...flipperButton.tabOrder.rtl.html.snap-4.png | Bin 0 -> 9775 bytes .../carousel/navigation.arrowKeys.button.html | 6 +- __tests__/html2/carousel/navigation.tab.html | 2 +- .../navigation.tab.scrollIntoView.html | 6 +- ...navigation.tab.scrollIntoView.pointer.html | 6 +- __tests__/html2/rtl/carousel.html | 3 +- .../src/Activity/CarouselFilmStrip.module.css | 8 ++ .../src/Activity/CarouselFilmStrip.tsx | 30 +++-- .../src/Activity/CarouselFlipper.module.css | 114 ++++++++++++++++++ .../src/Activity/CarouselFlipper.tsx | 79 ++++++++++++ .../component/src/Activity/CarouselLayout.js | 108 ----------------- .../src/Activity/CarouselLayout.module.css | 56 ++------- .../component/src/Activity/CarouselLayout.tsx | 79 ++++++++++++ .../src/Styles/StyleSet/CarouselFlipper.ts | 2 +- .../component/src/Styles/createStyleSet.ts | 2 +- .../globals/pageConditions/carouselSettled.js | 4 +- .../src/globals/testHelpers/index.js | 2 + .../globals/testHelpers/isFlipperHidden.js | 4 +- .../src/globals/testHelpers/isFlipperShown.js | 15 +++ 35 files changed, 660 insertions(+), 187 deletions(-) create mode 100644 __tests__/html2/carousel/autoCenter.html create mode 100644 __tests__/html2/carousel/autoCenter.html.snap-1.png create mode 100644 __tests__/html2/carousel/flipperButton.resize.html create mode 100644 __tests__/html2/carousel/flipperButton.resize.html.snap-1.png create mode 100644 __tests__/html2/carousel/flipperButton.resize.html.snap-2.png create mode 100644 __tests__/html2/carousel/flipperButton.resize.html.snap-3.png create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.html create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.html.snap-1.png create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.html.snap-2.png create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.html.snap-3.png create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.html.snap-4.png create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.rtl.html create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-1.png create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-2.png create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-3.png create mode 100644 __tests__/html2/carousel/flipperButton.tabOrder.rtl.html.snap-4.png create mode 100644 packages/component/src/Activity/CarouselFlipper.module.css create mode 100644 packages/component/src/Activity/CarouselFlipper.tsx delete mode 100644 packages/component/src/Activity/CarouselLayout.js create mode 100644 packages/component/src/Activity/CarouselLayout.tsx create mode 100644 packages/test/page-object/src/globals/testHelpers/isFlipperShown.js 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 0000000000000000000000000000000000000000..d36b43f83c28f05b82312b774a2bfb01e35944c9 GIT binary patch literal 7817 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 jIN=0*Ir+PKJPx|^#_IOPR{!_F2 { 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 0000000000000000000000000000000000000000..aaffb3cdd8a13721c2ced17b7c098ed656a9dc7e GIT binary patch literal 9791 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 VqZ1jE1Y7|b=$hzM+k*?CD3(^vrNbe#d z0#cI@Kza==p?8uqJ>%TRd&m8c^SU3ivdUa@e)HG974}F=nS~j~41qvcR8=18LLdxw z5Xi9?r;dRmGTxX~2;?k8^`RWnJ9T;VbZXZYt!-`m*N0z&QKi;h3BuMX?}e|iU&SaI z3knEe3Vu%ss@;pfbuTkS4eL@eD`-pX*X8y^D-_|j=#zvDiN9Jh@8-}19@bz*-8*AcCg@F(P zb9Am|idKdUDy6}TI(Bc|UsOu!@V9eQ!2UuWcuc;|r}|#2&IHF=Z6Z#;p#ZsulIJs zn-0I9*S4F&Ys;d_Y@+;JTVAtxjbaQlBG4tJvG|My_pJ#qrs;!_9ps@ z_sv}tOqiQ`8ZW(tZ2p{~s9Cxr_K2JAC4Sm{SmUvDE zvlw?!I1ClsUTtA@7)Kj&@;~|r)?C7~V!onsZ+#YTv)>f`aB-lh4o$?_ru+ODz0JF; zJ$c>ZQm#mL-vrscM|uZLoP*`oB5`roakw1oyR{g~$}>Oo zWLhSo+A^5?l{9+BFql8nrYm`CZ$2aAzN>s_Nc2hT)XKGV zKgu3BMC{NkFFc^s^(ZXUHyBEHEZK7;zl(T9IE1{`cJ5`}hQ zXx3lp(x%Niner#PZhB9?X1R>!V!whmtNtdj9tTVA%_6>&3D_^^CRP(A;so_Ow%}s! ztx?-em0Bw^P4pwGM40{gxa(kXO{<*Jf?KT5_AC9!huZh)yOx(pjduxb=F6mWA1wuJ zblqEWT_j*R4NYC7XCyFo@}ir%NrFw>L?#_`??&q2O8Zwu!$Kn+WgH)Xk$H2S zGL;yo^HvAjE8P7CC&5?RSM{z25>f^X2}9`||e zhNU^M(Phax3W&v6hjNP}A*yUwJuiEL+5i)e|D)5s2P;)O)NzYCUYE)sdE-~xpP-MU zpmr|J2igu6)6wbF^)|o|qlbvUGEYbOdN;yvxv&XNk7>>XJH*EY_Eg(MIE)Ln68zl! zbf+uOPIhO%E9}RXvH|Ps5EHXqYJ?$QLKwIImC2!`dX9;oc$H7v0{8xiSTnox@3f}{VNmr-3g~5g`i3i+Yvn$oXGTlDx9&`!`QFWc#}yame<-P>NZN8{*mEYKn8Cs>v*f-PTHhl} z-_sV}0`$@MFzZgz9qZba%HJY%(rqsNdO*_NzdqdVO1Vd$ihv_$B`}NGB)=UB7aLn& znXR_rGXTYbdz}`4cw%>;RBiJd%Wj5h6 zU?qH}zK2oDB)ure(m(13ZhqK)Gs7d#LPyoBY4(Ba(Pr%3MvbI9$$9Th5t@?n;|X

&xt|avsm_dsOW*2`)3d8NRnM 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 gf-jIx{(%@q2$E|%l++#z4nQEPidqkg + + + + + +

+ + + + 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 0000000000000000000000000000000000000000..597a0bef3c9477b6c9a37c4cd7c0dfcbe9a122ca GIT binary patch literal 7857 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 Ks5yV<@&5si=Sh=!7T3%L6|Hg7 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 LtIHKXdlB>>V3G{} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..463adb9d785359caf81d0a2e8909d8f31bae0aad GIT binary patch literal 9928 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c4c7695a0382fc7db032acefce43887ea24909a7 GIT binary patch literal 7656 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 S-2^-lx@KvAweIpCkNyV>bdd}I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a8e53ef95b51ff877d4811a74249e66c2700edd4 GIT binary patch literal 9741 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9411d5f2a5ec9118f4a9f843e52d368ca7a5dd6a GIT binary patch literal 9775 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 PzG2eSHqydqJPiLotLyr6 literal 0 HcmV?d00001 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/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.tsx b/packages/component/src/Activity/CarouselFilmStrip.tsx index 3c9d335f52..e0d9cc1e9d 100644 --- a/packages/component/src/Activity/CarouselFilmStrip.tsx +++ b/packages/component/src/Activity/CarouselFilmStrip.tsx @@ -1,11 +1,15 @@ /* eslint complexity: ["error", 30] */ +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 { hooks } from 'botframework-webchat-api'; import { type WebChatActivity } from 'botframework-webchat-core'; import cx from 'classnames'; -import { useItemContainerCallbackRef, useScrollableCallbackRef, useScrollTo } from 'react-film'; 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'; @@ -74,11 +78,11 @@ function CarouselFilmStrip(props: CarouselFilmStripProps) { 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( @@ -87,9 +91,9 @@ function CarouselFilmStrip(props: CarouselFilmStripProps) { 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 @@ -110,7 +114,7 @@ function CarouselFilmStrip(props: CarouselFilmStripProps) { 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) && @@ -146,11 +150,11 @@ function CarouselFilmStrip(props: CarouselFilmStripProps) { 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 isMessageOrTyping = activity.type === 'message' || activity.type === 'typing'; @@ -196,7 +200,7 @@ function CarouselFilmStrip(props: CarouselFilmStripProps) { 'react-film__filmstrip', className )} - ref={scrollableCallbackRef} + ref={scrollableRef} >

@@ -233,7 +237,7 @@ function CarouselFilmStrip(props: CarouselFilmStripProps) { className={cx(classNames['carousel-filmstrip__attachments'], 'react-film__filmstrip__list')} onFocus={handleAttachmentsFocus} onKeyDown={handleAttachmentsKeyDown} - ref={itemContainerCallbackRef} + ref={itemContainerRef} > {attachments.map((attachment, index) => ( ; + +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; +} From eadae468b1189e1455f0e86ac8a3b7e343d08cd1 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 17:01:06 -0700 Subject: [PATCH 4/4] Add the changelog fragment --- .changelog/5968-fix-carousel-without-react-film.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/5968-fix-carousel-without-react-film.md 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)