diff --git a/dev-packages/browser-integration-tests/suites/feedback/accessibility/init.js b/dev-packages/browser-integration-tests/suites/feedback/accessibility/init.js new file mode 100644 index 000000000000..b041bee3585a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/feedback/accessibility/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; +import { feedbackIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [feedbackIntegration({ colorScheme: 'light' })], +}); diff --git a/dev-packages/browser-integration-tests/suites/feedback/accessibility/template.html b/dev-packages/browser-integration-tests/suites/feedback/accessibility/template.html new file mode 100644 index 000000000000..d8619fa4ffcd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/feedback/accessibility/template.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/feedback/accessibility/test.ts b/dev-packages/browser-integration-tests/suites/feedback/accessibility/test.ts new file mode 100644 index 000000000000..4c8fb25907c5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/feedback/accessibility/test.ts @@ -0,0 +1,195 @@ +import { expect } from '@playwright/test'; +import type * as SentrySDK from '@sentry/browser'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipFeedbackTest } from '../../../utils/helpers'; + +sentryTest.beforeEach(async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipFeedbackTest()); + await page.goto(await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true })); +}); + +sentryTest('names the native modal and restores focus after repeated keyboard dismissal @firefox', async ({ page }) => { + const launcher = page.getByRole('button', { name: 'Report a Bug', exact: true }); + const dialog = page.locator('dialog'); + + for (const key of ['Escape', 'Enter', 'Escape']) { + await launcher.focus(); + await page.keyboard.press('Enter'); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveAccessibleName('Report a Bug'); + await expect(dialog).toHaveJSProperty('open', true); + expect(await dialog.evaluate(el => el.matches(':modal'))).toBe(true); + await expect(page.getByRole('textbox', { name: 'Description' })).toBeFocused(); + + await page.locator('#background').evaluate(el => (el as HTMLElement).focus()); + await expect(page.getByRole('textbox', { name: 'Description' })).toBeFocused(); + + if (key === 'Enter') { + await page.getByRole('button', { name: 'Cancel', exact: true }).focus(); + } else { + await page.getByRole('textbox', { name: 'Name', exact: true }).focus(); + } + await page.keyboard.press(key); + await expect(dialog).not.toBeVisible(); + await expect(launcher).toBeFocused(); + } +}); + +sentryTest('keeps keyboard navigation inside the modal @firefox', async ({ page }) => { + await page.getByRole('button', { name: 'Report a Bug', exact: true }).click(); + const cancel = page.getByRole('button', { name: 'Cancel', exact: true }); + await cancel.focus(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'Powered by Sentry' })).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(cancel).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('dialog')).not.toBeVisible(); +}); + +sentryTest('returns to a custom launcher inside another native modal @firefox', async ({ page }) => { + await page.evaluate(() => { + const { Sentry } = window as unknown as { Sentry: typeof SentrySDK }; + const parent = document.createElement('dialog'); + parent.id = 'parent-modal'; + document.body.appendChild(parent); + parent.appendChild(document.querySelector('#background')!); + parent.showModal(); + Sentry.getFeedback()!.attachTo('#background', { + formTitle: 'Custom feedback title', + showName: false, + showEmail: false, + showBranding: false, + enableScreenshot: false, + }); + }); + const launcher = page.getByRole('button', { name: 'Background control' }); + await launcher.click(); + await expect(page.getByRole('dialog', { name: 'Custom feedback title' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Description' })).toBeFocused(); + await page.getByRole('button', { name: 'Cancel', exact: true }).focus(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('textbox', { name: 'Description' })).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(page.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(launcher).toBeFocused(); + expect(await page.locator('#parent-modal').evaluate(el => el.matches(':modal'))).toBe(true); +}); + +sentryTest('dismisses on backdrop click and restores focus @firefox', async ({ page }) => { + const launcher = page.getByRole('button', { name: 'Report a Bug', exact: true }); + await launcher.focus(); + await page.keyboard.press('Enter'); + await page.locator('dialog').click({ position: { x: 1, y: 1 } }); + await expect(page.locator('dialog')).not.toBeVisible(); + await expect(launcher).toBeFocused(); +}); + +sentryTest( + 'programmatic close and removal release modal state and preserve body overflow @firefox', + async ({ page }) => { + const launcher = page.locator('#background'); + await launcher.focus(); + const form = await page.evaluateHandle(async () => { + const { Sentry } = window as unknown as { Sentry: typeof SentrySDK }; + document.body.style.overflow = 'scroll'; + const form = await Sentry.getFeedback()!.createForm(); + form.appendToDom(); + return form; + }); + + for (const remove of [false, true]) { + await form.evaluate(form => { + form.open(); + form.open(); + }); + await expect(page.getByRole('textbox', { name: 'Description' })).toBeFocused(); + await form.evaluate((form, remove) => (remove ? form.removeFromDom() : form.close()), remove); + await expect(page.locator('dialog:modal')).toHaveCount(0); + await expect(launcher).toBeFocused(); + expect(await page.evaluate(() => document.body.style.overflow)).toBe('scroll'); + } + await form.evaluate(form => { + form.appendToDom(); + form.open(); + }); + await expect(page.getByRole('textbox', { name: 'Description' })).toBeFocused(); + }, +); + +sentryTest('restores the launcher after successful submission @firefox', async ({ page }) => { + const launcher = page.getByRole('button', { name: 'Report a Bug', exact: true }); + await launcher.focus(); + await page.keyboard.press('Enter'); + await page.getByRole('textbox', { name: 'Description' }).fill('Feedback sent to the local test transport'); + await page.getByRole('button', { name: 'Send Bug Report' }).click(); + await expect(page.locator('dialog:modal')).toHaveCount(0); + await expect(page.locator('.success__content')).toBeVisible(); + await page.locator('.success__content').click(); + await expect(launcher).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('textbox', { name: 'Description' })).toBeFocused(); +}); + +sentryTest('preserves input border overrides @firefox', async ({ page }) => { + await page.locator('#sentry-feedback').evaluate(el => { + (el as HTMLElement).style.setProperty('--input-border', '2px solid rgb(0, 0, 255)'); + }); + await page.getByRole('button', { name: 'Report a Bug', exact: true }).click(); + await expect(page.locator('[name="name"]')).toHaveCSS('border-top-color', 'rgb(0, 0, 255)'); + await expect(page.locator('[name="name"]')).toHaveCSS('border-top-width', '2px'); +}); + +sentryTest('keeps the form usable without native dialog methods @firefox', async ({ page }) => { + await page.evaluate(() => { + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { value: undefined }); + Object.defineProperty(HTMLDialogElement.prototype, 'close', { value: undefined }); + }); + const launcher = page.getByRole('button', { name: 'Report a Bug', exact: true }); + for (const key of ['Escape', 'Enter']) { + await launcher.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog', { name: 'Report a Bug' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Description' })).toBeFocused(); + await page.getByRole('button', { name: 'Cancel', exact: true }).focus(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'Powered by Sentry' })).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press(key); + await expect(page.locator('dialog')).not.toBeVisible(); + await expect(launcher).toBeFocused(); + } +}); + +for (const scheme of ['light', 'dark'] as const) { + sentryTest(`enabled input boundaries have 3:1 contrast in ${scheme} mode @firefox`, async ({ page }) => { + await page.evaluate(scheme => { + const { Sentry } = window as unknown as { Sentry: typeof SentrySDK }; + Sentry.getFeedback()!.setTheme(scheme); + }, scheme); + await page.getByRole('button', { name: 'Report a Bug', exact: true }).click(); + await page.getByRole('button', { name: 'Cancel', exact: true }).focus(); + + for (const name of ['name', 'email', 'message']) { + const contrast = await page.locator(`[name="${name}"]`).evaluate(el => { + const rgb = (color: string): number[] => color.match(/[\d.]+/g)!.map(Number); + const background = rgb(getComputedStyle(el.closest('.dialog__content')!).backgroundColor); + const border = rgb(getComputedStyle(el).borderTopColor); + const alpha = border[3] ?? 1; + const composited = border.slice(0, 3).map((channel, i) => channel * alpha + background[i] * (1 - alpha)); + const luminance = (channels: number[]): number => + channels.slice(0, 3).reduce((sum, channel, i) => { + const value = channel / 255; + return ( + sum + (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) * [0.2126, 0.7152, 0.0722][i] + ); + }, 0); + const a = luminance(composited); + const b = luminance(background); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + }); + expect(contrast).toBeGreaterThanOrEqual(3); + } + }); +} diff --git a/packages/feedback/src/core/createMainStyles.ts b/packages/feedback/src/core/createMainStyles.ts index 36d38e62740a..3977f165448d 100644 --- a/packages/feedback/src/core/createMainStyles.ts +++ b/packages/feedback/src/core/createMainStyles.ts @@ -5,6 +5,7 @@ const PURPLE = 'rgba(88, 74, 192, 1)'; interface InternalTheme extends NonNullable { border: string; + inputBorder: string; interactiveFilter: string; } @@ -16,6 +17,7 @@ const DEFAULT_LIGHT: InternalTheme = { successColor: '#268d75', errorColor: '#df3338', border: '1.5px solid rgba(41, 35, 47, 0.13)', + inputBorder: '1.5px solid rgba(41, 35, 47, 0.6)', boxShadow: '0px 4px 24px 0px rgba(43, 34, 51, 0.12)', outline: '1px auto var(--accent-background)', interactiveFilter: 'brightness(95%)', @@ -28,6 +30,7 @@ const DEFAULT_DARK: InternalTheme = { successColor: '#2da98c', errorColor: '#f55459', border: '1.5px solid rgba(235, 230, 239, 0.15)', + inputBorder: '1.5px solid rgba(235, 230, 239, 0.6)', boxShadow: '0px 4px 24px 0px rgba(43, 34, 51, 0.12)', outline: '1px auto var(--accent-background)', interactiveFilter: 'brightness(150%)', @@ -42,6 +45,7 @@ function getThemedCssVariables(theme: InternalTheme): string { --success-color: ${theme.successColor}; --error-color: ${theme.errorColor}; --border: ${theme.border}; + --input-border: ${theme.inputBorder}; --box-shadow: ${theme.boxShadow}; --outline: ${theme.outline}; --interactive-filter: ${theme.interactiveFilter}; diff --git a/packages/feedback/src/modal/components/Dialog.css.ts b/packages/feedback/src/modal/components/Dialog.css.ts index 369321343f61..b1256bfe26a1 100644 --- a/packages/feedback/src/modal/components/Dialog.css.ts +++ b/packages/feedback/src/modal/components/Dialog.css.ts @@ -13,6 +13,8 @@ const DIALOG = ` padding: 0; height: 100vh; width: 100vw; + max-height: none; + max-width: none; color: var(--dialog-color, var(--foreground)); fill: var(--dialog-color, var(--foreground)); @@ -25,6 +27,10 @@ const DIALOG = ` transition: opacity 0.2s ease-in-out; } +.dialog::backdrop { + background: transparent; +} + .dialog__position { position: fixed; z-index: var(--z-index); diff --git a/packages/feedback/src/modal/components/Dialog.tsx b/packages/feedback/src/modal/components/Dialog.tsx index dceaec5e468b..cd5ff86e6f3b 100644 --- a/packages/feedback/src/modal/components/Dialog.tsx +++ b/packages/feedback/src/modal/components/Dialog.tsx @@ -1,7 +1,7 @@ import type { FeedbackFormData, FeedbackInternalOptions } from '@sentry/core'; import type { VNode } from 'preact'; import { Fragment, h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars -import { useCallback, useMemo, useState } from 'preact/hooks'; +import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks'; import { SUCCESS_MESSAGE_TIMEOUT } from '../../constants'; import type { Props as HeaderProps } from './DialogHeader'; import { DialogHeader } from './DialogHeader'; @@ -20,6 +20,35 @@ export function Dialog({ open, onFormSubmitted, ...props }: Props): VNode { const successIconHtml = useMemo(() => ({ __html: SuccessIcon().outerHTML }), []); const [timeoutId, setTimeoutId] = useState(null); + const dialogRef = useRef(null); + const titleId = useId(); + + useLayoutEffect(() => { + const dialog = dialogRef.current; + if (open && dialog && !dialog.open) { + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } else { + dialog.setAttribute('open', ''); + dialog.querySelector('[autofocus]')?.focus(); + } + } + return () => { + if (typeof dialog?.close === 'function') { + dialog.close(); + } else { + dialog?.removeAttribute('open'); + } + }; + }, [open, timeoutId]); + + useLayoutEffect(() => { + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [timeoutId]); const handleOnSuccessClick = useCallback(() => { if (timeoutId) { @@ -52,7 +81,41 @@ export function Dialog({ open, onFormSubmitted, ...props }: Props): VNode { ) : ( - + { + event.preventDefault(); + props.onFormClose(); + }} + onKeyDown={event => { + if (event.key === 'Escape' && typeof event.currentTarget.showModal !== 'function') { + event.preventDefault(); + props.onFormClose(); + return; + } + if (event.key !== 'Tab') { + return; + } + const controls = Array.from( + event.currentTarget.querySelectorAll('a[href], button, input, textarea, select, [tabindex]'), + ).filter( + control => control.tabIndex >= 0 && !control.matches(':disabled') && control.getClientRects().length > 0, + ); + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && event.target === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && event.target === last) { + event.preventDefault(); + first?.focus(); + } + }} + >
- +
diff --git a/packages/feedback/src/modal/components/DialogHeader.tsx b/packages/feedback/src/modal/components/DialogHeader.tsx index 8820b91d8a3a..f5d00659ab18 100644 --- a/packages/feedback/src/modal/components/DialogHeader.tsx +++ b/packages/feedback/src/modal/components/DialogHeader.tsx @@ -8,12 +8,14 @@ export interface Props { options: FeedbackInternalOptions; } -export function DialogHeader({ options }: Props): VNode { +export function DialogHeader({ options, titleId }: Props & { titleId: string }): VNode { const logoHtml = useMemo(() => ({ __html: SentryLogo().outerHTML }), []); return (

- {options.formTitle} + + {options.formTitle} + {options.showBranding ? ( { const style = createDialogStyles(options.styleNonce); let originalOverflow = ''; + let isOpen = false; + let previousFocus: Element | null = null; + + const restoreFocus = (): void => { + const target = previousFocus; + // The launcher is shown by the close/submitted callback after the dialog closes. + void Promise.resolve().then(() => { + if (!isOpen && target === previousFocus && target instanceof HTMLElement && target.isConnected) { + target.focus(); + } + }); + }; const dialog: ReturnType = { get el() { return el; @@ -43,20 +55,33 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => { } }, removeFromDom(): void { + dialog.close(); + render(null, el); el.remove(); style.remove(); - DOCUMENT.body.style.overflow = originalOverflow; }, open() { + if (isOpen) { + return; + } + previousFocus = DOCUMENT.activeElement; + while (previousFocus?.shadowRoot?.activeElement) { + previousFocus = previousFocus.shadowRoot.activeElement; + } + originalOverflow = DOCUMENT.body.style.overflow; + isOpen = true; renderContent(true); options.onFormOpen?.(); getClient()?.emit('openFeedbackWidget'); - originalOverflow = DOCUMENT.body.style.overflow; DOCUMENT.body.style.overflow = 'hidden'; }, close() { + if (isOpen) { + isOpen = false; + DOCUMENT.body.style.overflow = originalOverflow; + } renderContent(false); - DOCUMENT.body.style.overflow = originalOverflow; + restoreFocus(); }, }; @@ -72,12 +97,12 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => { defaultName={String((userKey && user?.[userKey.name]) || '')} defaultEmail={String((userKey && user?.[userKey.email]) || '')} onFormClose={() => { - renderContent(false); + dialog.close(); options.onFormClose?.(); }} onSubmit={sendFeedback} onSubmitSuccess={(data: FeedbackFormData, eventId: string) => { - renderContent(false); + dialog.close(); options.onSubmitSuccess?.(data, eventId); }} onSubmitError={(error: Error) => {