Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Legends:
- Fixed timestamps always using the 12-hour clock, such as "4:41 PM" instead of "16:41" in German, in PR [#5920](https://github.com/microsoft/BotFramework-WebChat/pull/5920), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed screen readers not saying "Click to interact." for messages with links, in PR [#5927](https://github.com/microsoft/BotFramework-WebChat/pull/5927), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed carousel cards focused with <kbd>TAB</kbd> staying partially out of view, in PR [#5926](https://github.com/microsoft/BotFramework-WebChat/pull/5926), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed <kbd>LEFT</kbd>/<kbd>RIGHT</kbd> on a button inside a carousel card scrolling the carousel, in PR [#5917](https://github.com/microsoft/BotFramework-WebChat/pull/5917), by [@OEvgeny](https://github.com/OEvgeny)

## [4.19.2] - 2026-10-08

Expand Down
236 changes: 236 additions & 0 deletions __tests__/html2/carousel/navigation.arrowKeys.button.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"botframework-webchat/": "/__dist__/packages/bundle/static/botframework-webchat/",
"react": "/__dist__/packages/bundle/static/react.js",
"react-dom": "/__dist__/packages/bundle/static/react-dom.js",
"react-dom/client": "/__dist__/packages/bundle/static/react-dom/client.js"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, ReactWebChat } from 'botframework-webchat';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';

// TODO: This is for `createDirectLineEmulator` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions };

run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();

createRoot(document.getElementById('webchat')).render(
createElement(ReactWebChat, { directLine, store, styleOptions: { hideSendBox: true } })
);

await pageConditions.uiConnected();

const createHeroCard = index => ({
content: {
buttons: [{ title: `Buy item ${index}`, type: 'imBack', value: `Buy item ${index}` }],
text: 'Price: $XXX.XX USD',
title: `Details about item ${index}`
},
contentType: 'application/vnd.microsoft.card.hero'
});

// GIVEN: A carousel with hero cards, each with a button, and an Adaptive Card whose container opens a URL.
await directLine.emulateIncomingActivity({
attachmentLayout: 'carousel',
attachments: [
createHeroCard(1),
createHeroCard(2),
{
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
body: [
{
items: [
{ size: 'large', text: 'Details about item 3', type: 'TextBlock', weight: 'bolder', wrap: true },
{ text: 'Price: $XXX.XX USD', type: 'TextBlock', wrap: true }
],
// Adaptive Cards render a container with Action.OpenUrl as `<div role="link" tabindex="0">`.
selectAction: { title: 'Open item 3', type: 'Action.OpenUrl', url: 'https://example.com/item/3' },
type: 'Container'
}
],
type: 'AdaptiveCard',
version: '1.5'
},
contentType: 'application/vnd.microsoft.card.adaptive'
},
createHeroCard(4)
],
from: { role: 'bot' },
type: 'message'
});

await pageConditions.numActivitiesShown(1);

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 secondCardButton = cards[1].querySelector('button');

const isCardInView = card => {
const cardRect = card.getBoundingClientRect();
const filmstripRect = carouselFilmstrip.getBoundingClientRect();

return cardRect.left >= filmstripRect.left && cardRect.right <= filmstripRect.right;
};

// 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 buttonRect = flipper.getBoundingClientRect();

return bodyRect.right <= buttonRect.left || bodyRect.left >= buttonRect.right;
};

const waitForCarouselSettled = () =>
waitFor(
() => {
expect(carouselLayout.querySelector('.react-film__main--scrolling')).toBeNull();
expect(flippers.every(isFlipperHidden)).toBe(true);
},
{ timeout: 5000 }
);

expect(cards).toHaveLength(4);
expect(secondCardButton).toHaveProperty('textContent', 'Buy item 2');

// WHEN: Focus on the first card via keyboard.
pageObjects.focusTranscript();
await host.sendKeys('ENTER');

// THEN: The first card has focus, the carousel is at the start.
expect(document.activeElement).toBe(cards[0]);
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(0);

await host.snapshot('local');

// WHEN: Press RIGHT.
await host.sendKeys('ARROW_RIGHT');

// THEN: The second card has focus and is scrolled to the center.
expect(document.activeElement).toBe(cards[1]);
await waitFor(() => expect(isCardInView(cards[1])).toBe(true));
await waitForCarouselSettled();

const centeredScrollLeft = carouselFilmstrip.scrollLeft;

expect(centeredScrollLeft).toBeGreaterThan(0);

await host.snapshot('local');

// WHEN: Press TAB.
await host.sendTab();

// THEN: The button inside the second card has focus, the carousel has not moved.
expect(document.activeElement).toBe(secondCardButton);
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(centeredScrollLeft);

await host.snapshot('local');

// From now on, record every scroll of the carousel and every key press.
const keyDownEvents = [];
let numScrolls = 0;

carouselFilmstrip.addEventListener('scroll', () => numScrolls++);
window.addEventListener('keydown', event => keyDownEvents.push(event));

// WHEN: Press LEFT on the button.
await host.sendKeys('ARROW_LEFT');

// THEN: The button keeps focus, the browser does not scroll the carousel.
// `defaultPrevented` is the real check: `scrollLeft` can be read before a native scroll would start.
expect(keyDownEvents).toHaveLength(1);
expect(keyDownEvents[0]).toHaveProperty('defaultPrevented', true);
expect(document.activeElement).toBe(secondCardButton);
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(centeredScrollLeft);

await host.snapshot('local');

// WHEN: Press RIGHT on the button.
await host.sendKeys('ARROW_RIGHT');

// THEN: The button keeps focus, the browser does not scroll the carousel.
expect(keyDownEvents).toHaveLength(2);
expect(keyDownEvents[1]).toHaveProperty('defaultPrevented', true);
expect(document.activeElement).toBe(secondCardButton);
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(centeredScrollLeft);
expect(numScrolls).toBe(0);

await host.snapshot('local');

// WHEN: Press SHIFT-TAB.
await host.sendShiftTab();

// THEN: The second card has focus, the carousel has not moved.
expect(document.activeElement).toBe(cards[1]);
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(centeredScrollLeft);

await host.snapshot('local');

// WHEN: Press RIGHT.
await host.sendKeys('ARROW_RIGHT');

// THEN: The third card (the Adaptive Card) has focus and is scrolled to the center.
expect(document.activeElement).toBe(cards[2]);
await waitFor(() => expect(isCardInView(cards[2])).toBe(true));
await waitForCarouselSettled();

const thirdCenteredScrollLeft = carouselFilmstrip.scrollLeft;
const thirdCardLink = cards[2].querySelector('[role="link"]');

expect(thirdCenteredScrollLeft).toBeGreaterThan(centeredScrollLeft);

await host.snapshot('local');

// WHEN: Press TAB.
await host.sendTab();

// THEN: The link container inside the third card has focus, the carousel has not moved.
expect(document.activeElement).toBe(thirdCardLink);
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(thirdCenteredScrollLeft);

await host.snapshot('local');

numScrolls = 0;

// WHEN: Press RIGHT on the link container.
await host.sendKeys('ARROW_RIGHT');

// THEN: The link container keeps focus, the browser does not scroll the carousel.
expect(keyDownEvents.at(-1)).toHaveProperty('key', 'ArrowRight');
expect(keyDownEvents.at(-1)).toHaveProperty('defaultPrevented', true);
expect(document.activeElement).toBe(thirdCardLink);
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(thirdCenteredScrollLeft);
expect(numScrolls).toBe(0);

await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 33 additions & 18 deletions __tests__/html2/carousel/navigation.arrowKeys.formControls.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,6 @@
{ timeout: 5000 }
);

const waitForCarouselScrolled = async () => {
await waitFor(() => expect(carouselLayout.querySelector('.react-film__main--scrolling')).not.toBeNull());
await waitForCarouselSettled();
};

// Focus should be on the card itself, not on the form controls inside it.
const expectFocusOnCard = async index => {
expect(document.activeElement).toBe(cards[index]);
Expand Down Expand Up @@ -208,33 +203,53 @@
await host.sendKeys('ARROW_RIGHT');
expect(document.activeElement).toBe(submitButton);

// RIGHT on a button keeps the browser default of scrolling the carousel, wait for the scroll to start and settle.
await waitForCarouselScrolled();
expect(document.activeElement).toBe(submitButton);
// RIGHT on a button does not scroll the carousel.
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(0);

await host.snapshot('local');

// WHEN: Press TAB twice to focus on the button inside the hero card.
await host.sendTab(2);
// WHEN: Press TAB to focus on the partially visible hero card.
await host.sendTab();

// THEN: The hero card should have focus and be scrolled fully into view.
await expectFocusOnCard(1);

const heroCardScrollLeft = carouselFilmstrip.scrollLeft;

expect(heroCardScrollLeft).toBeGreaterThan(0);

await host.snapshot('local');

// THEN: The button inside the hero card should have focus, TAB into the hero card scrolled it into view.
// WHEN: Press TAB to focus on the button inside the hero card.
await host.sendTab();

// THEN: The button inside the hero card should have focus, the carousel should not scroll.
expect(document.activeElement).toBe(heroCardButton);
await waitFor(() => expect(isCardInView(cards[1])).toBe(true));
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(heroCardScrollLeft);

await host.snapshot('local');

// WHEN: Press LEFT and RIGHT on the button inside the hero card.
// THEN: The button inside the hero card should keep focus.
// WHEN: Press LEFT on the button inside the hero card.
await host.sendKeys('ARROW_LEFT');
expect(document.activeElement).toBe(heroCardButton);

await waitForCarouselScrolled();
// THEN: The button inside the hero card should keep focus, the carousel should not scroll.
expect(document.activeElement).toBe(heroCardButton);
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(heroCardScrollLeft);

await host.snapshot('local');

// WHEN: Press RIGHT on the button inside the hero card.
await host.sendKeys('ARROW_RIGHT');
expect(document.activeElement).toBe(heroCardButton);

await waitForCarouselScrolled();
// THEN: The button inside the hero card should keep focus, the carousel should not scroll.
expect(document.activeElement).toBe(heroCardButton);
await waitForCarouselSettled();
expect(carouselFilmstrip.scrollLeft).toBe(heroCardScrollLeft);

await host.snapshot('local');

// WHEN: Press SHIFT-TAB to focus on the hero card, then press RIGHT.
await host.sendShiftTab();
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 17 additions & 1 deletion packages/component/src/Activity/CarouselFilmStrip.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ import styles from './CarouselFilmStrip.module.css';

const { useAvatarForBot, useAvatarForUser, useDirection, useLocalizer, useStyleOptions } = hooks;

// Controls without their own LEFT/RIGHT behavior. Inputs that use arrow keys (text, number, date, radio, range, select)
// and media are left out. Adaptive Cards render a `selectAction` as `[role="button"]` or, for Action.OpenUrl,
// `[role="link"]`.
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,
Expand Down Expand Up @@ -62,7 +68,17 @@ const CarouselFilmStrip = ({
const index = cards.indexOf(target);
const rtl = direction === 'rtl';

if (altKey || ctrlKey || metaKey || shiftKey || !~index) {
if (altKey || ctrlKey || metaKey || shiftKey) {
return;
}

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.
(key === 'ArrowLeft' || key === 'ArrowRight') &&
target.matches(CONTROLS_WITHOUT_ARROW_KEYS_SELECTOR) &&
event.preventDefault();

return;
}

Expand Down
Loading