Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/browser/src/report-dialog-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
.${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;
}
Comment thread
krassx marked this conversation as resolved.
Resolved

/* On a phone-sized viewport the card is the mobile SDKs' full-screen form. */
@media (max-width: 480px) {
.${p}overlay { padding: 0; }
Expand Down
57 changes: 57 additions & 0 deletions packages/browser/src/report-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UploadResult>((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<UploadResult>(() => {}));
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'));
Expand Down
46 changes: 44 additions & 2 deletions packages/browser/src/report-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -162,6 +166,9 @@ interface Refs {
send: HTMLButtonElement;
close: HTMLButtonElement;
confirm: HTMLElement;
busyOverlay: HTMLElement;
announcer: HTMLElement;
footer: HTMLElement;
keep: HTMLButtonElement;
discard: HTMLButtonElement;
fields: Partial<Record<FieldName, HTMLInputElement | HTMLTextAreaElement>>;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 {
Expand All @@ -700,6 +739,9 @@ function build(
send,
close,
confirm,
busyOverlay,
announcer,
footer: footerRow,
keep,
discard,
fields: fields as Refs['fields'],
Expand Down
Loading