Skip to content

Commit 705befe

Browse files
OEvgenyEugene Olonov
andauthored
Scroll carousel cards into view when focused with Tab (#5926)
* Scroll carousel cards into view when focused with TAB When the keyboard moves the focus into a carousel 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. Focus by pointer is not :focus-visible and does not scroll. * Add PR number to changelog --------- Co-authored-by: Eugene Olonov <eugeneolonov@microsoft.com>
1 parent 241d83c commit 705befe

10 files changed

Lines changed: 271 additions & 2 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Legends:
6262
- 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)
6363
- 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)
6464
- 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)
65+
- 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)
6566

6667
## [4.19.2] - 2026-10-08
6768

‎__tests__/html2/carousel/navigation.arrowKeys.formControls.html‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,14 +217,18 @@
217217
// WHEN: Press TAB twice to focus on the button inside the hero card.
218218
await host.sendTab(2);
219219

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

223225
// WHEN: Press LEFT and RIGHT on the button inside the hero card.
224226
// THEN: The button inside the hero card should keep focus.
225227
await host.sendKeys('ARROW_LEFT');
226228
expect(document.activeElement).toBe(heroCardButton);
227-
expect(carouselFilmstrip.scrollLeft).toBe(0);
229+
230+
await waitForCarouselScrolled();
231+
expect(document.activeElement).toBe(heroCardButton);
228232

229233
await host.sendKeys('ARROW_RIGHT');
230234
expect(document.activeElement).toBe(heroCardButton);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<!doctype html>
2+
<html lang="en-US">
3+
<head>
4+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
5+
</head>
6+
<body>
7+
<main id="webchat"></main>
8+
<script type="importmap">
9+
{
10+
"imports": {
11+
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
12+
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
13+
"botframework-webchat/": "/__dist__/packages/bundle/static/botframework-webchat/",
14+
"react": "/__dist__/packages/bundle/static/react.js",
15+
"react-dom": "/__dist__/packages/bundle/static/react-dom.js",
16+
"react-dom/client": "/__dist__/packages/bundle/static/react-dom/client.js"
17+
}
18+
}
19+
</script>
20+
<script type="module">
21+
import '/test-harness.mjs';
22+
import '/test-page-object.mjs';
23+
24+
import { waitFor } from '@testduet/wait-for';
25+
import { createStoreWithOptions, ReactWebChat } from 'botframework-webchat';
26+
import { createElement } from 'react';
27+
import { createRoot } from 'react-dom/client';
28+
29+
// TODO: This is for `createDirectLineEmulator` only, should find ways to eliminate this line.
30+
window.WebChat = { createStoreWithOptions };
31+
32+
run(async function () {
33+
const { directLine, store } = testHelpers.createDirectLineEmulator();
34+
35+
createRoot(document.getElementById('webchat')).render(
36+
createElement(ReactWebChat, { directLine, store, styleOptions: { hideSendBox: true } })
37+
);
38+
39+
await pageConditions.uiConnected();
40+
41+
// GIVEN: A carousel of hero cards, each with a button.
42+
await directLine.emulateIncomingActivity({
43+
attachmentLayout: 'carousel',
44+
attachments: [1, 2, 3].map(index => ({
45+
content: {
46+
buttons: [{ title: `Buy item ${index}`, type: 'imBack', value: `Buy item ${index}` }],
47+
text: 'Price: $XXX.XX USD',
48+
title: `Details about item ${index}`
49+
},
50+
contentType: 'application/vnd.microsoft.card.hero'
51+
})),
52+
from: { role: 'bot' },
53+
type: 'message'
54+
});
55+
56+
await pageConditions.numActivitiesShown(1);
57+
58+
const carouselLayout = document.querySelector('.carousel-layout');
59+
const carouselFilmstrip = carouselLayout.querySelector('.carousel-filmstrip');
60+
const cards = Array.from(carouselFilmstrip.querySelectorAll('[aria-roledescription="attachment"]'));
61+
const flippers = Array.from(carouselLayout.querySelectorAll('.react-film__flipper'));
62+
const buttons = cards.map(card => card.querySelector('button'));
63+
64+
const isCardInView = card => {
65+
const cardRect = card.getBoundingClientRect();
66+
const filmstripRect = carouselFilmstrip.getBoundingClientRect();
67+
68+
return cardRect.left >= filmstripRect.left && cardRect.right <= filmstripRect.right;
69+
};
70+
71+
// The flipper is hidden when its body has slid out of the flipper button (1s delay after scroll stops, then 300ms slide).
72+
const isFlipperHidden = flipper => {
73+
const bodyRect = flipper.querySelector('.react-film__flipper__body').getBoundingClientRect();
74+
const buttonRect = flipper.getBoundingClientRect();
75+
76+
return bodyRect.right <= buttonRect.left || bodyRect.left >= buttonRect.right;
77+
};
78+
79+
const expectCardInView = async index => {
80+
await waitFor(() => expect(isCardInView(cards[index])).toBe(true));
81+
82+
// Wait for the scroll and the flippers to settle.
83+
await waitFor(
84+
() => {
85+
expect(carouselLayout.querySelector('.react-film__main--scrolling')).toBeNull();
86+
expect(flippers.every(isFlipperHidden)).toBe(true);
87+
},
88+
{ timeout: 5000 }
89+
);
90+
91+
expect(isCardInView(cards[index])).toBe(true);
92+
};
93+
94+
expect(cards).toHaveLength(3);
95+
expect(carouselFilmstrip.scrollLeft).toBe(0);
96+
expect(isCardInView(cards[1])).toBe(false); // The second card is partially visible.
97+
98+
// WHEN: Focus on the first card via keyboard.
99+
pageObjects.focusTranscript();
100+
await host.sendKeys('ENTER');
101+
102+
// THEN: The first card has focus, the carousel is at the start.
103+
expect(document.activeElement).toBe(cards[0]);
104+
await expectCardInView(0);
105+
expect(carouselFilmstrip.scrollLeft).toBe(0);
106+
await host.snapshot('local');
107+
108+
// WHEN: Press TAB.
109+
await host.sendTab();
110+
111+
// THEN: The "Buy item 1" button has focus, the carousel is at the start.
112+
expect(document.activeElement).toBe(buttons[0]);
113+
await expectCardInView(0);
114+
expect(carouselFilmstrip.scrollLeft).toBe(0);
115+
await host.snapshot('local');
116+
117+
// WHEN: Press TAB.
118+
await host.sendTab();
119+
120+
// THEN: The second card has focus and is scrolled fully into view, as RIGHT would do.
121+
expect(document.activeElement).toBe(cards[1]);
122+
await expectCardInView(1);
123+
expect(isCardInView(cards[0])).toBe(false);
124+
await host.snapshot('local');
125+
126+
// WHEN: Press SHIFT-TAB.
127+
await host.sendShiftTab();
128+
129+
// THEN: The "Buy item 1" button has focus, the first card is scrolled back into view.
130+
expect(document.activeElement).toBe(buttons[0]);
131+
await expectCardInView(0);
132+
expect(carouselFilmstrip.scrollLeft).toBe(0);
133+
await host.snapshot('local');
134+
});
135+
</script>
136+
</body>
137+
</html>
8.23 KB
Loading
8.21 KB
Loading
7.7 KB
Loading
8.21 KB
Loading
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<!doctype html>
2+
<html lang="en-US">
3+
<head>
4+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
5+
</head>
6+
<body>
7+
<main id="webchat"></main>
8+
<script type="importmap">
9+
{
10+
"imports": {
11+
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
12+
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
13+
"botframework-webchat/": "/__dist__/packages/bundle/static/botframework-webchat/",
14+
"react": "/__dist__/packages/bundle/static/react.js",
15+
"react-dom": "/__dist__/packages/bundle/static/react-dom.js",
16+
"react-dom/client": "/__dist__/packages/bundle/static/react-dom/client.js"
17+
}
18+
}
19+
</script>
20+
<script type="module">
21+
import '/test-harness.mjs';
22+
import '/test-page-object.mjs';
23+
24+
import { waitFor } from '@testduet/wait-for';
25+
import { createStoreWithOptions, ReactWebChat } from 'botframework-webchat';
26+
import { createElement } from 'react';
27+
import { createRoot } from 'react-dom/client';
28+
29+
// TODO: This is for `createDirectLineEmulator` only, should find ways to eliminate this line.
30+
window.WebChat = { createStoreWithOptions };
31+
32+
run(async function () {
33+
const { directLine, store } = testHelpers.createDirectLineEmulator();
34+
35+
createRoot(document.getElementById('webchat')).render(
36+
createElement(ReactWebChat, { directLine, store, styleOptions: { hideSendBox: true } })
37+
);
38+
39+
await pageConditions.uiConnected();
40+
41+
// GIVEN: A carousel of hero cards, each with a button.
42+
await directLine.emulateIncomingActivity({
43+
attachmentLayout: 'carousel',
44+
attachments: [1, 2, 3].map(index => ({
45+
content: {
46+
buttons: [{ title: `Buy item ${index}`, type: 'imBack', value: `Buy item ${index}` }],
47+
text: 'Price: $XXX.XX USD',
48+
title: `Details about item ${index}`
49+
},
50+
contentType: 'application/vnd.microsoft.card.hero'
51+
})),
52+
from: { role: 'bot' },
53+
type: 'message'
54+
});
55+
56+
await pageConditions.numActivitiesShown(1);
57+
58+
const carouselLayout = document.querySelector('.carousel-layout');
59+
const carouselFilmstrip = carouselLayout.querySelector('.carousel-filmstrip');
60+
const cards = Array.from(carouselFilmstrip.querySelectorAll('[aria-roledescription="attachment"]'));
61+
const flippers = Array.from(carouselLayout.querySelectorAll('.react-film__flipper'));
62+
63+
const isCardInView = card => {
64+
const cardRect = card.getBoundingClientRect();
65+
const filmstripRect = carouselFilmstrip.getBoundingClientRect();
66+
67+
return cardRect.left >= filmstripRect.left && cardRect.right <= filmstripRect.right;
68+
};
69+
70+
// The flipper is hidden when its body has slid out of the flipper button (1s delay after scroll stops, then 300ms slide).
71+
const isFlipperHidden = flipper => {
72+
const bodyRect = flipper.querySelector('.react-film__flipper__body').getBoundingClientRect();
73+
const buttonRect = flipper.getBoundingClientRect();
74+
75+
return bodyRect.right <= buttonRect.left || bodyRect.left >= buttonRect.right;
76+
};
77+
78+
const expectCardInView = async index => {
79+
await waitFor(() => expect(isCardInView(cards[index])).toBe(true));
80+
81+
// Wait for the scroll and the flippers to settle.
82+
await waitFor(
83+
() => {
84+
expect(carouselLayout.querySelector('.react-film__main--scrolling')).toBeNull();
85+
expect(flippers.every(isFlipperHidden)).toBe(true);
86+
},
87+
{ timeout: 5000 }
88+
);
89+
90+
expect(isCardInView(cards[index])).toBe(true);
91+
};
92+
93+
expect(cards).toHaveLength(3);
94+
expect(carouselFilmstrip.scrollLeft).toBe(0);
95+
expect(isCardInView(cards[1])).toBe(false); // The second card is partially visible.
96+
97+
// WHEN: Click on the visible part of the second card.
98+
await host.clickAt(10, 10, cards[1]);
99+
100+
// Move the mouse away from the carousel, the flippers are shown while the mouse is over it.
101+
await host.moveTo(10, 10, document.getElementById('webchat'));
102+
103+
// THEN: The second card has focus by pointer (not :focus-visible), the carousel stays at the start.
104+
expect(document.activeElement).toBe(cards[1]);
105+
expect(cards[1].matches(':focus-visible')).toBe(false);
106+
await expectCardInView(0);
107+
expect(carouselFilmstrip.scrollLeft).toBe(0);
108+
await host.snapshot('local');
109+
});
110+
</script>
111+
</body>
112+
</html>
8.22 KB
Loading

‎packages/component/src/Activity/CarouselFilmStrip.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ const CarouselFilmStrip = ({
3838
const scrollableCallbackRef = useScrollableCallbackRef();
3939
const scrollTo = useScrollTo();
4040

41+
// When the keyboard moves the focus into a card from outside of it (TAB or SHIFT-TAB), center the card with react-film,
42+
// as LEFT/RIGHT do, instead of leaving it partially visible. Clicking a card or button is not :focus-visible and does not
43+
// scroll; text inputs are, so the card is centered for typing.
44+
const handleAttachmentsFocus = useCallback(
45+
({ currentTarget, relatedTarget, target }) => {
46+
const cards = Array.from(currentTarget.children);
47+
48+
const index = cards.findIndex(card => card.contains(target));
49+
50+
~index && !cards[+index].contains(relatedTarget) && target.matches(':focus-visible') && scrollTo(() => index);
51+
},
52+
[scrollTo]
53+
);
54+
4155
// When a card itself has focus (not a control inside it), LEFT/RIGHT move the focus to the previous/next card
4256
// (mirrored in RTL), HOME/END to the first/last card. This is a shortcut: every card is still a tab stop.
4357
const handleAttachmentsKeyDown = useCallback(
@@ -167,6 +181,7 @@ const CarouselFilmStrip = ({
167181
<div className={classNames['carousel-filmstrip__complimentary-content']}>
168182
<ul
169183
className={cx(classNames['carousel-filmstrip__attachments'], 'react-film__filmstrip__list')}
184+
onFocus={handleAttachmentsFocus}
170185
onKeyDown={handleAttachmentsKeyDown}
171186
ref={itemContainerCallbackRef}
172187
>

0 commit comments

Comments
 (0)