Skip to content

Commit a191a10

Browse files
OEvgenyEugene Olonov
andauthored
Show keyboard focus indicator on carousel flippers (#5876)
* Show keyboard focus indicator on carousel flippers (#5704, #5744) The "Previous" and "Next" flippers in carousel layout had `outline: 0` and only darkened their background on focus, the same as on hover. The flipper at the edge of the carousel (e.g. "Previous" at the start) stayed in the tab order but was slid out of view, so keyboard focus on it was invisible. - On `:focus-visible`, draw a two-tone ring around the flipper: a band in the flipper foreground color and an outline using the transcript keyboard indicator style options (2px solid black by default) - Always show the focused flipper, including the one hidden at the edge - Add html2 test for the default and Fluent themes * Add PR number to changelog --------- Co-authored-by: Eugene Olonov <eugeneolonov@microsoft.com>
1 parent 2474cd2 commit a191a10

8 files changed

Lines changed: 165 additions & 0 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Legends:
3434

3535
- Fixed the "Copied" confirmation of the Copy button disappearing too quickly: it is now shown for 5 seconds in all themes, fixes [#5857](https://github.com/microsoft/BotFramework-WebChat/issues/5857), in PR [#5872](https://github.com/microsoft/BotFramework-WebChat/pull/5872), by [@OEvgeny](https://github.com/OEvgeny)
3636
- Fixed screen readers repeating the full bot response when focus enters message actions such as Copy, Like, or Dislike, resolving [#5849](https://github.com/microsoft/BotFramework-WebChat/issues/5849), by [@asalsa](https://github.com/asalsa)
37+
- Fixed the keyboard focus indicator of the "Previous" and "Next" flipper buttons in carousel layout: they now show a visible focus ring, and the focused flipper is shown even at the start or end of the carousel, fixes [#5704](https://github.com/microsoft/BotFramework-WebChat/issues/5704) and [#5744](https://github.com/microsoft/BotFramework-WebChat/issues/5744), in PR [#5876](https://github.com/microsoft/BotFramework-WebChat/pull/5876), by [@OEvgeny](https://github.com/OEvgeny)
3738

3839
## [4.19.2] - 2026-10-08
3940

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<script>
5+
location = './flipperButton.focusVisible?variant=fluent';
6+
</script>
7+
</head>
8+
<body></body>
9+
</html>
14.7 KB
Loading
14.5 KB
Loading
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
"botframework-webchat-fluent-theme": "/__dist__/packages/fluent-theme/static/botframework-webchat-fluent-theme.js",
15+
"react": "/__dist__/packages/bundle/static/react.js",
16+
"react-dom": "/__dist__/packages/bundle/static/react-dom.js",
17+
"react-dom/client": "/__dist__/packages/bundle/static/react-dom/client.js"
18+
}
19+
}
20+
</script>
21+
<script type="module">
22+
import '/test-harness.mjs';
23+
import '/test-page-object.mjs';
24+
25+
import { waitFor } from '@testduet/wait-for';
26+
import { createStoreWithOptions, ReactWebChat } from 'botframework-webchat';
27+
import { FluentThemeProvider } from 'botframework-webchat-fluent-theme';
28+
import { createElement } from 'react';
29+
import { createRoot } from 'react-dom/client';
30+
31+
// TODO: This is for `createDirectLineEmulator` only, should find ways to eliminate this line.
32+
window.WebChat = { createStoreWithOptions };
33+
34+
run(async function () {
35+
const { directLine, store } = testHelpers.createDirectLineEmulator();
36+
const variant = new URLSearchParams(location.search).get('variant');
37+
const webChatElement = createElement(ReactWebChat, { directLine, store });
38+
39+
createRoot(document.getElementById('webchat')).render(
40+
variant ? createElement(FluentThemeProvider, { variant }, webChatElement) : webChatElement
41+
);
42+
43+
await pageConditions.uiConnected();
44+
45+
await directLine.emulateIncomingActivity({
46+
attachmentLayout: 'carousel',
47+
attachments: [1, 2, 3, 4].map(index => ({
48+
content: {
49+
buttons: [{ title: `Buy item ${index}`, type: 'imBack', value: `Buy item ${index}` }],
50+
subtitle: 'This is the subtitle',
51+
text: 'Price: $XXX.XX USD',
52+
title: `Details about item ${index}`
53+
},
54+
contentType: 'application/vnd.microsoft.card.hero'
55+
})),
56+
from: { role: 'bot' },
57+
type: 'message'
58+
});
59+
60+
await pageConditions.numActivitiesShown(1);
61+
62+
const nextFlipper = document.querySelector('.carousel-layout button[aria-label="Next"]');
63+
const previousFlipper = document.querySelector('.carousel-layout button[aria-label="Previous"]');
64+
65+
const getFocusIndicatorStyle = flipper => {
66+
const { boxShadow, outlineColor, outlineOffset, outlineStyle, outlineWidth } = getComputedStyle(
67+
flipper.querySelector('.react-film__flipper__body')
68+
);
69+
70+
return { boxShadow, outlineColor, outlineOffset, outlineStyle, outlineWidth };
71+
};
72+
73+
// The flipper is shown when its body has slid into the center of the flipper button.
74+
const getFlipperBodyOffset = flipper => {
75+
const bodyRect = flipper.querySelector('.react-film__flipper__body').getBoundingClientRect();
76+
const buttonRect = flipper.getBoundingClientRect();
77+
78+
return Math.round(bodyRect.left + bodyRect.width / 2 - (buttonRect.left + buttonRect.width / 2));
79+
};
80+
81+
// Two-tone focus indicator: an outer ring in the transcript keyboard indicator color (black, 2px),
82+
// separated from the flipper by a band in the flipper foreground color (white, 2px).
83+
// It has at least 3:1 contrast against the flipper and against any content behind it.
84+
const visibleFocusIndicatorStyle = {
85+
boxShadow: 'rgb(255, 255, 255) 0px 0px 0px 2px',
86+
outlineColor: 'rgb(0, 0, 0)',
87+
outlineOffset: '2px',
88+
outlineStyle: 'solid',
89+
outlineWidth: '2px'
90+
};
91+
92+
// GIVEN: The carousel is scrolled to the start, the "Previous" flipper is hidden.
93+
expect(document.querySelector('.carousel-filmstrip').scrollLeft).toBe(0);
94+
expect(previousFlipper.querySelector('.react-film__main__slider--hide')).toBeTruthy();
95+
96+
// WHEN: Focus on the first attachment of the carousel via keyboard.
97+
pageObjects.focusTranscript();
98+
await host.sendKeys('ENTER');
99+
100+
// WHEN: Press SHIFT-TAB to move focus to the "Next" flipper.
101+
await host.sendShiftTab();
102+
103+
// THEN: The "Next" flipper should be shown with a visible focus indicator.
104+
expect(document.activeElement).toBe(nextFlipper);
105+
expect(nextFlipper.matches(':focus-visible')).toBe(true);
106+
await waitFor(() => expect(getFlipperBodyOffset(nextFlipper)).toBe(0));
107+
expect(getFocusIndicatorStyle(nextFlipper)).toEqual(visibleFocusIndicatorStyle);
108+
109+
await host.snapshot('local');
110+
111+
// WHEN: Press SHIFT-TAB to move focus to the "Previous" flipper.
112+
await host.sendShiftTab();
113+
114+
// THEN: The "Previous" flipper should be shown with a visible focus indicator, although the carousel cannot scroll back.
115+
expect(document.activeElement).toBe(previousFlipper);
116+
expect(previousFlipper.matches(':focus-visible')).toBe(true);
117+
await waitFor(() => expect(getFlipperBodyOffset(previousFlipper)).toBe(0));
118+
expect(getFocusIndicatorStyle(previousFlipper)).toEqual(visibleFocusIndicatorStyle);
119+
120+
// THEN: The "Next" flipper should not show the focus indicator after losing focus.
121+
expect(getFocusIndicatorStyle(nextFlipper)).toHaveProperty('outlineStyle', 'none');
122+
123+
// THEN: The "Next" flipper should auto-hide after losing focus (1s delay, then 300ms slide out).
124+
await waitFor(() => expect(getFlipperBodyOffset(nextFlipper)).toBe(50), { timeout: 2000 });
125+
126+
await host.snapshot('local');
127+
});
128+
</script>
129+
</body>
130+
</html>
14.3 KB
Loading
14.1 KB
Loading

‎packages/component/src/Activity/CarouselLayout.module.css‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,30 @@
2626
background-color: var(--webchat__background--transcript-overlay-button-hover);
2727
color: var(--webchat__color--transcript-overlay-button-hover, var(--webchat__color--transcript-overlay-button));
2828
}
29+
30+
&:focus-visible {
31+
/* Show the focused flipper, including the one hidden at the edge, so keyboard users can see where the focus is. */
32+
:global(.react-film__main__slider) {
33+
transition-delay: 0s;
34+
}
35+
36+
:global(.react-film__main__slider--left) {
37+
left: 0;
38+
}
39+
40+
:global(.react-film__main__slider--right) {
41+
right: 0;
42+
}
43+
44+
/* Two-tone focus indicator: visible against both the flipper and any content behind it. */
45+
:global(.react-film__flipper__body) {
46+
box-shadow: 0 0 0 2px
47+
var(--webchat__color--transcript-overlay-button-focus, var(--webchat__color--transcript-overlay-button));
48+
outline: var(--webchat__border-width--transcript-visual-keyboard-indicator)
49+
var(--webchat__border-style--transcript-visual-keyboard-indicator)
50+
var(--webchat__color--transcript-visual-keyboard-indicator);
51+
outline-offset: 2px;
52+
}
53+
}
2954
}
3055
}

0 commit comments

Comments
 (0)