diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index b60e30629644d..bfdab79536c1f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -98,6 +98,13 @@ .interactive-session .chat-question-carousel-container .rendered-markdown a:hover, .interactive-session .chat-question-carousel-container .rendered-markdown a:active { color: var(--vscode-textLink-activeForeground); + text-decoration: underline; + text-decoration-thickness: 2px; +} + +.interactive-session .chat-question-carousel-container .rendered-markdown a:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; } .interactive-session .chat-question-carousel-container.chat-question-carousel-collapsed { diff --git a/test/componentFixtures/playwright/tests/chatQuestionCarouselLinks.spec.ts b/test/componentFixtures/playwright/tests/chatQuestionCarouselLinks.spec.ts new file mode 100644 index 0000000000000..406391df1f2d1 --- /dev/null +++ b/test/componentFixtures/playwright/tests/chatQuestionCarouselLinks.spec.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect, type Page, test } from '@playwright/test'; +import { openFixture } from './utils.js'; + +interface LinkStyles { + readonly outlineColor: string; + readonly outlineStyle: string; + readonly outlineWidth: string; + readonly textDecorationLine: string; + readonly textDecorationThickness: string; +} + +async function getLinkStyles(page: Page): Promise { + return page.locator('.chat-question-carousel-message a').first().evaluate(element => { + const style = getComputedStyle(element); + return { + outlineColor: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + textDecorationLine: style.textDecorationLine, + textDecorationThickness: style.textDecorationThickness, + }; + }); +} + +async function focusLinkWithKeyboard(page: Page): Promise { + const link = page.locator('.chat-question-carousel-message a').first(); + for (let attempt = 0; attempt < 20; attempt++) { + await page.keyboard.press('Tab'); + if (await link.evaluate(element => element.matches(':focus-visible'))) { + return; + } + } + throw new Error('Could not focus the carousel link using the keyboard'); +} + +async function getActiveLinkStyles(page: Page): Promise { + const linkSelector = '.chat-question-carousel-message a'; + const link = page.locator(linkSelector).first(); + await link.hover(); + await page.mouse.down(); + try { + await page.mouse.move(0, 0); + const pseudoState = await link.evaluate(element => ({ + active: element.matches(':active'), + hover: element.matches(':hover'), + })); + expect(pseudoState).toEqual({ active: true, hover: false }); + const styles = await getLinkStyles(page); + return styles; + } finally { + await page.mouse.up(); + } +} + +for (const theme of [ + { id: 'Dark', highContrast: false }, + { id: 'Light', highContrast: false }, + { id: 'DarkHighContrast', highContrast: true }, + { id: 'LightHighContrast', highContrast: true }, +]) { + test(`chat question carousel links expose interaction states in ${theme.id}`, async ({ page }) => { + await openFixture(page, `chat/chatQuestionCarousel/MarkdownLinks/${theme.id}`, '.chat-question-carousel-container'); + + const link = page.locator('.chat-question-carousel-message a').first(); + const rest = await getLinkStyles(page); + + await link.hover(); + const hover = await getLinkStyles(page); + + const active = await getActiveLinkStyles(page); + + await focusLinkWithKeyboard(page); + const keyboardFocus = await getLinkStyles(page); + const focusBorder = await link.evaluate(element => { + const probe = document.createElement('span'); + probe.style.color = 'var(--vscode-focusBorder)'; + element.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }); + + expect({ + rest: { + textDecorationLine: rest.textDecorationLine, + textDecorationThickness: rest.textDecorationThickness, + }, + hover: { + textDecorationLine: hover.textDecorationLine, + textDecorationThickness: hover.textDecorationThickness, + }, + active: { + textDecorationLine: active.textDecorationLine, + textDecorationThickness: active.textDecorationThickness, + }, + keyboardFocus: { + outlineColor: keyboardFocus.outlineColor, + outlineStyle: keyboardFocus.outlineStyle, + outlineWidth: keyboardFocus.outlineWidth, + textDecorationLine: keyboardFocus.textDecorationLine, + textDecorationThickness: keyboardFocus.textDecorationThickness, + }, + }).toEqual({ + rest: { + textDecorationLine: theme.highContrast ? 'underline' : 'none', + textDecorationThickness: 'auto', + }, + hover: { + textDecorationLine: 'underline', + textDecorationThickness: '2px', + }, + active: { + textDecorationLine: 'underline', + textDecorationThickness: '2px', + }, + keyboardFocus: { + outlineColor: focusBorder, + outlineStyle: 'solid', + outlineWidth: '1px', + textDecorationLine: theme.highContrast ? 'underline' : 'none', + textDecorationThickness: 'auto', + }, + }); + }); +}