diff --git a/packages/browser/src/report-dialog-styles.ts b/packages/browser/src/report-dialog-styles.ts index b053c957..40eed3b5 100644 --- a/packages/browser/src/report-dialog-styles.ts +++ b/packages/browser/src/report-dialog-styles.ts @@ -278,6 +278,54 @@ textarea.${p}input { min-height: 8.2em; resize: vertical; } } .${p}confirm-discard { color: ${v('accent')}; } +/* While a send is in flight the form is dimmed and the viewer's infinity loader sits over it. */ +.${p}card[data-busy] .${p}bar, +.${p}card[data-busy] .${p}body, +.${p}card[data-busy] .${p}footer-row { opacity: 0.45; transition: opacity 120ms; } +.${p}busy { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: progress; +} +.${p}busy[hidden] { display: none; } +.${p}infinity { width: 80px; height: 40px; overflow: visible; display: block; } +.${p}infinity-track { + fill: none; + stroke: ${v('field-border')}; + stroke-width: 3; + stroke-linecap: round; + opacity: 0.5; +} +.${p}infinity-bar { + fill: none; + stroke: ${v('accent')}; + stroke-width: 3; + stroke-linecap: round; + stroke-dasharray: 25 75; + stroke-dashoffset: 0; + animation: ${p}infinity-travel 1.6s linear infinite; +} +@keyframes ${p}infinity-travel { to { stroke-dashoffset: -100; } } +/* Reduced motion means no motion (as with the card entry below): a static track with a part-drawn bar. */ +@media (prefers-reduced-motion: reduce) { + .${p}infinity-bar { animation: none; } +} +/* Visually hidden, still announced: the live region that speaks "Sending…". */ +.${p}announcer { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* On a phone-sized viewport the card is the mobile SDKs' full-screen form. */ @media (max-width: 480px) { .${p}overlay { padding: 0; } diff --git a/packages/browser/src/report-dialog.test.ts b/packages/browser/src/report-dialog.test.ts index 026a6a31..fd9956ec 100644 --- a/packages/browser/src/report-dialog.test.ts +++ b/packages/browser/src/report-dialog.test.ts @@ -511,6 +511,63 @@ describe('sending', () => { expect(await pending).toEqual({ ok: false }); }); + it('dims the form and shows the infinity loader over it while sending', async () => { + let finish: (value: UploadResult) => void = () => {}; + const { show, upload } = harness(); + upload.mockImplementation(() => new Promise((resolve) => (finish = resolve))); + const pending = show(); + const busy = q('[data-busy-overlay]'); + const announcer = q('[data-announcer]'); + expect(announcer.textContent).toBe(''); + const card = q('form'); + expect(busy.hasAttribute('hidden')).toBe(true); + expect(card.hasAttribute('data-busy')).toBe(false); + + send(); + + expect(busy.hasAttribute('hidden')).toBe(false); + expect(busy.getAttribute('aria-hidden')).toBe('true'); // visual only; the spoken state is the announcer + // the viewer's loader: a faint track and a travelling bar on the same lemniscate path + const paths = busy.querySelectorAll('svg path'); + expect(paths).toHaveLength(2); + expect(paths[0]?.getAttribute('d')).toBe(paths[1]?.getAttribute('d')); + expect(paths[1]?.getAttribute('class')).toContain('infinity-bar'); + + // spoken from an always-rendered live region OUTSIDE the form (no busy ancestor), its text set on send + expect(announcer.textContent).toBe('Sending…'); + expect(announcer.getAttribute('role')).toBe('status'); + expect(card.contains(announcer)).toBe(false); + expect(card.hasAttribute('aria-busy')).toBe(false); + // the powered-by link is out of the tab order while the form is locked + expect(q('[data-powered]').closest('[inert]')).not.toBeNull(); + // the sections are dimmed by the stylesheet (jsdom does not cascade shadow-root styles, so read the rule) + const css = REPORT_DIALOG_CSS.replace(/\s+/g, ' '); + for (const section of ['bar', 'body', 'footer-row']) { + expect(css).toContain( + `.bugsee-reporting-dialog-card[data-busy] .bugsee-reporting-dialog-${section}`, + ); + } + expect(css).toMatch(/\[data-busy\] \.bugsee-reporting-dialog-footer-row \{ opacity: 0\.45/); + // reduced motion turns the travelling bar off + expect(css).toMatch( + /prefers-reduced-motion: reduce\) \{ \.bugsee-reporting-dialog-infinity-bar \{ animation: none; \}/, + ); + + finish({ ok: true }); + await pending; + }); + + it('speaks the localized "sending" string', async () => { + const { show, upload } = harness(); + upload.mockImplementation(() => new Promise(() => {})); + void show({ strings: { sending: 'Wird gesendet…' } }); + + send(); + + expect(q('[data-announcer]').textContent).toBe('Wird gesendet…'); + expect(q('[data-action="send"]').getAttribute('aria-label')).toBe('Wird gesendet…'); + }); + it('closes and resolves a failed result rather than hanging when the upload throws', async () => { const { show, upload } = harness(); upload.mockRejectedValue(new Error('boom')); diff --git a/packages/browser/src/report-dialog.ts b/packages/browser/src/report-dialog.ts index a3b6129a..35001810 100644 --- a/packages/browser/src/report-dialog.ts +++ b/packages/browser/src/report-dialog.ts @@ -103,6 +103,10 @@ export interface ReportDialogDeps { } const SVG_NS = 'http://www.w3.org/2000/svg'; + +// The viewer's loading mark: one closed lemniscate (infinity sign) in a 100×50 box. +const INFINITY_PATH = + 'M20,25 C20,5 35,5 50,25 C65,45 80,45 80,25 C80,5 65,5 50,25 C35,45 20,45 20,25 Z'; // Material "bug_report" and "close" glyphs (Apache-2.0). const BUG_PATH = 'M20 8h-2.81a5.985 5.985 0 0 0-1.82-1.96L17 4.41 15.59 3l-2.17 2.17a6.002 6.002 0 0 0-2.83 0L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z'; @@ -162,6 +166,9 @@ interface Refs { send: HTMLButtonElement; close: HTMLButtonElement; confirm: HTMLElement; + busyOverlay: HTMLElement; + announcer: HTMLElement; + footer: HTMLElement; keep: HTMLButtonElement; discard: HTMLButtonElement; fields: Partial>; @@ -263,6 +270,12 @@ function showDialog( radio.disabled = value; } refs.close.disabled = value; + // Dim the form and cover it with the loader, so a send in flight is visible (not just a greyed button). + refs.form.toggleAttribute('data-busy', value); + refs.announcer.textContent = value ? strings.sending : ''; + // The powered-by link is the one control left enabled: keep it out of the tab order while sending. + refs.footer.toggleAttribute('inert', value); + refs.busyOverlay.toggleAttribute('hidden', !value); }; const showConfirm = (visible: boolean): void => { @@ -689,8 +702,34 @@ function build( hook(discardBody, 'confirm-body'); confirm.append(discardTitle, discardBody, actions); - form.append(bar, body, footerRow, confirm); - dialog.append(form); + // The "sending" cover: the viewer's infinity-sign loader (a faint track with a bar travelling along the + // same lemniscate; see the `infinity-bar` animation in the styles), shown over the dimmed form. + // Purely visual (`aria-hidden`): the spoken state is the persistent live region below, outside the form. + const busyOverlay = make('div', { 'aria-hidden': 'true', hidden: '' }); + hook(busyOverlay, 'busy'); + busyOverlay.setAttribute('data-busy-overlay', ''); + const loader = doc.createElementNS(SVG_NS, 'svg'); + loader.setAttribute('viewBox', '0 0 100 50'); + loader.setAttribute('aria-hidden', 'true'); + hook(loader, 'infinity'); + for (const name of ['infinity-track', 'infinity-bar']) { + const path = doc.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', INFINITY_PATH); + path.setAttribute('pathLength', '100'); + hook(path, name); + loader.append(path); + } + busyOverlay.append(loader); + + // The announcement: an always-rendered, initially EMPTY live region OUTSIDE the form (so nothing busy is + // its ancestor), whose text is set when a send starts — screen readers announce a text change in an + // exposed region far more reliably than a region that is merely unhidden. Visually hidden. + const announcer = make('div', { role: 'status', 'aria-live': 'polite' }); + hook(announcer, 'announcer'); + announcer.setAttribute('data-announcer', ''); + + form.append(bar, body, footerRow, confirm, busyOverlay); + dialog.append(form, announcer); shadow.append(dialog); return { @@ -700,6 +739,9 @@ function build( send, close, confirm, + busyOverlay, + announcer, + footer: footerRow, keep, discard, fields: fields as Refs['fields'],