From 09ff67a303ef44d74306d472a6bc4b4c4a16367a Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Sat, 7 Nov 2026 21:14:47 -0600 Subject: [PATCH 1/2] [#106] - naming the toggle and tabs for screen readers, closing panels with Escape, fixing two docblocks Assisted-by: Claude Code --- resources/assets/debugbar.js | 30 +++++++++- resources/assets/debugbar.min.js | 11 ++-- src/Debug.php | 6 +- tests/JavaScript/debugbar.test.js | 97 +++++++++++++++++++++++++++++++ tests/JavaScript/support/dom.js | 7 +++ 5 files changed, 143 insertions(+), 8 deletions(-) diff --git a/resources/assets/debugbar.js b/resources/assets/debugbar.js index 866e5e9..586b2ed 100644 --- a/resources/assets/debugbar.js +++ b/resources/assets/debugbar.js @@ -665,10 +665,12 @@ var body = el('div', 'phalcon-debugbar-body'); var row = el('div', 'phalcon-debugbar-row'); var tabs = el('div', 'phalcon-debugbar-tabs'); + tabs.setAttribute('role', 'tablist'); var indicators = el('div', 'phalcon-debugbar-indicators'); var toggle = el('button', 'phalcon-debugbar-toggle', '⚡'); toggle.type = 'button'; toggle.title = 'Toggle Phalcon DebugBar'; + toggle.setAttribute('aria-label', 'Toggle Phalcon DebugBar'); body.style.display = 'none'; var active = null; @@ -679,11 +681,19 @@ var historyTrigger = null; var activeBeforeHistory = null; + // A metric indicator also opens a panel, but only a tab has aria-selected. + function setSelected(node, selected) { + node.classList.toggle('is-active', selected); + if (node.getAttribute('role') === 'tab') { + node.setAttribute('aria-selected', selected ? 'true' : 'false'); + } + } + function closePanel() { body.style.display = 'none'; active = null; Array.prototype.forEach.call(row.querySelectorAll('[data-panel-tab]'), function (child) { - child.classList.remove('is-active'); + setSelected(child, false); }); } @@ -738,6 +748,7 @@ } else { mount.classList.remove('is-collapsed'); } + toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); } toggle.addEventListener('click', function () { @@ -746,10 +757,23 @@ writeCollapsed(collapsed); }); + // The listener is on the bar, so it gets only the keys pressed while the focus is in the bar. + mount.addEventListener('keydown', function (event) { + if (event.key !== 'Escape') { + return; + } + + if (historyOpen) { + setHistoryOpen(false); + } else if (active !== null) { + closePanel(); + } + }); + function activate(name, tab, entry, type) { closePanel(); setHistoryOpen(false); - tab.classList.add('is-active'); + setSelected(tab, true); body.innerHTML = ''; var summary = renderSummary(entry.summary); if (summary) { @@ -813,6 +837,8 @@ var tab = el('button', 'phalcon-debugbar-tab'); tab.type = 'button'; + tab.setAttribute('role', 'tab'); + tab.setAttribute('aria-selected', 'false'); tab.appendChild(el('span', 'phalcon-debugbar-tab-label', label)); if (hasBadge(entry.badge)) { tab.appendChild(el('span', 'phalcon-debugbar-badge', scalar(entry.badge))); diff --git a/resources/assets/debugbar.min.js b/resources/assets/debugbar.min.js index 3e9f608..eec6897 100644 --- a/resources/assets/debugbar.min.js +++ b/resources/assets/debugbar.min.js @@ -75,19 +75,22 @@ if(typeof document==='undefined'){if(typeof module!=='undefined'&&module.exports return} ready(function(){var dataNode=document.getElementById('phalcon-debugbar-data');var mount=document.getElementById('phalcon-debugbar');if(!dataNode||!mount){return} var payload;try{payload=JSON.parse(dataNode.textContent)}catch(error){return} -var data=payload.data||{};var widgets=(payload.meta&&payload.meta.widgets)||{};var bar=el('div','phalcon-debugbar-bar');var historyBrowser=el('div','phalcon-debugbar-history-browser');var body=el('div','phalcon-debugbar-body');var row=el('div','phalcon-debugbar-row');var tabs=el('div','phalcon-debugbar-tabs');var indicators=el('div','phalcon-debugbar-indicators');var toggle=el('button','phalcon-debugbar-toggle','⚡');toggle.type='button';toggle.title='Toggle Phalcon DebugBar';body.style.display='none';var active=null;var selectedHistoryId='';var historyOpen=!1;var historyPanel=null;var historyRenderGeneration=0;var historyTrigger=null;var activeBeforeHistory=null;function closePanel(){body.style.display='none';active=null;Array.prototype.forEach.call(row.querySelectorAll('[data-panel-tab]'),function(child){child.classList.remove('is-active')})} +var data=payload.data||{};var widgets=(payload.meta&&payload.meta.widgets)||{};var bar=el('div','phalcon-debugbar-bar');var historyBrowser=el('div','phalcon-debugbar-history-browser');var body=el('div','phalcon-debugbar-body');var row=el('div','phalcon-debugbar-row');var tabs=el('div','phalcon-debugbar-tabs');tabs.setAttribute('role','tablist');var indicators=el('div','phalcon-debugbar-indicators');var toggle=el('button','phalcon-debugbar-toggle','⚡');toggle.type='button';toggle.title='Toggle Phalcon DebugBar';toggle.setAttribute('aria-label','Toggle Phalcon DebugBar');body.style.display='none';var active=null;var selectedHistoryId='';var historyOpen=!1;var historyPanel=null;var historyRenderGeneration=0;var historyTrigger=null;var activeBeforeHistory=null;function setSelected(node,selected){node.classList.toggle('is-active',selected);if(node.getAttribute('role')==='tab'){node.setAttribute('aria-selected',selected?'true':'false')}} +function closePanel(){body.style.display='none';active=null;Array.prototype.forEach.call(row.querySelectorAll('[data-panel-tab]'),function(child){setSelected(child,!1)})} function renderOpenHistory(){if(!historyOpen||!historyPanel){historyBrowser.style.display='none';return} var generation=++historyRenderGeneration;renderHistoryBrowser(historyBrowser,{panel:historyPanel,selectedId:selectedHistoryId,onSelect:function(storedPayload,storedMetadata,id){var preferredActive=activeBeforeHistory;selectedHistoryId=id;setHistoryOpen(!1);activeBeforeHistory=null;renderData(storedPayload,preferredActive,storedMetadata)},onClear:function(){selectedHistoryId=''},isCurrent:function(){return historyOpen&&generation===historyRenderGeneration}})} function setHistoryOpen(open){historyOpen=Boolean(open&&historyPanel);if(historyTrigger){historyTrigger.classList.toggle('is-active',historyOpen);historyTrigger.setAttribute('aria-expanded',historyOpen?'true':'false');historyTrigger.title=(historyOpen?'Close request history: ':'Open request history: ')+historyTrigger.getAttribute('data-history-label')} if(!historyOpen){historyRenderGeneration++;historyBrowser.style.display='none'}else{renderOpenHistory()}} -function setCollapsed(collapsed){if(collapsed){closePanel();setHistoryOpen(!1);mount.classList.add('is-collapsed')}else{mount.classList.remove('is-collapsed')}} -toggle.addEventListener('click',function(){var collapsed=!mount.classList.contains('is-collapsed');setCollapsed(collapsed);writeCollapsed(collapsed)});function activate(name,tab,entry,type){closePanel();setHistoryOpen(!1);tab.classList.add('is-active');body.innerHTML='';var summary=renderSummary(entry.summary);if(summary){body.appendChild(summary)} +function setCollapsed(collapsed){if(collapsed){closePanel();setHistoryOpen(!1);mount.classList.add('is-collapsed')}else{mount.classList.remove('is-collapsed')} +toggle.setAttribute('aria-expanded',collapsed?'false':'true')} +toggle.addEventListener('click',function(){var collapsed=!mount.classList.contains('is-collapsed');setCollapsed(collapsed);writeCollapsed(collapsed)});mount.addEventListener('keydown',function(event){if(event.key!=='Escape'){return} +if(historyOpen){setHistoryOpen(!1)}else if(active!==null){closePanel()}});function activate(name,tab,entry,type){closePanel();setHistoryOpen(!1);setSelected(tab,!0);body.innerHTML='';var summary=renderSummary(entry.summary);if(summary){body.appendChild(summary)} body.appendChild(renderPanel(type,entry.panel));body.style.display='block';active=name} function renderData(nextPayload,preferredActive,requestMetadata){payload=nextPayload||{};data=payload.data||{};widgets=(payload.meta&&payload.meta.widgets)||{};tabs.innerHTML='';body.innerHTML='';body.style.display='none';active=null;var historyName=Object.keys(widgets).find(function(name){return widgets[name]&&widgets[name].panel==='history'});var historyEntry=historyName?(data[historyName]||{}):{};historyPanel=null;if(historyEntry.panel&&typeof historyEntry.panel.url==='string'){historyPanel=historyEntry.panel}else{historyOpen=!1} var preferred=null;function bindPanel(name,node){var entry=data[name]||{};var widget=widgets[name]||{};var type=widget.panel||inferType(entry.panel);node.setAttribute('data-panel-tab',name);node.addEventListener('click',function(){setHistoryOpen(!1);if(active===name){closePanel();return} activate(name,node,entry,type)});if(name===preferredActive){preferred=[name,node,entry,type]}} Object.keys(data).forEach(function(name){var entry=data[name]||{};var widget=widgets[name]||{};if(widget.panel==='history'||widget.indicator){return} -var label=widget.label||titleize(name);var tab=el('button','phalcon-debugbar-tab');tab.type='button';tab.appendChild(el('span','phalcon-debugbar-tab-label',label));if(hasBadge(entry.badge)){tab.appendChild(el('span','phalcon-debugbar-badge',scalar(entry.badge)))} +var label=widget.label||titleize(name);var tab=el('button','phalcon-debugbar-tab');tab.type='button';tab.setAttribute('role','tab');tab.setAttribute('aria-selected','false');tab.appendChild(el('span','phalcon-debugbar-tab-label',label));if(hasBadge(entry.badge)){tab.appendChild(el('span','phalcon-debugbar-badge',scalar(entry.badge)))} bindPanel(name,tab);tabs.appendChild(tab)});indicators.innerHTML='';renderMetricIndicators(indicators,data,widgets,bindPanel);historyTrigger=renderRequestControl(indicators,{data:data,widgets:widgets,requestMetadata:requestMetadata,historyOpen:historyOpen,onHistoryToggle:historyPanel?function(){if(!historyOpen){activeBeforeHistory=active;closePanel()} setHistoryOpen(!historyOpen)}:null});if(preferred){activate(preferred[0],preferred[1],preferred[2],preferred[3])} setHistoryOpen(historyOpen)} diff --git a/src/Debug.php b/src/Debug.php index 3c7faea..f967de5 100644 --- a/src/Debug.php +++ b/src/Debug.php @@ -87,7 +87,7 @@ public function __construct() } /** - * Clears are variables added previously + * Clears the variables added previously * * @return $this */ @@ -289,7 +289,9 @@ public function renderHtml(Throwable $exception): string } /** - * Sets if files the exception's backtrace must be showed + * Sets the keys to remove from the request and the server data. The + * array has a `request` and a `server` list of keys. A key matches at + * every depth. * * @param array $blacklist * diff --git a/tests/JavaScript/debugbar.test.js b/tests/JavaScript/debugbar.test.js index 230df23..b6055bf 100644 --- a/tests/JavaScript/debugbar.test.js +++ b/tests/JavaScript/debugbar.test.js @@ -1290,3 +1290,100 @@ test('the collapse toggle works when localStorage throws', function () { dom.findByClass(mount, 'phalcon-debugbar-toggle').click(); assert.equal(mount.classList.contains('is-collapsed'), true); }); + +function twoTabsPayload() { + return { + data: {messages: {panel: []}, route: {panel: []}}, + meta: { + widgets: { + messages: {label: 'Messages', panel: 'list'}, + route: {label: 'Route', panel: 'list'} + } + } + }; +} + +function selectedTabs(tabs) { + return tabs.map(function (tab) { + return tab.getAttribute('aria-selected'); + }); +} + +test('the collapse toggle has a name and reports whether the bar is open', function () { + var mount = renderPayload(messagesPayload()); + var toggle = dom.findByClass(mount, 'phalcon-debugbar-toggle'); + + assert.equal(toggle.getAttribute('aria-label'), 'Toggle Phalcon DebugBar'); + assert.equal(toggle.getAttribute('aria-expanded'), 'true'); + + toggle.click(); + + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); +}); + +test('the tabs are an ARIA tab list that marks the selected tab', function () { + var mount = renderPayload(twoTabsPayload()); + var tabs = mount.querySelectorAll('.phalcon-debugbar-tab'); + + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-tabs').getAttribute('role'), 'tablist'); + assert.deepEqual(tabs.map(function (tab) { + return tab.getAttribute('role'); + }), ['tab', 'tab']); + assert.deepEqual(selectedTabs(tabs), ['false', 'false']); + + tabs[1].click(); + assert.deepEqual(selectedTabs(tabs), ['false', 'true']); + + tabs[0].click(); + assert.deepEqual(selectedTabs(tabs), ['true', 'false']); + + tabs[0].click(); + assert.deepEqual(selectedTabs(tabs), ['false', 'false']); +}); + +test('Escape in the bar closes the open panel, and other keys do nothing', function () { + var mount = renderPayload(twoTabsPayload()); + var tab = mount.querySelectorAll('.phalcon-debugbar-tab')[0]; + var body = dom.findByClass(mount, 'phalcon-debugbar-body'); + + tab.click(); + mount.keydown('Enter'); + assert.equal(body.style.display, 'block'); + + mount.keydown('Escape'); + assert.equal(body.style.display, 'none'); + assert.equal(tab.classList.contains('is-active'), false); + assert.equal(tab.getAttribute('aria-selected'), 'false'); +}); + +test('Escape in the bar closes the request history', async function () { + var mount = renderPayload({ + data: { + request: {panel: {Method: 'GET', URI: '/orders'}, metrics: {method: 'GET', uri: '/orders'}}, + history: {panel: {url: '/_debugbar/open'}} + }, + meta: { + widgets: { + request: { + label: 'Request', + panel: 'grid', + request: {method: ['metrics', 'method'], uri: ['metrics', 'uri']} + }, + history: {label: 'History', panel: 'history'} + } + } + }, { + fetch: function () { + return Promise.resolve(response({requests: []})); + } + }); + + dom.findByClass(mount, 'phalcon-debugbar-request-control').click(); + await dom.flushPromises(); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-browser').style.display, 'block'); + + mount.keydown('Escape'); + + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-browser').style.display, 'none'); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-request-control').getAttribute('aria-expanded'), 'false'); +}); diff --git a/tests/JavaScript/support/dom.js b/tests/JavaScript/support/dom.js index 361c4f2..2330651 100644 --- a/tests/JavaScript/support/dom.js +++ b/tests/JavaScript/support/dom.js @@ -116,6 +116,13 @@ TestElement.prototype.click = function () { } }; +// Sends a key to the element, as when the focus is inside it. +TestElement.prototype.keydown = function (key) { + (this._listeners.keydown || []).forEach(function (listener) { + listener({key: key}); + }); +}; + TestElement.prototype.querySelectorAll = function (selector) { var className = selector.charAt(0) === '.' ? selector.slice(1) : ''; var attribute = /^\[([^\]]+)\]$/.exec(selector); From 0929e97eb4a0e2bd56814ca9d4ff51fda745bcd0 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Sat, 7 Nov 2026 21:14:47 -0600 Subject: [PATCH 2/2] [#106] - updating changelog/docs Assisted-by: Claude Code --- CHANGELOG.md | 1 + docs/index.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fca3204..ebb1586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - Debug page: copy on `http://` pages, show "Copy failed" when a copy fails, and encode the paths in the PhpStorm and VS Code links. [#101](https://github.com/phalcon/debugbar/issues/101) - Keep host CSS out of the bar tables, code blocks and buttons, stop history requests after 10 seconds, and show "Unable to load" in a history row that fails. [#102](https://github.com/phalcon/debugbar/issues/102) - Pin `pie.phar` to a release with a sha256 check, build the PHP 8.1 Docker image, declare `ext-mbstring`, and list the Phalcon version that each feature needs. [#104](https://github.com/phalcon/debugbar/issues/104) +- Name the collapse toggle and the tabs for screen readers, close the open panel or the history with Escape, and fix two `Phalcon\Debug` docblocks. [#106](https://github.com/phalcon/debugbar/issues/106) - Test HTML writes, the collapse state and `localStorage` errors in the JS tests, and keep every listener in the fake DOM. [#103](https://github.com/phalcon/debugbar/issues/103) ## [0.4.0](https://github.com/phalcon/debugbar/releases/tag/v0.4.0) (2026-07-14) diff --git a/docs/index.md b/docs/index.md index 45cff61..1e1cf55 100644 --- a/docs/index.md +++ b/docs/index.md @@ -642,7 +642,7 @@ The `request`, `config`, `session`, and `logger` collectors run their data throu The `database` panel and request history show and store query bindings as they are, because generated names such as `:APL0` do not match a key. The redactor does not change log messages, exception messages, or stack traces. -`Phalcon\DebugBar\Security\AccessGate` decides who receives the bar within an allowed environment. An empty IP allowlist admits any client. When both an allowlist and a callback are given, both must pass. Only `true` from the callback allows the client. When the callback or a collector throws, that response gets no bar, and the page of the application does not change. The callback can run more than once for a request, for example for a server error in an ADR application with `debug_page` on. Do not put side effects in the callback. +`Phalcon\DebugBar\Security\AccessGate` decides who receives the bar within an allowed environment. An empty IP allowlist admits any client. The allowlist compares the address exactly. On a host that has IPv4 and IPv6, a local client can come as `::1`, so add `::1` next to `127.0.0.1`. Behind a proxy on the same host, `REMOTE_ADDR` is the address of the proxy, for example `127.0.0.1`, for every client, so the allowlist then lets every client through. When both an allowlist and a callback are given, both must pass. Only `true` from the callback allows the client. When the callback or a collector throws, that response gets no bar, and the page of the application does not change. The callback can run more than once for a request, for example for a server error in an ADR application with `debug_page` on. Do not put side effects in the callback. ```php