Skip to content

Navigation Menu

Sign in
Appearance settings
Sign up
Appearance settings

Commit d334117

Browse filesBrowse the repository at this point in the historyBrowse files
bug symfony#65625 [ErrorHandler] Fix what the exception page announces to assistive technologies (Nitram1123)
This PR was merged into the 8.2 branch. Discussion ---------- [ErrorHandler] Fix what the exception page announces to assistive technologies | Q | A | ------------- | --- | Branch? | 8.2 | Bug fix? | yes | New feature? | no | Deprecations? | no | License | MIT The exception page redesign (symfony#64766) gained a lot of structure, and in a few spots what is announced to assistive technologies drifted from what the page actually does. **Collapsed content stays in the accessibility tree.** The exception chain and the log rows collapse with `grid-template-rows: 0fr`, which hides them visually but leaves them readable: the summary announces `aria-expanded="false"` and a screen reader then reads everything it claims to have collapsed. Collapsing now also flips `visibility`, delayed to match the animation so the transition still plays, and reduced motion drops both. **The log filters declare themselves as menus.** `aria-haspopup="true"` promises a menu, but what opens is a group of native checkboxes. Those checkboxes are already fully accessible as they are, `Tab` reaches them and `Space` toggles them, so there is nothing to build: this drops the false promise and names the group being revealed. `Escape` closed the menu while a checkbox held the focus, which sent the focus back to the document, so it now hands it to the trigger instead. **Copying is silent.** The three copy controls swap an icon for 1.5s and nothing else. A single `role="status"` region now announces each copy, and clears itself afterwards so it does not linger in browse mode. It is rendered by `exception.html.php` rather than injected from JavaScript: the profiler embeds this fragment and scopes the stylesheet to it, so a region appended to `document.body` would stay unstyled there and print its text on the page. **The per-line copy control is mouse-only.** It was a `<span>` with no role, no `tabindex` and no accessible name, revealed on hover. It is now a named `<button>`, still hidden until hover, and revealed when it receives focus. The cost is one extra tab stop per visible trace frame; hidden vendor frames are not affected. **A log line is announced as one run-on string.** `07:50:09INFOrequestMatched route "..."` is what NVDA announces on Chrome, while JAWS spaces the same line correctly. Nothing guarantees that space in this context: the accname specification leaves the case open. Composing the name with `aria-labelledby` removes the uncertainty, since concatenation from IDREFs is specified to join with a single space, one space for everyone, and the NVDA problem goes away. Commits ------- 3a10a34 [ErrorHandler] Fix what the exception page announces to assistive technologies
2 parents 524a899 + 3a10a34 commit d334117
Copy full SHA for d334117

6 files changed

+127-20Lines changed: 127 additions & 20 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎src/Symfony/Component/ErrorHandler/Resources/assets/css/exception.css‎

Copy file name to clipboardExpand all lines: src/Symfony/Component/ErrorHandler/Resources/assets/css/exception.css
+17-6Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ code, pre { font: 13px/1.6 var(--font-mono); font-size-adjust: 0.5298; font-weig
144144
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
145145

146146
.hidden { display: none; }
147+
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
147148
.break-long-words { word-wrap: break-word; overflow-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; hyphenate-character: ''; min-width: 0; }
148149
.sf-toggle { cursor: pointer; position: relative; }
149150

@@ -336,9 +337,14 @@ header .container { display: flex; align-items: center; justify-content: space-b
336337
.exc-detail { display: grid; grid-template-rows: 1fr; transition: grid-template-rows .25s ease; padding: 0 var(--chain-pad-x); }
337338
.exc-item:not(.is-expanded) .exc-detail { grid-template-rows: 0fr; }
338339

339-
.exc-detail-inner { min-height: 0; overflow: hidden; opacity: 1; transition: opacity .2s ease; }
340+
.exc-detail-inner { min-height: 0; overflow: hidden; opacity: 1; visibility: visible; transition: opacity .2s ease; }
340341
.exc-item.is-expanded .exc-detail-inner { margin-bottom: 4px; }
341-
.exc-item:not(.is-expanded) .exc-detail-inner { opacity: 0; }
342+
.exc-item:not(.is-expanded) .exc-detail-inner { opacity: 0; visibility: hidden; transition: opacity .2s ease, visibility 0s .25s; }
343+
@media (prefers-reduced-motion: reduce) {
344+
.exc-detail { transition: none; }
345+
.exc-detail-inner,
346+
.exc-item:not(.is-expanded) .exc-detail-inner { transition: none; }
347+
}
342348

343349
.exc-single { margin: 15px 0 0; padding: 10px; }
344350
@media (min-width: 1280px) { .exc-single { padding: 15px; } }
@@ -491,8 +497,9 @@ header .container { display: flex; align-items: center; justify-content: space-b
491497
.trace-line a { color: var(--foreground); }
492498
.trace-line .icon { position: absolute; left: 10px; }
493499
.trace-line .icon svg { fill: var(--muted-foreground); height: 16px; width: 16px; }
494-
.trace-line .icon.icon-copy { position: static; margin: -3px 0; padding-left: 5px; display: none; vertical-align: middle; }
495-
.trace-line:hover .icon.icon-copy:not(.hidden) { display: inline-flex; }
500+
.trace-line .icon.icon-copy { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; background: none; border: 0; color: inherit; font: inherit; cursor: pointer; }
501+
.trace-line:hover .icon.icon-copy:not(.hidden),
502+
.trace-line .icon.icon-copy:not(.hidden):focus-visible { position: static; display: inline-flex; width: auto; height: auto; margin: -3px 0; padding: 0 0 0 5px; overflow: visible; clip-path: none; vertical-align: middle; }
496503
.trace-line .icon.icon-copy .icon-copy-default, .trace-line .icon.icon-copy .icon-copy-success { display: inline-flex; }
497504
.trace-line .icon.icon-copy .icon-copy-success { display: none; }
498505
.trace-line .icon.icon-copy.is-copied { color: var(--success); }
@@ -665,10 +672,14 @@ header .container { display: flex; align-items: center; justify-content: space-b
665672

666673
.log-detail { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .25s ease; }
667674
.log-line.is-expanded .log-detail { grid-template-rows: 1fr; }
668-
.log-detail-inner { min-height: 0; overflow: hidden; }
675+
.log-detail-inner { min-height: 0; overflow: hidden; visibility: visible; }
676+
.log-line:not(.is-expanded) .log-detail-inner { visibility: hidden; transition: visibility 0s .25s; }
669677
.log-json { margin: 0; padding: 2px 14px 12px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; color: var(--muted-foreground); }
670678
.log-json-key { color: var(--code-syntax-function-title); }
671679
.log-json-str { color: var(--code-syntax-string); }
672680
.log-json-num { color: var(--code-syntax-variable-other-marker); }
673681
.log-json-kw { color: var(--code-syntax-keyword); }
674-
@media (prefers-reduced-motion: reduce) { .log-detail { transition: none; } }
682+
@media (prefers-reduced-motion: reduce) {
683+
.log-detail { transition: none; }
684+
.log-line:not(.is-expanded) .log-detail-inner { transition: none; }
685+
}
Collapse file

‎src/Symfony/Component/ErrorHandler/Resources/assets/js/exception.js‎

Copy file name to clipboardExpand all lines: src/Symfony/Component/ErrorHandler/Resources/assets/js/exception.js
+37-5Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,31 @@
2929
};
3030
}
3131

32+
var announcer = document.querySelector('[data-announcer]');
33+
34+
function announce(message) {
35+
if (!announcer) {
36+
return;
37+
}
38+
/* clearing first, then filling in a moment later, makes screen readers announce repeated copies */
39+
announcer.textContent = '';
40+
clearTimeout(announcer.sfAnnounceTimer);
41+
announcer.sfAnnounceTimer = setTimeout(function() {
42+
announcer.textContent = message;
43+
announcer.sfAnnounceTimer = setTimeout(function() { announcer.textContent = ''; }, 5000);
44+
}, 50);
45+
}
46+
3247
if (navigator.clipboard) {
3348
document.querySelectorAll('[data-clipboard-text]:not([data-processed=true])').forEach(function(element) {
3449
removeClass(element, 'hidden');
35-
element.addEventListener('click', function() {
36-
navigator.clipboard.writeText(element.getAttribute('data-clipboard-text'));
50+
element.addEventListener('click', function(e) {
51+
/* Prevents from disallowing clicks on "copy to clipboard" elements inside toggles */
52+
e.stopPropagation();
53+
54+
navigator.clipboard.writeText(element.getAttribute('data-clipboard-text')).then(function() {
55+
announce('File path copied to clipboard');
56+
});
3757
/* briefly swap the copy glyph for a checkmark as feedback */
3858
addClass(element, 'is-copied');
3959
clearTimeout(element.sfCopiedTimer);
@@ -170,6 +190,7 @@
170190
btn.removeAttribute('hidden');
171191
btn.addEventListener('click', function() {
172192
navigator.clipboard.writeText(buildTextContent(btn)).then(function() {
193+
announce('Copied to clipboard');
173194
var label = btn.querySelector('.copy-label');
174195
if (!label) { return; }
175196
var previous = label.textContent;
@@ -200,7 +221,7 @@
200221
addEventListener(toggles[i], 'click', function(e) {
201222
var toggle = e.currentTarget;
202223

203-
if (e.target.closest('a, span[data-clipboard-text], .sf-toggle') !== toggle) {
224+
if (e.target.closest('a, .sf-toggle') !== toggle) {
204225
return;
205226
}
206227

@@ -257,7 +278,9 @@
257278
var card = btn.closest('.trace-raw-card');
258279
var pre = card ? card.querySelector('pre') : null;
259280
addEventListener(btn, 'click', function() {
260-
navigator.clipboard.writeText(pre ? pre.textContent.replace(/[ \t]+\n/g, '\n').trim() : '');
281+
navigator.clipboard.writeText(pre ? pre.textContent.replace(/[ \t]+\n/g, '\n').trim() : '').then(function() {
282+
announce('Stack trace copied to clipboard');
283+
});
261284
addClass(btn, 'is-copied');
262285
clearTimeout(btn.sfCopiedTimer);
263286
btn.sfCopiedTimer = setTimeout(function() { removeClass(btn, 'is-copied'); }, 1500);
@@ -387,7 +410,16 @@
387410
});
388411

389412
addEventListener(document, 'click', closeFilter);
390-
addEventListener(document, 'keydown', function(e) { if ('Escape' === e.key) { closeFilter(); } });
413+
addEventListener(document, 'keydown', function(e) {
414+
if ('Escape' !== e.key || !openFilter) {
415+
return;
416+
}
417+
/* hiding the menu drops the focus it holds, so hand it back to the trigger */
418+
var trigger = openFilter.trigger;
419+
var restoreFocus = openFilter.menu.contains(document.activeElement);
420+
closeFilter();
421+
if (restoreFocus) { trigger.focus(); }
422+
});
391423
})();
392424
})();
393425
/*]]>*/
Collapse file

‎src/Symfony/Component/ErrorHandler/Resources/views/exception.html.php‎

Copy file name to clipboardExpand all lines: src/Symfony/Component/ErrorHandler/Resources/views/exception.html.php
+3Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,6 @@
7979
</div>
8080
<?php } ?>
8181
</div>
82+
83+
<?php // the profiler scopes this fragment's stylesheet to itself, so the live region has to sit inside the fragment ?>
84+
<p class="sr-only" role="status" data-announcer></p>
Collapse file

‎src/Symfony/Component/ErrorHandler/Resources/views/logs.html.php‎

Copy file name to clipboardExpand all lines: src/Symfony/Component/ErrorHandler/Resources/views/logs.html.php
+16-7Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
return;
2424
} ?>
2525
<div class="logs-filter" data-filter="<?= $name; ?>">
26-
<button type="button" class="logs-filter-trigger" aria-expanded="false" aria-haspopup="true"><?= $this->escape($label); ?> <span class="logs-filter-icon"><?= $this->include('assets/images/icon-chevrons-up-down.svg'); ?></span></button>
27-
<div class="logs-filter-menu" hidden>
26+
<button type="button" class="logs-filter-trigger" aria-expanded="false"><?= $this->escape($label); ?> <span class="logs-filter-icon"><?= $this->include('assets/images/icon-chevrons-up-down.svg'); ?></span></button>
27+
<div class="logs-filter-menu" role="group" aria-label="Filter by <?= $this->escape($label); ?>" hidden>
2828
<div class="logs-filter-options">
2929
<?php foreach ($values as $value) { ?>
3030
<label class="logs-filter-option"><input type="checkbox" value="<?= $this->escape($value); ?>" checked><span><?= $this->escape($value); ?></span></label>
@@ -58,6 +58,7 @@
5858

5959
<div class="logs-list">
6060
<?php
61+
$logIndex = 0;
6162
foreach ($logs as $log) {
6263
if ($log['priority'] >= 400) {
6364
$status = 'error';
@@ -74,13 +75,21 @@
7475
// error logs are expanded by default so the failure context is visible right away
7576
$expanded = $hasDetail && 'error' === $status;
7677
$tag = $hasDetail ? 'button' : 'div';
78+
// an expandable row names its summary from its parts, so that assistive technologies
79+
// read them as separate words instead of as one run-on string
80+
$summaryId = $hasDetail ? 'log-'.$logIndex++ : null;
81+
$labelledBy = [$summaryId.'-time', $summaryId.'-level'];
82+
if ($channelIsDefined) {
83+
$labelledBy[] = $summaryId.'-channel';
84+
}
85+
$labelledBy[] = $summaryId.'-message';
7786
?>
7887
<div class="log-line status-<?= $status; ?><?= $hasDetail ? ' has-detail' : ''; ?><?= $expanded ? ' is-expanded' : ''; ?>"<?= $expanded ? ' data-default-expanded="1"' : ''; ?> data-level="<?= $this->escape($log['priorityName']); ?>"<?php if ($channelIsDefined) { ?> data-channel="<?= $this->escape($log['channel']); ?>"<?php } ?> data-time="<?= $this->escape($log['timestamp_rfc3339'] ?? date(\DATE_RFC3339_EXTENDED, $log['timestamp'])); ?>">
79-
<<?= $tag; ?> class="log-summary"<?= $hasDetail ? ' type="button" aria-expanded="'.($expanded ? 'true' : 'false').'"' : ''; ?>>
80-
<span class="log-time"><?= date('H:i:s', $log['timestamp']); ?></span>
81-
<span class="log-level log-level-<?= $status; ?>"><?= $this->escape($log['priorityName']); ?></span>
82-
<?php if ($channelIsDefined) { ?><span class="log-channel"><?= $this->escape($log['channel']); ?></span><?php } ?>
83-
<span class="log-message break-long-words"><?= $this->formatLogMessage($log['message'], $log['context']); ?></span>
88+
<<?= $tag; ?> class="log-summary"<?= $hasDetail ? ' type="button" aria-expanded="'.($expanded ? 'true' : 'false').'" aria-labelledby="'.implode(' ', $labelledBy).'"' : ''; ?>>
89+
<span class="log-time"<?= $summaryId ? ' id="'.$summaryId.'-time"' : ''; ?>><?= date('H:i:s', $log['timestamp']); ?></span>
90+
<span class="log-level log-level-<?= $status; ?>"<?= $summaryId ? ' id="'.$summaryId.'-level"' : ''; ?>><?= $this->escape($log['priorityName']); ?></span>
91+
<?php if ($channelIsDefined) { ?><span class="log-channel"<?= $summaryId ? ' id="'.$summaryId.'-channel"' : ''; ?>><?= $this->escape($log['channel']); ?></span><?php } ?>
92+
<span class="log-message break-long-words"<?= $summaryId ? ' id="'.$summaryId.'-message"' : ''; ?>><?= $this->formatLogMessage($log['message'], $log['context']); ?></span>
8493
<span class="log-chevron"><?php if ($hasDetail) { echo $this->include('assets/images/chevron-right.svg'); } ?></span>
8594
</<?= $tag; ?>>
8695
<?php if ($hasDetail) { ?>
Collapse file

‎src/Symfony/Component/ErrorHandler/Resources/views/trace.html.php‎

Copy file name to clipboardExpand all lines: src/Symfony/Component/ErrorHandler/Resources/views/trace.html.php
+2-2Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
<?= implode(\DIRECTORY_SEPARATOR, array_slice($filePathParts, 0, -1)).\DIRECTORY_SEPARATOR; ?><strong><?= end($filePathParts); ?></strong>
1717
</a>
1818
(line <?= $lineNumber; ?>)
19-
<span class="icon icon-copy hidden" data-clipboard-text="<?php echo implode(\DIRECTORY_SEPARATOR, $filePathParts).':'.$lineNumber; ?>">
19+
<button type="button" class="icon icon-copy hidden" aria-label="Copy file path" data-clipboard-text="<?php echo implode(\DIRECTORY_SEPARATOR, $filePathParts).':'.$lineNumber; ?>">
2020
<span class="icon-copy-default"><?php echo $this->include('assets/images/icon-copy.svg'); ?></span>
2121
<span class="icon-copy-success"><?php echo $this->include('assets/images/icon-copy-check.svg'); ?></span>
22-
</span>
22+
</button>
2323
</span>
2424
<?php } ?>
2525
</div>
Collapse file

‎src/Symfony/Component/ErrorHandler/Tests/ErrorRenderer/HtmlErrorRendererTest.php‎

Copy file name to clipboardExpand all lines: src/Symfony/Component/ErrorHandler/Tests/ErrorRenderer/HtmlErrorRendererTest.php
+52Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313

1414
use PHPUnit\Framework\Attributes\DataProvider;
1515
use PHPUnit\Framework\TestCase;
16+
use Psr\Log\AbstractLogger;
17+
use Psr\Log\LoggerInterface;
1618
use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer;
19+
use Symfony\Component\ErrorHandler\Exception\FlattenException;
20+
use Symfony\Component\HttpFoundation\Request;
21+
use Symfony\Component\HttpKernel\Log\DebugLoggerInterface;
1722

1823
class HtmlErrorRendererTest extends TestCase
1924
{
@@ -124,6 +129,53 @@ public function testRendersStackWithoutBinaryStrings()
124129
);
125130
}
126131

132+
public function testTheCopyStatusRegionIsPartOfTheRenderedBody()
133+
{
134+
// the profiler embeds this fragment and scopes the stylesheet to it, so the region
135+
// cannot be appended to the document from JavaScript
136+
$body = (new HtmlErrorRenderer(true))->getBody(FlattenException::createFromThrowable(new \RuntimeException('Foo')));
137+
138+
$this->assertStringContainsString('<p class="sr-only" role="status" data-announcer></p>', $body);
139+
}
140+
141+
public function testExpandableLogRowsNameThemselvesFromTheirParts()
142+
{
143+
$rendered = (new HtmlErrorRenderer(true, null, null, null, '', $this->createDebugLogger()))->render(new \RuntimeException('Foo'))->getAsString();
144+
145+
$this->assertStringContainsString('aria-labelledby="log-0-time log-0-level log-0-channel log-0-message"', $rendered);
146+
$this->assertStringContainsString('<span class="log-time" id="log-0-time">', $rendered);
147+
$this->assertStringContainsString('<span class="log-message break-long-words" id="log-0-message">', $rendered);
148+
149+
// a row without context is not expandable, so it has no summary to name
150+
$this->assertStringNotContainsString('id="log-1-', $rendered);
151+
}
152+
153+
private function createDebugLogger(): LoggerInterface&DebugLoggerInterface
154+
{
155+
return new class extends AbstractLogger implements DebugLoggerInterface {
156+
public function log($level, $message, array $context = []): void
157+
{
158+
}
159+
160+
public function getLogs(?Request $request = null): array
161+
{
162+
return [
163+
['timestamp' => 0, 'timestamp_rfc3339' => '1970-01-01T00:00:00.000+00:00', 'message' => 'With context', 'priority' => 200, 'priorityName' => 'INFO', 'channel' => 'request', 'context' => ['foo' => 'bar']],
164+
['timestamp' => 0, 'timestamp_rfc3339' => '1970-01-01T00:00:00.000+00:00', 'message' => 'Without context', 'priority' => 200, 'priorityName' => 'INFO', 'channel' => 'request', 'context' => []],
165+
];
166+
}
167+
168+
public function countErrors(?Request $request = null): int
169+
{
170+
return 0;
171+
}
172+
173+
public function clear(): void
174+
{
175+
}
176+
};
177+
}
178+
127179
private function getRuntimeException(string $unusedArgument): \RuntimeException
128180
{
129181
return $this->buildRuntimeException('FooException');

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.