Skip to content
Open
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 @@ -63,6 +63,7 @@ Legends:
- Fixed screen readers announcing a single audio, video, or animation in a card as a list item, such as "1 of 1", fixes [#5722](https://github.com/microsoft/BotFramework-WebChat/issues/5722), in PR [#5903](https://github.com/microsoft/BotFramework-WebChat/pull/5903), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed video and animation cards disappearing in production builds when the video URL is relative or invalid, in PR [#5889](https://github.com/microsoft/BotFramework-WebChat/pull/5889), by [@OEvgeny](https://github.com/OEvgeny)
- 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 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)

## [4.19.2] - 2026-10-08

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,14 +217,18 @@
// WHEN: Press TAB twice to focus on the button inside the hero card.
await host.sendTab(2);

// THEN: The button inside the hero card should have focus.
// THEN: The button inside the hero card should have focus, TAB into the hero card scrolled it into view.
expect(document.activeElement).toBe(heroCardButton);
await waitFor(() => expect(isCardInView(cards[1])).toBe(true));
await waitForCarouselSettled();

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

await waitForCarouselScrolled();
expect(document.activeElement).toBe(heroCardButton);

await host.sendKeys('ARROW_RIGHT');
expect(document.activeElement).toBe(heroCardButton);
Expand Down
137 changes: 137 additions & 0 deletions __tests__/html2/carousel/navigation.tab.scrollIntoView.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<!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();

// GIVEN: A carousel of hero cards, each with a button.
await directLine.emulateIncomingActivity({
attachmentLayout: 'carousel',
attachments: [1, 2, 3].map(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'
})),
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 buttons = cards.map(card => card.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 expectCardInView = async index => {
await waitFor(() => expect(isCardInView(cards[index])).toBe(true));

// Wait for the scroll and the flippers to settle.
await waitFor(
() => {
expect(carouselLayout.querySelector('.react-film__main--scrolling')).toBeNull();
expect(flippers.every(isFlipperHidden)).toBe(true);
},
{ timeout: 5000 }
);

expect(isCardInView(cards[index])).toBe(true);
};

expect(cards).toHaveLength(3);
expect(carouselFilmstrip.scrollLeft).toBe(0);
expect(isCardInView(cards[1])).toBe(false); // The second card is partially visible.

// 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 expectCardInView(0);
expect(carouselFilmstrip.scrollLeft).toBe(0);
await host.snapshot('local');

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

// THEN: The "Buy item 1" button has focus, the carousel is at the start.
expect(document.activeElement).toBe(buttons[0]);
await expectCardInView(0);
expect(carouselFilmstrip.scrollLeft).toBe(0);
await host.snapshot('local');

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

// THEN: The second card has focus and is scrolled fully into view, as RIGHT would do.
expect(document.activeElement).toBe(cards[1]);
await expectCardInView(1);
expect(isCardInView(cards[0])).toBe(false);
await host.snapshot('local');

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

// THEN: The "Buy item 1" button has focus, the first card is scrolled back into view.
expect(document.activeElement).toBe(buttons[0]);
await expectCardInView(0);
expect(carouselFilmstrip.scrollLeft).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.
112 changes: 112 additions & 0 deletions __tests__/html2/carousel/navigation.tab.scrollIntoView.pointer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<!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();

// GIVEN: A carousel of hero cards, each with a button.
await directLine.emulateIncomingActivity({
attachmentLayout: 'carousel',
attachments: [1, 2, 3].map(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'
})),
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 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 expectCardInView = async index => {
await waitFor(() => expect(isCardInView(cards[index])).toBe(true));

// Wait for the scroll and the flippers to settle.
await waitFor(
() => {
expect(carouselLayout.querySelector('.react-film__main--scrolling')).toBeNull();
expect(flippers.every(isFlipperHidden)).toBe(true);
},
{ timeout: 5000 }
);

expect(isCardInView(cards[index])).toBe(true);
};

expect(cards).toHaveLength(3);
expect(carouselFilmstrip.scrollLeft).toBe(0);
expect(isCardInView(cards[1])).toBe(false); // The second card is partially visible.

// WHEN: Click on the visible part of the second card.
await host.clickAt(10, 10, cards[1]);

// Move the mouse away from the carousel, the flippers are shown while the mouse is over it.
await host.moveTo(10, 10, document.getElementById('webchat'));

// THEN: The second card has focus by pointer (not :focus-visible), the carousel stays at the start.
expect(document.activeElement).toBe(cards[1]);
expect(cards[1].matches(':focus-visible')).toBe(false);
await expectCardInView(0);
expect(carouselFilmstrip.scrollLeft).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.
15 changes: 15 additions & 0 deletions packages/component/src/Activity/CarouselFilmStrip.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ const CarouselFilmStrip = ({
const scrollableCallbackRef = useScrollableCallbackRef();
const scrollTo = useScrollTo();

// When the keyboard moves the focus into a card from outside of it (TAB or SHIFT-TAB), center the card with react-film,
// 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 }) => {
const cards = Array.from(currentTarget.children);

const index = cards.findIndex(card => card.contains(target));

~index && !cards[+index].contains(relatedTarget) && target.matches(':focus-visible') && scrollTo(() => index);
},
[scrollTo]
);

// 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(
Expand Down Expand Up @@ -167,6 +181,7 @@ const CarouselFilmStrip = ({
<div className={classNames['carousel-filmstrip__complimentary-content']}>
<ul
className={cx(classNames['carousel-filmstrip__attachments'], 'react-film__filmstrip__list')}
onFocus={handleAttachmentsFocus}
onKeyDown={handleAttachmentsKeyDown}
ref={itemContainerCallbackRef}
>
Expand Down
Loading