From 0c23ae3e898df33bae9de5bc498f18309e842bbd Mon Sep 17 00:00:00 2001 From: Belisoful Date: Sun, 30 Aug 2026 03:22:46 +0000 Subject: [PATCH 1/4] ActiveControls, In-Place, and their Parents - Accessibility Audit Fixes and Text Localization Sweep --- .../activefileupload/activefileupload.js | 10 ++ .../source/prado/datepicker/datepicker.js | 75 +++++++++++++-- .../source/prado/ratings/blocks.css | 82 ++++++++++------ .../source/prado/ratings/default.css | 84 +++++++++++------ .../source/prado/ratings/ratings.js | 61 +++++++++++- .../UI/ActiveControls/TActiveFileUpload.php | 92 ++++++++++++++++++ .../TInPlaceListControlTrait.php | 5 +- .../Web/UI/ActiveControls/TInPlaceTextBox.php | 4 +- .../Web/UI/WebControls/TCheckBoxList.php | 29 ++++++ framework/Web/UI/WebControls/TDatePicker.php | 48 +++++++++- .../Web/UI/WebControls/TRadioButtonList.php | 9 ++ framework/Web/UI/WebControls/TRatingList.php | 6 ++ .../protected/messages/en/messages.xml | 36 ++++++- .../protected/pages/ListGroupingA11yTest.page | 20 ++++ .../protected/pages/ListGroupingA11yTest.php | 5 + .../activecontrols/activefileupload.test.js | 59 +++++++++++- tests/js/datepicker/datepicker.test.js | 84 +++++++++++++++++ tests/js/ratings/ratings.test.js | 93 +++++++++++++++++++ ...iveRatingListAccessibilityTestCase.spec.js | 67 +++++++++++++ .../web/DatePickerA11yTestCase.spec.js | 56 +++++++++++ .../web/ListGroupingA11yTestCase.spec.js | 33 +++++++ .../ActiveControls/TActiveFileUploadTest.php | 28 ++++++ .../TInPlaceDropDownListTest.php | 9 ++ .../UI/ActiveControls/TInPlaceListBoxTest.php | 9 ++ .../UI/ActiveControls/TInPlaceTextBoxTest.php | 9 ++ .../Web/UI/WebControls/TDatePickerTest.php | 22 +++++ 26 files changed, 961 insertions(+), 74 deletions(-) create mode 100644 tests/harness/web/protected/pages/ListGroupingA11yTest.page create mode 100644 tests/harness/web/protected/pages/ListGroupingA11yTest.php create mode 100644 tests/playwright/active-controls/ActiveRatingListAccessibilityTestCase.spec.js create mode 100644 tests/playwright/web/DatePickerA11yTestCase.spec.js create mode 100644 tests/playwright/web/ListGroupingA11yTestCase.spec.js diff --git a/framework/Web/Javascripts/source/prado/activefileupload/activefileupload.js b/framework/Web/Javascripts/source/prado/activefileupload/activefileupload.js index 112251ed9..e423d1d5c 100644 --- a/framework/Web/Javascripts/source/prado/activefileupload/activefileupload.js +++ b/framework/Web/Javascripts/source/prado/activefileupload/activefileupload.js @@ -13,6 +13,7 @@ Prado.WebUI.TActiveFileUpload = Prado.Class(Prado.WebUI.Control, this.indicator = document.getElementById(options.indicatorID); this.complete = document.getElementById(options.completeID); this.error = document.getElementById(options.errorID); + this.status = document.getElementById(options.statusID); // set up events if (options.autoPostBack){ @@ -30,6 +31,7 @@ Prado.WebUI.TActiveFileUpload = Prado.Class(Prado.WebUI.Control, this.complete.style.display = 'none'; this.error.style.display = 'none'; this.indicator.style.display = ''; + this.announce(this.options.uploadingText); // set the form to submit in the iframe, submit it, and then reset it. this.oldtargetID = this.form.target; @@ -95,9 +97,17 @@ Prado.WebUI.TActiveFileUpload = Prado.Class(Prado.WebUI.Control, if (/^[0[\],]+$/.test(this.finishoptions.errorCode) && success) { this.complete.style.display = ''; this.input.value = ''; + this.announce(this.options.completeText); } else { this.error.style.display = ''; + this.announce(this.options.errorText); } + }, + + /** Update the live region so assistive tech announces the upload state. */ + announce(text) { + if (this.status && text) + this.status.textContent = text; } }); diff --git a/framework/Web/Javascripts/source/prado/datepicker/datepicker.js b/framework/Web/Javascripts/source/prado/datepicker/datepicker.js index 19dd9896b..1b890867f 100644 --- a/framework/Web/Javascripts/source/prado/datepicker/datepicker.js +++ b/framework/Web/Javascripts/source/prado/datepicker/datepicker.js @@ -11,6 +11,14 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, ShortWeekDayNames : ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ], + // Assistive-technology labels; the server sends translations through the + // options, which Object.assign lays over these English defaults. + CalendarLabel : "Calendar", + MonthLabel : "Month", + YearLabel : "Year", + PrevMonthLabel : "Previous month", + NextMonthLabel : "Next month", + Format : "yyyy-MM-dd", FirstDayOfWeek : 1, // 0 for sunday @@ -94,6 +102,14 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, this._calDiv.className = `TDatePicker_${this.CalendarStyle} ${this.ClassName}`; this._calDiv.style.display = "none"; this._calDiv.style.position = "absolute" + // The popup is a labeled dialog so assistive technology announces it when + // it opens; the input keeps focus and arrow keys navigate the dates. + this._calDiv.id = `${this.options.ID}_calendar`; + this._calDiv.setAttribute("role", "dialog"); + this._calDiv.setAttribute("aria-modal", "false"); + this._calDiv.setAttribute("aria-label", this.CalendarLabel); + if(this.trigger && this.trigger.setAttribute) + this.trigger.setAttribute("aria-controls", this._calDiv.id); // header div div = document.createElement("div"); @@ -116,6 +132,7 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, previousMonth.className = "prevMonthButton button"; previousMonth.type = "button" previousMonth.value = "<<"; + previousMonth.setAttribute("aria-label", this.PrevMonthLabel); td.appendChild(previousMonth); tr.appendChild(td); @@ -128,6 +145,7 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, tr.appendChild(td); this._monthSelect = document.createElement("select"); this._monthSelect.className = "months"; + this._monthSelect.setAttribute("aria-label", this.MonthLabel); for (let i = 0 ; i < this.MonthNames.length ; i++) { const opt = document.createElement("option"); opt.innerHTML = this.MonthNames[i]; @@ -147,6 +165,7 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, td.className = "labelContainer"; tr.appendChild(td); this._yearSelect = document.createElement("select"); + this._yearSelect.setAttribute("aria-label", this.YearLabel); for(let i = this.FromYear; i <= this.UpToYear; ++i) { const opt = document.createElement("option"); opt.innerHTML = i; @@ -164,6 +183,7 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, nextMonth.className = "nextMonthButton button"; nextMonth.type = "button"; nextMonth.value = ">>"; + nextMonth.setAttribute("aria-label", this.NextMonthLabel); td.appendChild(nextMonth); tr.appendChild(td); @@ -179,6 +199,10 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, table = document.createElement("table"); table.align="center"; table.className = "grid"; + // role=grid gives the ths and tds their columnheader/gridcell roles + // implicitly; updateHeader() keeps the label at "MonthName Year". + table.setAttribute("role", "grid"); + this._gridTable = table; div.appendChild(table); const thead = document.createElement("thead"); @@ -191,6 +215,7 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, text = document.createTextNode(this.ShortWeekDayNames[(i+this.FirstDayOfWeek)%7]); td.appendChild(text); td.className = "weekDayHead"; + td.setAttribute("scope", "col"); tr.appendChild(td); } @@ -205,6 +230,7 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, for(let day=0; day<7; ++day) { td = document.createElement("td"); td.className = "calendarDate"; + td.id = `${this.options.ID}_day${(week*7)+day}`; text = document.createTextNode(String.fromCharCode(160)); td.appendChild(text); @@ -234,6 +260,13 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, todayButton.value = buttonText; div.appendChild(todayButton); + // Visually-hidden live region: announces the date the arrow keys land + // on, since keyboard focus stays on the input while navigating. + this._liveRegion = document.createElement("div"); + this._liveRegion.setAttribute("aria-live", "polite"); + this._liveRegion.style.cssText = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap"; + this._calDiv.appendChild(this._liveRegion); + this.control.parentNode.appendChild(this._calDiv); this.update(); @@ -616,6 +649,8 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, } this.observe(document,"keydown", this.documentKeyDownEvent); this.showing = true; + if(this.trigger && this.trigger.setAttribute) + this.trigger.setAttribute("aria-expanded", "true"); } }, @@ -651,6 +686,10 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, this.showing = false; this.stopObserving(document.body, "click", this.documentClickEvent); this.stopObserving(document,"keydown", this.documentKeyDownEvent); + if(this.trigger && this.trigger.setAttribute) + this.trigger.setAttribute("aria-expanded", "false"); + if(this.control && this.control.removeAttribute) + this.control.removeAttribute("aria-activedescendant"); } }, @@ -669,11 +708,21 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, if (firstIndex < 0) firstIndex += 7; + // Marks a padding cell before or after the month's dates: not a date, + // so assistive technology skips it. + const emptySlot = (slot) => { + slot.value = -1; + slot.data.data = String.fromCharCode(160); + const node = slot.data.parentNode; + node.className = "empty"; + node.setAttribute("aria-hidden", "true"); + node.removeAttribute("aria-selected"); + node.removeAttribute("aria-current"); + }; + let index = 0; while (index < firstIndex) { - this.dateSlot[index].value = -1; - this.dateSlot[index].data.data = String.fromCharCode(160); - this.dateSlot[index].data.parentNode.className = "empty"; + emptySlot(this.dateSlot[index]); index++; } @@ -683,13 +732,26 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, slot.value = i; slot.data.data = i; slotNode.className = "date"; + slotNode.removeAttribute("aria-hidden"); //slotNode.style.color = ""; if (d1.toISODate() == today) { slotNode.className += " today"; + slotNode.setAttribute("aria-current", "date"); + } else { + slotNode.removeAttribute("aria-current"); } if (d1.toISODate() == selected) { // slotNode.style.color = "blue"; slotNode.className += " selected"; + slotNode.setAttribute("aria-selected", "true"); + // The input keeps focus; point assistive tech at the active cell + // and announce the date it represents. + if(this.options.InputMode == "TextBox" && this.control && this.control.setAttribute) + this.control.setAttribute("aria-activedescendant", slotNode.id); + if(this._liveRegion) + this._liveRegion.textContent = `${this.MonthNames[date.getMonth()]} ${i}, ${date.getFullYear()}`; + } else { + slotNode.setAttribute("aria-selected", "false"); } d1 = new Date(d1.getFullYear(), d1.getMonth(), d1.getDate()+1); } @@ -699,9 +761,7 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, const _lastDateIndex = index; while(index < 42) { - this.dateSlot[index].value = -1; - this.dateSlot[index].data.data = String.fromCharCode(160); - this.dateSlot[index].data.parentNode.className = "empty"; + emptySlot(this.dateSlot[index]); ++index; } @@ -718,6 +778,9 @@ Prado.WebUI.TDatePicker = Prado.Class(Prado.WebUI.Control, }, updateHeader() { + if(this._gridTable) + this._gridTable.setAttribute("aria-label", + `${this.MonthNames[this.selectedDate.getMonth()]} ${this.selectedDate.getFullYear()}`); let options = this._monthSelect.options; const m = this.selectedDate.getMonth(); diff --git a/framework/Web/Javascripts/source/prado/ratings/blocks.css b/framework/Web/Javascripts/source/prado/ratings/blocks.css index 2bf2e9047..e9cb49e8a 100644 --- a/framework/Web/Javascripts/source/prado/ratings/blocks.css +++ b/framework/Web/Javascripts/source/prado/ratings/blocks.css @@ -1,29 +1,53 @@ -.TRatingList_blocks -{ - border-collapse: collapse; -} -.TRatingList_blocks input, .TRatingList_blocks label -{ - display: none; -} -.TRatingList_blocks td -{ - width: 17px; - height: 18px; - background-image: url(blocks_blank.gif); - background-repeat: no-repeat; - cursor: pointer; -} -.TRatingList_blocks td.rating_selected -{ - background-image: url(blocks_selected.gif); -} - -.TRatingList_blocks td.rating_hover -{ - background-image: url(blocks_hover.gif); -} -.TRatingList_blocks td.rating_half -{ - background-image: url(blocks_half.gif); -} +.TRatingList_blocks +{ + border-collapse: collapse; +} +/* + * Visually hide the radios rather than display:none so they remain in the + * accessibility tree and keyboard tab order; see default.css for the rationale. + */ +.TRatingList_blocks input +{ + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + overflow: hidden; + white-space: nowrap; +} +.TRatingList_blocks label +{ + display: none; +} +.TRatingList_blocks td +{ + width: 17px; + height: 18px; + background-image: url(blocks_blank.gif); + background-repeat: no-repeat; + cursor: pointer; +} +.TRatingList_blocks td.rating_selected +{ + background-image: url(blocks_selected.gif); +} + +.TRatingList_blocks td.rating_hover +{ + background-image: url(blocks_hover.gif); +} +.TRatingList_blocks td.rating_half +{ + background-image: url(blocks_half.gif); +} +/* Visible focus indicator on the star that owns keyboard focus. */ +.TRatingList_blocks td.rating_focus +{ + outline: 2px solid Highlight; + outline: 2px solid -webkit-focus-ring-color; + outline-offset: -2px; +} diff --git a/framework/Web/Javascripts/source/prado/ratings/default.css b/framework/Web/Javascripts/source/prado/ratings/default.css index c15a36bdb..8b77230e1 100644 --- a/framework/Web/Javascripts/source/prado/ratings/default.css +++ b/framework/Web/Javascripts/source/prado/ratings/default.css @@ -1,29 +1,55 @@ -.TRatingList_default -{ - border-collapse: collapse; -} -.TRatingList_default input, .TRatingList_default label -{ - display: none; -} -.TRatingList_default td -{ - width: 17px; - height: 18px; - background-image: url(default_blank.gif); - background-repeat: no-repeat; - cursor: pointer; -} -.TRatingList_default td.rating_selected -{ - background-image: url(default_selected.gif); -} - -.TRatingList_default td.rating_hover -{ - background-image: url(default_hover.gif); -} -.TRatingList_default td.rating_half -{ - background-image: url(default_half.gif); -} +.TRatingList_default +{ + border-collapse: collapse; +} +/* + * The radio inputs stay in the accessibility tree and the keyboard tab order: + * they are visually hidden rather than display:none so screen readers announce + * the rating radio group and arrow keys move between the stars. The labels are + * redundant with the per-radio aria-label the script sets, so they are removed. + */ +.TRatingList_default input +{ + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + overflow: hidden; + white-space: nowrap; +} +.TRatingList_default label +{ + display: none; +} +.TRatingList_default td +{ + width: 17px; + height: 18px; + background-image: url(default_blank.gif); + background-repeat: no-repeat; + cursor: pointer; +} +.TRatingList_default td.rating_selected +{ + background-image: url(default_selected.gif); +} + +.TRatingList_default td.rating_hover +{ + background-image: url(default_hover.gif); +} +.TRatingList_default td.rating_half +{ + background-image: url(default_half.gif); +} +/* Visible focus indicator on the star that owns keyboard focus. */ +.TRatingList_default td.rating_focus +{ + outline: 2px solid Highlight; + outline: 2px solid -webkit-focus-ring-color; + outline-offset: -2px; +} diff --git a/framework/Web/Javascripts/source/prado/ratings/ratings.js b/framework/Web/Javascripts/source/prado/ratings/ratings.js index 5735916f1..d9515663d 100644 --- a/framework/Web/Javascripts/source/prado/ratings/ratings.js +++ b/framework/Web/Javascripts/source/prado/ratings/ratings.js @@ -23,8 +23,14 @@ Prado.WebUI.TRatingList = Prado.Class(Prado.WebUI.Control, if(td.tagName.toLowerCase()=='td') { + const index = this.radios.length; this.radios.push(radio); td.classList.add("rating"); + // The star cell is decorative; the radio carries the semantics. + td.setAttribute('aria-hidden', 'true'); + // Give the radio an accessible name even when its item text is empty. + if(!radio.getAttribute('aria-label')) + radio.setAttribute('aria-label', this.getIndexCaption(index) || String(index+1)); } } @@ -59,6 +65,15 @@ Prado.WebUI.TRatingList = Prado.Class(Prado.WebUI.Control, }, click(index, ev) { + if(this.readOnly==true) return; + this.select(index, ev); + }, + + /** + * Selects a rating. Shared by mouse clicks on a star cell and by the radio + * `change` event that a keyboard arrow key triggers. + */ + select(index, ev) { if(this.readOnly==true) return; this.selectedIndex = index; this.setRating(index+1); @@ -68,6 +83,27 @@ Prado.WebUI.TRatingList = Prado.Class(Prado.WebUI.Control, } }, + /** Radio `change` (keyboard arrow key or programmatic check). */ + change(index, ev) { + this.select(index, ev); + }, + + /** Radio gains keyboard focus: preview the rating and mark the focused star. */ + focus(index, _ev) { + if(this.readOnly==true) return; + const node = this.radios[index].parentNode.parentNode; + node.classList.add('rating_focus'); + this.hover(index, _ev); + }, + + /** Radio loses keyboard focus: clear the preview. */ + blur(index, _ev) { + if(this.readOnly==true) return; + const node = this.radios[index].parentNode.parentNode; + node.classList.remove('rating_focus'); + this.recover(index, _ev); + }, + dispatchRequest(ev) { const requestOptions = Object.assign({}, this.options, { @@ -134,28 +170,51 @@ Prado.WebUI.TRatingList = Prado.Class(Prado.WebUI.Control, setReadOnly(value) { this.readOnly = value; + // Keep the radiogroup's server-rendered aria-readonly in step when the + // state is toggled from a callback. + const root = document.getElementById(this.options.ID); + if (root) { + if (value) + root.setAttribute('aria-readonly', 'true'); + else + root.removeAttribute('aria-readonly'); + } for(let i = 0; isetViewState('ValidationGroup', TPropertyValue::ensureString($value), ''); } + /** + * @return string the message announced to assistive technology, and the busy + * image alt text, while a file uploads. Defaults to 'Uploading file' passed + * through {@see \Prado\Prado::localize()}. + * @since 4.4.0 + */ + public function getUploadingText() + { + $text = $this->getViewState('UploadingText', ''); + return $text !== '' ? $text : Prado::localize('Uploading file'); + } + + /** + * @param string $value the uploading status message; empty string restores + * the localized default + * @since 4.4.0 + */ + public function setUploadingText($value) + { + $this->setViewState('UploadingText', TPropertyValue::ensureString($value), ''); + } + + /** + * @return string the message announced to assistive technology, and the + * success image alt text, when an upload completes. Defaults to + * 'File upload complete' passed through {@see \Prado\Prado::localize()}. + * @since 4.4.0 + */ + public function getCompleteText() + { + $text = $this->getViewState('CompleteText', ''); + return $text !== '' ? $text : Prado::localize('File upload complete'); + } + + /** + * @param string $value the completed status message; empty string restores + * the localized default + * @since 4.4.0 + */ + public function setCompleteText($value) + { + $this->setViewState('CompleteText', TPropertyValue::ensureString($value), ''); + } + + /** + * @return string the message announced to assistive technology, and the error + * image alt text, when an upload fails. Defaults to 'File upload failed' + * passed through {@see \Prado\Prado::localize()}. + * @since 4.4.0 + */ + public function getErrorText() + { + $text = $this->getViewState('ErrorText', ''); + return $text !== '' ? $text : Prado::localize('File upload failed'); + } + + /** + * @param string $value the error status message; empty string restores the + * localized default + * @since 4.4.0 + */ + public function setErrorText($value) + { + $this->setViewState('ErrorText', TPropertyValue::ensureString($value), ''); + } + /** * @return string A chuck of javascript that will need to be called if {{@see getAutoPostBack AutoPostBack} is set to false} */ @@ -387,25 +459,41 @@ public function createChildControls() $this->_busy = new TImage(); $this->_busy->setID('Busy'); $this->_busy->setImageUrl($this->getAssetUrl('ActiveFileUploadIndicator.gif')); + $this->_busy->setAlternateText($this->getUploadingText()); $this->_busy->setStyle("display:none"); $this->getControls()->add($this->_busy); $this->_success = new TImage(); $this->_success->setID('Success'); $this->_success->setImageUrl($this->getAssetUrl('ActiveFileUploadComplete.png')); + $this->_success->setAlternateText($this->getCompleteText()); $this->_success->setStyle("display:none"); $this->getControls()->add($this->_success); $this->_error = new TImage(); $this->_error->setID('Error'); $this->_error->setImageUrl($this->getAssetUrl('ActiveFileUploadError.png')); + $this->_error->setAlternateText($this->getErrorText()); $this->_error->setStyle("display:none"); $this->getControls()->add($this->_error); + // Visually-hidden live region: assistive tech announces the upload state + // as the script updates this text at each transition. + $this->_status = new TLabel(); + $this->_status->setID('Status'); + $this->_status->getAttributes()->add('role', 'status'); + $this->_status->getAttributes()->add('aria-live', 'polite'); + $this->_status->setStyle('position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap'); + $this->getControls()->add($this->_status); + $this->_target = new TInlineFrame(); $this->_target->setID('Target'); $this->_target->setFrameUrl('about:blank'); $this->_target->setStyle("width:0px; height:0px; border:none"); + // The upload sink carries no content for the user; keep it out of the + // accessibility tree and the tab order. + $this->_target->getAttributes()->add('aria-hidden', 'true'); + $this->_target->getAttributes()->add('tabindex', '-1'); $this->getControls()->add($this->_target); parent::createChildControls(); @@ -485,6 +573,10 @@ protected function getClientOptions() $options['indicatorID'] = $this->_busy->getClientID(); $options['completeID'] = $this->_success->getClientID(); $options['errorID'] = $this->_error->getClientID(); + $options['statusID'] = $this->_status->getClientID(); + $options['uploadingText'] = $this->getUploadingText(); + $options['completeText'] = $this->getCompleteText(); + $options['errorText'] = $this->getErrorText(); $options['autoPostBack'] = $this->getAutoPostBack(); $options['causesValidation'] = $this->getCausesValidation(); return $options; diff --git a/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php b/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php index 033864259..24d26e439 100644 --- a/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php +++ b/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php @@ -10,6 +10,7 @@ namespace Prado\Web\UI\ActiveControls; +use Prado\Prado; use Prado\TPropertyValue; use Prado\Web\THttpUtility; @@ -162,7 +163,9 @@ protected function getPostBackOptions() $options['AutoPostBack'] = $this->getAutoPostBack(); $options['EmptyDisplayText'] = $this->getEmptyDisplayText(); $options['DisplayEditor'] = $this->getDisplayEditor(); - $options['EditorLabel'] = $this->getToolTip(); + // The server-rendered select has no associated label element; ToolTip + // names it, with a localized default so the editor is never nameless. + $options['EditorLabel'] = $this->getToolTip() !== '' ? $this->getToolTip() : Prado::localize('Edit value'); if ($this->hasEventHandler('OnLoadingItems')) { $options['LoadItemsOnEdit'] = true; diff --git a/framework/Web/UI/ActiveControls/TInPlaceTextBox.php b/framework/Web/UI/ActiveControls/TInPlaceTextBox.php index b86c977ae..e01002c41 100644 --- a/framework/Web/UI/ActiveControls/TInPlaceTextBox.php +++ b/framework/Web/UI/ActiveControls/TInPlaceTextBox.php @@ -196,7 +196,9 @@ protected function getPostBackOptions() $options['AutoPostBack'] = $this->getAutoPostBack() == false ? '' : true; $options['EmptyDisplayText'] = $this->getEmptyDisplayText(); $options['DisplayEditor'] = $this->getDisplayEditor(); - $options['EditorLabel'] = $this->getToolTip(); + // The created text input has no associated label element; ToolTip names + // it, with a localized default so the editor is never nameless. + $options['EditorLabel'] = $this->getToolTip() !== '' ? $this->getToolTip() : Prado::localize('Edit value'); $options['Columns'] = $this->getColumns(); if ($this->getTextMode() === 'MultiLine') { $options['Rows'] = $this->getRows(); diff --git a/framework/Web/UI/WebControls/TCheckBoxList.php b/framework/Web/UI/WebControls/TCheckBoxList.php index b2391d9b9..e4e21302a 100644 --- a/framework/Web/UI/WebControls/TCheckBoxList.php +++ b/framework/Web/UI/WebControls/TCheckBoxList.php @@ -388,6 +388,31 @@ protected function getSpanNeeded() return $this->getRepeatLayout() === TRepeatLayout::Raw; } + /** + * @return string the ARIA role that groups the list items. Defaults to + * `group`; {@see TRadioButtonList} overrides it with `radiogroup`. + * @since 4.4.0 + */ + protected function getGroupRole(): string + { + return 'group'; + } + + /** + * Adds the grouping ARIA attributes to the list container so assistive + * technology announces the items as one named set. The accessible name comes + * from {@see getToolTip ToolTip} when set. + * @param \Prado\Web\UI\THtmlWriter $writer writer for rendering purpose. + * @since 4.4.0 + */ + protected function addGroupAttributesToRender($writer) + { + $writer->addAttribute('role', $this->getGroupRole()); + if (($toolTip = $this->getToolTip()) !== '') { + $writer->addAttribute('aria-label', $toolTip); + } + } + /** * Renders the checkbox list control. * This method overrides the parent implementation. @@ -397,6 +422,7 @@ public function render($writer) { if ($needSpan = $this->getSpanNeeded()) { $writer->addAttribute('id', $this->getClientId()); + $this->addGroupAttributesToRender($writer); $writer->renderBeginTag('span'); } if ($this->getItemCount() > 0) { @@ -410,6 +436,9 @@ public function render($writer) $this->setAccessKey(''); $this->setTabIndex(0); $this->addAttributesToRender($writer); + if (!$needSpan) { + $this->addGroupAttributesToRender($writer); + } $repeatInfo->renderRepeater($writer, $this); $this->setAccessKey($accessKey); $this->setTabIndex($tabIndex); diff --git a/framework/Web/UI/WebControls/TDatePicker.php b/framework/Web/UI/WebControls/TDatePicker.php index ef3129fae..cda12a1dd 100644 --- a/framework/Web/UI/WebControls/TDatePicker.php +++ b/framework/Web/UI/WebControls/TDatePicker.php @@ -570,7 +570,7 @@ protected function getDatePickerOptions() } $options['PositionMode'] = $this->getPositionMode(); - $options = array_merge($options, $this->getCulturalOptions()); + $options = array_merge($options, $this->getCulturalOptions(), $this->getAccessibleTextOptions()); if ($this->_clientScript !== null) { $options = array_merge( $options, @@ -598,6 +598,32 @@ protected function getCulturalOptions() return $options; } + /** + * Returns the calendar's assistive-technology labels that + * {@see \Prado\Prado::localize()} translates to something other than the + * client-side English defaults. Untranslated labels are omitted; the + * JavaScript class supplies them. + * @return array translated label options for the client-side calendar + * @since 4.4.0 + */ + protected function getAccessibleTextOptions() + { + $labels = [ + 'CalendarLabel' => 'Calendar', + 'MonthLabel' => 'Month', + 'YearLabel' => 'Year', + 'PrevMonthLabel' => 'Previous month', + 'NextMonthLabel' => 'Next month', + ]; + $options = []; + foreach ($labels as $key => $text) { + if (($localized = Prado::localize($text)) !== $text) { + $options[$key] = $localized; + } + } + return $options; + } + /** * @return string the current culture, falls back to application if culture is not set. */ @@ -835,6 +861,9 @@ protected function renderButtonDatePicker($writer) $writer->addAttribute('type', 'button'); $writer->addAttribute('class', $this->getCssClass() . ' TDatePickerButton'); $writer->addAttribute('value', $this->getButtonText()); + $writer->addAttribute('aria-label', $this->getTriggerAccessibleName()); + $writer->addAttribute('aria-haspopup', 'dialog'); + $writer->addAttribute('aria-expanded', 'false'); if (!$this->getEnabled(true)) { $writer->addAttribute('disabled', 'disabled'); } @@ -842,6 +871,19 @@ protected function renderButtonDatePicker($writer) $writer->renderEndTag(); } + /** + * @return string the accessible name for the trigger that opens the calendar. + * Uses {@see getToolTip ToolTip} when set, otherwise 'Choose date' passed + * through {@see \Prado\Prado::localize()}. The visible + * {@see getButtonText ButtonText} (which defaults to "...") is not a useful + * name for assistive technology. + * @since 4.4.0 + */ + protected function getTriggerAccessibleName(): string + { + return $this->getToolTip() !== '' ? $this->getToolTip() : Prado::localize('Choose date'); + } + /** * Adds an additional image button such that when clicked it shows the date picker. * @param \Prado\Web\UI\THtmlWriter $writer @@ -852,7 +894,9 @@ protected function renderImageButtonDatePicker($writer) $url = empty($url) ? $this->getAssetUrl('calendar.png') : $url; $writer->addAttribute('id', $this->getDatePickerButtonID()); $writer->addAttribute('src', $url); - $writer->addAttribute('alt', ' '); + $writer->addAttribute('alt', $this->getTriggerAccessibleName()); + $writer->addAttribute('aria-haspopup', 'dialog'); + $writer->addAttribute('aria-expanded', 'false'); $writer->addAttribute('class', $this->getCssClass() . ' TDatePickerImageButton'); if (!$this->getEnabled(true)) { $writer->addAttribute('disabled', 'disabled'); diff --git a/framework/Web/UI/WebControls/TRadioButtonList.php b/framework/Web/UI/WebControls/TRadioButtonList.php index 7a3bb6f24..9f4c0669b 100644 --- a/framework/Web/UI/WebControls/TRadioButtonList.php +++ b/framework/Web/UI/WebControls/TRadioButtonList.php @@ -86,4 +86,13 @@ protected function getClientClassName() { return 'Prado.WebUI.TRadioButtonList'; } + + /** + * @return string the ARIA role that groups the radio items: `radiogroup`. + * @since 4.4.0 + */ + protected function getGroupRole(): string + { + return 'radiogroup'; + } } diff --git a/framework/Web/UI/WebControls/TRatingList.php b/framework/Web/UI/WebControls/TRatingList.php index ff957c0e9..d3e9c7500 100644 --- a/framework/Web/UI/WebControls/TRatingList.php +++ b/framework/Web/UI/WebControls/TRatingList.php @@ -351,6 +351,12 @@ protected function getAssetUrl($file = '') public function render($writer) { $writer->addAttribute('id', $this->getClientID()); + // TRadioButtonList supplies role="radiogroup" and the aria-label; the + // visually-hidden radios keep the group operable by keyboard and readable + // by assistive tech. A read-only rating additionally announces as such. + if ($this->getReadOnly()) { + $writer->addAttribute('aria-readonly', 'true'); + } $this->getPage()->getClientScript()->registerPostBackControl( $this->getClientClassName(), $this->getPostBackOptions() diff --git a/tests/harness/tickets/protected/messages/en/messages.xml b/tests/harness/tickets/protected/messages/en/messages.xml index 9b04d076e..ddbea0fff 100644 --- a/tests/harness/tickets/protected/messages/en/messages.xml +++ b/tests/harness/tickets/protected/messages/en/messages.xml @@ -1,16 +1,46 @@ - + {field} is required. -Lütfen '{field}' alanını doldurunuz. +Lütfen '{field}' alanını doldurunuz. city -Şehir +Şehir + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/harness/web/protected/pages/ListGroupingA11yTest.page b/tests/harness/web/protected/pages/ListGroupingA11yTest.page new file mode 100644 index 000000000..ab1460ca5 --- /dev/null +++ b/tests/harness/web/protected/pages/ListGroupingA11yTest.page @@ -0,0 +1,20 @@ + +

List Grouping Accessibility Test Case

+ + + + + + + + + + + + + + + + + +
diff --git a/tests/harness/web/protected/pages/ListGroupingA11yTest.php b/tests/harness/web/protected/pages/ListGroupingA11yTest.php new file mode 100644 index 000000000..cb24c58a5 --- /dev/null +++ b/tests/harness/web/protected/pages/ListGroupingA11yTest.php @@ -0,0 +1,5 @@ + { } }); }); + +// ─── accessibility: live-region announcements ───────────────────────────────── + +describe('TActiveFileUpload accessibility', () => { + let dom; + + beforeEach(() => { + clearRegistry(); clearControls(); + dom = buildDOM(); + }); + + afterEach(() => { + restoreMocks(); + destroyDOM(dom); + }); + + it('announces the uploading state when a file is selected', () => { + const ctrl = new TActiveFileUpload(IDS); + Object.defineProperty(dom.fileInput, 'value', { + get: () => 'C:\\fakepath\\file.txt', + configurable: true, + }); + ctrl.fileChanged(); + expect(dom.status.textContent).toBe('Uploading file'); + }); + + it('announces completion on a successful finish', () => { + const ctrl = new TActiveFileUpload(IDS); + ctrl.finishoptions = { errorCode: '0' }; + ctrl.finishCallBack(true); + expect(dom.status.textContent).toBe('File upload complete'); + }); + + it('announces failure on an unsuccessful finish', () => { + const ctrl = new TActiveFileUpload(IDS); + ctrl.finishoptions = { errorCode: '1' }; + ctrl.finishCallBack(false); + expect(dom.status.textContent).toBe('File upload failed'); + }); + + it('announce() is a no-op without a status element', () => { + const ctrl = new TActiveFileUpload(IDS); + ctrl.status = null; + expect(() => ctrl.announce('x')).not.toThrow(); + }); +}); diff --git a/tests/js/datepicker/datepicker.test.js b/tests/js/datepicker/datepicker.test.js index 2995d9e32..6d27a5940 100644 --- a/tests/js/datepicker/datepicker.test.js +++ b/tests/js/datepicker/datepicker.test.js @@ -665,6 +665,90 @@ describe('TDatePicker#create — calendar DOM structure', () => { expect(header).not.toBeNull(); }); + it('_calDiv is a labeled dialog for assistive technology', () => { + expect(picker._calDiv.getAttribute('role')).toBe('dialog'); + expect(picker._calDiv.getAttribute('aria-label')).toBe('Calendar'); + }); + + it('toggles aria-expanded on the trigger as the calendar shows and hides', () => { + picker.show(); + expect(picker.trigger.getAttribute('aria-expanded')).toBe('true'); + picker.hide(); + expect(picker.trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('links the trigger to the calendar via aria-controls', () => { + expect(picker._calDiv.id).toBe(`${picker.options.ID}_calendar`); + expect(picker.trigger.getAttribute('aria-controls')).toBe(picker._calDiv.id); + }); + + it('the date table is a grid labeled with the visible month and year', () => { + expect(picker._gridTable.getAttribute('role')).toBe('grid'); + const expected = `${picker.MonthNames[picker.selectedDate.getMonth()]} ${picker.selectedDate.getFullYear()}`; + expect(picker._gridTable.getAttribute('aria-label')).toBe(expected); + }); + + it('weekday headers carry scope=col', () => { + const heads = picker._calDiv.querySelectorAll('th.weekDayHead'); + expect(heads.length).toBe(7); + heads.forEach((th) => expect(th.getAttribute('scope')).toBe('col')); + }); + + it('marks the selected date aria-selected and today aria-current', () => { + const selected = picker._calDiv.querySelectorAll('td[aria-selected="true"]'); + expect(selected.length).toBe(1); + expect(selected[0].classList.contains('selected')).toBe(true); + const current = picker._calDiv.querySelectorAll('td[aria-current="date"]'); + expect(current.length).toBeLessThanOrEqual(1); + }); + + it('hides the padding cells from assistive technology', () => { + const empties = picker._calDiv.querySelectorAll('td.empty'); + empties.forEach((td) => { + expect(td.getAttribute('aria-hidden')).toBe('true'); + expect(td.hasAttribute('aria-selected')).toBe(false); + }); + }); + + it('labels the month/year selects and the month step buttons', () => { + expect(picker._monthSelect.getAttribute('aria-label')).toBe('Month'); + expect(picker._yearSelect.getAttribute('aria-label')).toBe('Year'); + expect(picker._calDiv.querySelector('.prevMonthButton').getAttribute('aria-label')).toBe('Previous month'); + expect(picker._calDiv.querySelector('.nextMonthButton').getAttribute('aria-label')).toBe('Next month'); + }); + + it('server-sent translations override the label defaults', () => { + // The PHP side sends these options only when Prado::localize() changes them + const id = nextId(); + buildDOM(id); + const localized = new TDatePicker(makeOptions(id, { + CalendarLabel: 'Kalender', + MonthLabel: 'Monat', + YearLabel: 'Jahr', + PrevMonthLabel: 'Voriger Monat', + NextMonthLabel: 'Nächster Monat', + })); + localized.create(); + expect(localized._calDiv.getAttribute('aria-label')).toBe('Kalender'); + expect(localized._monthSelect.getAttribute('aria-label')).toBe('Monat'); + expect(localized._yearSelect.getAttribute('aria-label')).toBe('Jahr'); + expect(localized._calDiv.querySelector('.prevMonthButton').getAttribute('aria-label')).toBe('Voriger Monat'); + expect(localized._calDiv.querySelector('.nextMonthButton').getAttribute('aria-label')).toBe('Nächster Monat'); + document.getElementById(`${id}_container`)?.remove(); + delete global.Prado.Registry[id]; + }); + + it('points aria-activedescendant at the selected cell and announces the date', () => { + picker.show(); + picker.setSelectedDate(new Date(2020, 9, 20)); // Oct 20 2020 + const cell = picker._calDiv.querySelector('td[aria-selected="true"]'); + expect(cell.id).toMatch(new RegExp(`^${picker.options.ID}_day\\d+$`)); + expect(picker.control.getAttribute('aria-activedescendant')).toBe(cell.id); + expect(picker._liveRegion.textContent).toBe('October 20, 2020'); + picker.hide(); + expect(picker.control.hasAttribute('aria-activedescendant')).toBe(false); + }); + it('_calDiv has a calendarBody child', () => { const body = picker._calDiv.querySelector('.calendarBody'); expect(body).not.toBeNull(); diff --git a/tests/js/ratings/ratings.test.js b/tests/js/ratings/ratings.test.js index 2d917a8c4..c2a97a5f9 100644 --- a/tests/js/ratings/ratings.test.js +++ b/tests/js/ratings/ratings.test.js @@ -635,6 +635,99 @@ describe('TRatingList edge cases', () => { }); }); +// ─── TRatingList — accessibility ───────────────────────────────────────────── + +describe('TRatingList accessibility', () => { + let rl, cleanup; + + beforeEach(() => { + const built = buildRatingDOM('rla', 5); + cleanup = built.cleanup; + rl = new TRatingList(built.options); + }); + + afterEach(() => cleanup()); + + it('labels each radio so it has an accessible name', () => { + rl.radios.forEach((radio, i) => { + expect(radio.getAttribute('aria-label')).toBe('Star ' + (i + 1)); + }); + }); + + it('falls back to the position when the radio value is empty', () => { + cleanup(); + const built = buildRatingDOM('rlb', 3); + cleanup = built.cleanup; + // blank the values before init + for (let i = 0; i < 3; i++) { + document.getElementById('rlb_c' + i).value = ''; + } + const r = new TRatingList(built.options); + expect(r.radios[2].getAttribute('aria-label')).toBe('3'); + }); + + it('marks the decorative star cells aria-hidden', () => { + rl.radios.forEach((radio) => { + expect(radio.parentNode.parentNode.getAttribute('aria-hidden')).toBe('true'); + }); + }); + + it('does not put display:none on the radios (keeps them focusable)', () => { + // The control never sets inline display:none; hiding is CSS visually-hidden. + rl.radios.forEach((radio) => { + expect(radio.style.display).not.toBe('none'); + }); + }); + + it('a radio change (keyboard arrow) selects that rating', () => { + // simulate arrow-key selection: the browser checks the radio and fires change + rl.radios[3].checked = true; + rl.radios[3].dispatchEvent(new Event('change')); + expect(rl.selectedIndex).toBe(3); + expect(rl.rating).toBe(4); + }); + + it('a radio change dispatches a postback when AutoPostBack is on', () => { + cleanup(); + const built = buildRatingDOM('rlc', 5, { AutoPostBack: true }); + cleanup = built.cleanup; + const r = new TRatingList(built.options); + global.Prado.PostBack = vi.fn(); + r.radios[1].checked = true; + r.radios[1].dispatchEvent(new Event('change')); + expect(global.Prado.PostBack).toHaveBeenCalled(); + expect(global.Prado.PostBack.mock.calls[0][0].ID).toBe('rlc_c1'); + }); + + it('focus previews the star and marks it, blur clears it', () => { + const td = rl.radios[2].parentNode.parentNode; + rl.radios[2].dispatchEvent(new Event('focus')); + expect(td.classList.contains('rating_focus')).toBe(true); + rl.radios[2].dispatchEvent(new Event('blur')); + expect(td.classList.contains('rating_focus')).toBe(false); + }); + + it('toggling read-only keeps the group aria-readonly in step', () => { + const root = document.getElementById('rla'); + rl.setReadOnly(true); + expect(root.getAttribute('aria-readonly')).toBe('true'); + rl.setReadOnly(false); + expect(root.hasAttribute('aria-readonly')).toBe(false); + }); + + it('read-only disables the radios and ignores keyboard changes', () => { + cleanup(); + const built = buildRatingDOM('rld', 5, { ReadOnly: true }); + cleanup = built.cleanup; + const r = new TRatingList(built.options); + r.radios.forEach((radio) => expect(radio.disabled).toBe(true)); + // a stray change must not move the rating + const before = r.rating; + r.change(4, { preventDefault() {} }); + expect(r.rating).toBe(before); + }); +}); + // ─── TActiveRatingList — class existence and inheritance ───────────────────── describe('TActiveRatingList', () => { diff --git a/tests/playwright/active-controls/ActiveRatingListAccessibilityTestCase.spec.js b/tests/playwright/active-controls/ActiveRatingListAccessibilityTestCase.spec.js new file mode 100644 index 000000000..9426c5ba7 --- /dev/null +++ b/tests/playwright/active-controls/ActiveRatingListAccessibilityTestCase.spec.js @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; +import { genericHelper } from '../helpers.js'; + +/** + * Accessibility of the rating widget (parent TRatingList behavior, inherited by + * TActiveRatingList): the star cells are a real radio group that assistive tech + * can read and the keyboard can operate. Regression guard for the previous + * `display:none` on the radios, which removed them from the a11y tree entirely. + */ +test.describe('ActiveRatingListAccessibilityTestCase', () => { + const base = 'ctl0_Content_'; + + test('renders an operable, labeled radio group', async ({ page }) => { + const h = genericHelper(page); + await h.url('active-controls/index.php?page=ActiveRatingListAutoPostBackTest'); + await h.assertSourceContains('TActiveRatingList AutoPostBack Test Case'); + + const group = page.locator(`#${base}RatingList`); + await expect(group).toHaveAttribute('role', 'radiogroup'); + + // Each radio has an accessible name from its item text + await expect(page.locator(`#${base}RatingList_c0`)).toHaveAttribute('aria-label', 'Poor'); + await expect(page.locator(`#${base}RatingList_c3`)).toHaveAttribute('aria-label', 'Good'); + + // The radios are in the accessibility tree and focusable (not display:none) + const display = await page.locator(`#${base}RatingList_c0`).evaluate( + (el) => getComputedStyle(el).display + ); + expect(display).not.toBe('none'); + + // The decorative star cells are hidden from assistive tech + const cellHidden = await page.locator(`#${base}RatingList_c0`).evaluate( + (el) => el.closest('td').getAttribute('aria-hidden') + ); + expect(cellHidden).toBe('true'); + + // Keyboard: focus a star's radio and activate it with the Space key, the + // native radio behavior — no mouse involved + await page.locator(`#${base}RatingList_c3`).focus(); + await expect(page.locator(`#${base}RatingList_c3`)).toBeFocused(); + await page.keyboard.press('Space'); + await expect(page.locator(`#${base}RatingList_c3`)).toBeChecked(); + // The change handler updated the visual selection (stars 0..3 selected) + const selectedStars = await page.locator(`#${base}RatingList td.rating_selected`).count(); + expect(selectedStars).toBe(4); + }); + + test('read-only rating is disabled and marked aria-readonly', async ({ page }) => { + const h = genericHelper(page); + await h.url('active-controls/index.php?page=ActiveRatingListReadOnlyTest'); + + const group = page.locator(`#${base}RatingList`); + await expect(group).toHaveAttribute('aria-readonly', 'true'); + await expect(page.locator(`#${base}RatingList_c0`)).toBeDisabled(); + + // A callback toggling ReadOnly keeps the group state and radios in step + await page.locator(`#${base}Writable`).click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${base}RatingList_c0`)).toBeEnabled(); + expect(await group.getAttribute('aria-readonly')).toBeNull(); + + await page.locator(`#${base}ReadOnly`).click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${base}RatingList_c0`)).toBeDisabled(); + await expect(group).toHaveAttribute('aria-readonly', 'true'); + }); +}); diff --git a/tests/playwright/web/DatePickerA11yTestCase.spec.js b/tests/playwright/web/DatePickerA11yTestCase.spec.js new file mode 100644 index 000000000..739902348 --- /dev/null +++ b/tests/playwright/web/DatePickerA11yTestCase.spec.js @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; +import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js'; + +/** + * Accessibility of the TDatePicker calendar in a real browser: the trigger is a + * named popup button, the date table is a labeled grid whose selected cell is + * exposed, keyboard navigation moves the selection while focus stays on the + * input, and Escape closes the popup. + * + * Uses the Ticket670 harness page: a Button-mode, TextBox-input date picker. + */ +test('DatePickerA11yTestCase', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url('tickets/index.php?page=Ticket670'); + + // The trigger button carries a name and popup semantics + const trigger = page.locator('input[aria-haspopup="dialog"]').first(); + await expect(trigger).toHaveAttribute('aria-label', 'Choose date'); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + // Open the calendar + await trigger.click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await expect(trigger).toHaveAttribute('aria-controls', /.+_calendar$/); + + // The popup is a dialog holding a grid labeled "MonthName Year" + const dialog = page.locator('div[role="dialog"][aria-label="Calendar"]'); + await expect(dialog).toBeVisible(); + const grid = dialog.locator('table[role="grid"]'); + await expect(grid).toHaveAttribute('aria-label', /^[^ ]+ \d{4}$/); + + // Exactly one selected gridcell; the input points at it while it has focus + const selected = grid.locator('td[aria-selected="true"]'); + await expect(selected).toHaveCount(1); + const cellId = await selected.getAttribute('id'); + const input = page.locator('[aria-activedescendant]'); + await expect(input).toHaveAttribute('aria-activedescendant', cellId); + + // Arrow keys move the selection without moving focus off the input + const before = await selected.textContent(); + await page.keyboard.press('ArrowRight'); + const after = await grid.locator('td[aria-selected="true"]').textContent(); + expect(after).not.toBe(before); + + // Padding cells are hidden from assistive technology + const empties = grid.locator('td.empty'); + if (await empties.count() > 0) { + await expect(empties.first()).toHaveAttribute('aria-hidden', 'true'); + } + + // Escape closes the popup and restores the collapsed state + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(await page.locator('[aria-activedescendant]').count()).toBe(0); +}); diff --git a/tests/playwright/web/ListGroupingA11yTestCase.spec.js b/tests/playwright/web/ListGroupingA11yTestCase.spec.js new file mode 100644 index 000000000..eb08364fd --- /dev/null +++ b/tests/playwright/web/ListGroupingA11yTestCase.spec.js @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; +import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js'; + +const PAGE_URL = 'web/index.php?page=ListGroupingA11yTest'; + +/** + * TCheckBoxList and TRadioButtonList expose their items as a named group so + * assistive technology announces them as one set rather than a bare table. + */ +test('ListGroupingA11yTestCase', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + await h.assertSourceContains('List Grouping Accessibility Test Case'); + + // TCheckBoxList -> role=group, named from ToolTip + const cbl = page.locator('#ctl0_Content_cbl'); + await expect(cbl).toHaveAttribute('role', 'group'); + await expect(cbl).toHaveAttribute('aria-label', 'Pick your toppings'); + + // TRadioButtonList -> role=radiogroup, named from ToolTip + const rbl = page.locator('#ctl0_Content_rbl'); + await expect(rbl).toHaveAttribute('role', 'radiogroup'); + await expect(rbl).toHaveAttribute('aria-label', 'Choose a size'); + + // Without a ToolTip the role is still applied (no aria-label) + const cblNoTip = page.locator('#ctl0_Content_cblNoTip'); + await expect(cblNoTip).toHaveAttribute('role', 'group'); + expect(await cblNoTip.getAttribute('aria-label')).toBeNull(); + + // The items remain real, keyboard-operable inputs inside the group + await expect(page.locator('#ctl0_Content_rbl input[type=radio]')).toHaveCount(3); + await expect(page.locator('#ctl0_Content_cbl input[type=checkbox]')).toHaveCount(3); +}); diff --git a/tests/unit/Web/UI/ActiveControls/TActiveFileUploadTest.php b/tests/unit/Web/UI/ActiveControls/TActiveFileUploadTest.php index 8c6803712..dfd2045c6 100644 --- a/tests/unit/Web/UI/ActiveControls/TActiveFileUploadTest.php +++ b/tests/unit/Web/UI/ActiveControls/TActiveFileUploadTest.php @@ -38,6 +38,34 @@ public function testSetValidationGroup() $this->assertEquals('uploadGroup', $upload->getValidationGroup()); } + public function testStatusTextsDefaultToLocalizedEnglish() + { + // Without a translation module, Prado::localize() returns the literal + $upload = new TActiveFileUpload(); + $this->assertSame('Uploading file', $upload->getUploadingText()); + $this->assertSame('File upload complete', $upload->getCompleteText()); + $this->assertSame('File upload failed', $upload->getErrorText()); + } + + public function testStatusTextsAcceptCustomValues() + { + $upload = new TActiveFileUpload(); + $upload->setUploadingText('Wird hochgeladen'); + $upload->setCompleteText('Hochladen abgeschlossen'); + $upload->setErrorText('Hochladen fehlgeschlagen'); + $this->assertSame('Wird hochgeladen', $upload->getUploadingText()); + $this->assertSame('Hochladen abgeschlossen', $upload->getCompleteText()); + $this->assertSame('Hochladen fehlgeschlagen', $upload->getErrorText()); + } + + public function testEmptyStatusTextRestoresTheDefault() + { + $upload = new TActiveFileUpload(); + $upload->setUploadingText('Custom'); + $upload->setUploadingText(''); + $this->assertSame('Uploading file', $upload->getUploadingText()); + } + public function testInheritsAcceptAndCapture() { $upload = new TActiveFileUpload(); diff --git a/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php b/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php index de286b589..e80194e0c 100644 --- a/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php +++ b/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php @@ -240,6 +240,15 @@ public function testPostBackOptionsCarryEditorLabelFromToolTip() $this->assertSame('Pick a color', $options['EditorLabel']); } + public function testEditorLabelDefaultsWhenToolTipIsEmpty() + { + // The editor must never be nameless; without a translation module + // Prado::localize() returns the English literal + $control = new TInPlaceDropDownList(); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertSame('Edit value', $options['EditorLabel']); + } + // --- getPostBackOptions --- public function testPostBackOptions() diff --git a/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php b/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php index 980bc34e3..9156dadb4 100644 --- a/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php +++ b/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php @@ -206,6 +206,15 @@ public function testPostBackOptionsCarryEditorLabelFromToolTip() $this->assertSame('Pick a color', $options['EditorLabel']); } + public function testEditorLabelDefaultsWhenToolTipIsEmpty() + { + // The editor must never be nameless; without a translation module + // Prado::localize() returns the English literal + $control = $this->multiSelectListBox(); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertSame('Edit value', $options['EditorLabel']); + } + // --- getPostBackOptions --- public function testPostBackOptions() diff --git a/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php b/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php index 1f4ad7f0c..222ffc40a 100644 --- a/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php +++ b/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php @@ -136,6 +136,15 @@ public function testPostBackOptionsCarryEditorLabelFromToolTip() $this->assertSame('Favorite color', $options['EditorLabel']); } + public function testEditorLabelDefaultsWhenToolTipIsEmpty() + { + // The editor must never be nameless; without a translation module + // Prado::localize() returns the English literal + $control = new TInPlaceTextBox(); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertSame('Edit value', $options['EditorLabel']); + } + public function testEditTriggerControlID() { $control = new TInPlaceTextBox(); diff --git a/tests/unit/Web/UI/WebControls/TDatePickerTest.php b/tests/unit/Web/UI/WebControls/TDatePickerTest.php index 1956367cf..7bf98c072 100644 --- a/tests/unit/Web/UI/WebControls/TDatePickerTest.php +++ b/tests/unit/Web/UI/WebControls/TDatePickerTest.php @@ -405,4 +405,26 @@ public function testValidationPropertyValueInvalidDateReturnsText() $result = $picker->getValidationPropertyValue(); $this->assertEquals('not-a-date', $result); } + + public function testTriggerAccessibleNameDefaultsToLocalizedChooseDate() + { + // Without a translation module, Prado::localize() returns the literal + $picker = new TDatePicker(); + $this->assertSame('Choose date', PradoUnit::invoke($picker, 'getTriggerAccessibleName')); + } + + public function testTriggerAccessibleNamePrefersToolTip() + { + $picker = new TDatePicker(); + $picker->setToolTip('Pick a delivery date'); + $this->assertSame('Pick a delivery date', PradoUnit::invoke($picker, 'getTriggerAccessibleName')); + } + + public function testAccessibleTextOptionsOmitUntranslatedLabels() + { + // The client-side class carries the English defaults; the server sends a + // label only when a translation changes it, which none does here + $picker = new TDatePicker(); + $this->assertSame([], PradoUnit::invoke($picker, 'getAccessibleTextOptions')); + } } From b59ce3bd6f031494971f2319c1ca2f0bd78c6574 Mon Sep 17 00:00:00 2001 From: Belisoful Date: Sun, 30 Aug 2026 04:37:08 +0000 Subject: [PATCH 2/4] TAccordion, TSlider, TTabView/TTabPanel, TBaseValidator, TValidationSummary - Accessibility corrections --- .../source/prado/controls/accordion.js | 45 +++++++- .../source/prado/controls/slider.js | 37 ++++++ .../source/prado/controls/tabpanel.js | 48 ++++++++ .../source/prado/validator/validation3.js | 43 +++++++ .../Web/UI/WebControls/TAccordionView.php | 15 +++ .../Web/UI/WebControls/TBaseValidator.php | 3 + framework/Web/UI/WebControls/TSlider.php | 17 ++- framework/Web/UI/WebControls/TTabPanel.php | 9 +- framework/Web/UI/WebControls/TTabView.php | 30 ++++- .../Web/UI/WebControls/TValidationSummary.php | 3 + .../protected/pages/AccordionA11yTest.page | 15 +++ .../web/protected/pages/AccordionA11yTest.php | 5 + .../web/protected/pages/SliderA11yTest.page | 4 + .../web/protected/pages/SliderA11yTest.php | 5 + tests/js/controls/accordion.test.js | 61 ++++++++++ tests/js/controls/slider.test.js | 85 +++++++++++++- tests/js/controls/tabpanel.test.js | 83 +++++++++++++- tests/js/validator/validation.test.js | 108 ++++++++++++++++++ ...RequiredFieldValidatorA11yTestCase.spec.js | 36 ++++++ .../web/AccordionA11yTestCase.spec.js | 49 ++++++++ .../playwright/web/SliderA11yTestCase.spec.js | 40 +++++++ .../web/TabPanelA11yTestCase.spec.js | 57 +++++++++ .../UI/WebControls/TValidationSummaryTest.php | 37 ++++++ 23 files changed, 821 insertions(+), 14 deletions(-) create mode 100644 tests/harness/web/protected/pages/AccordionA11yTest.page create mode 100644 tests/harness/web/protected/pages/AccordionA11yTest.php create mode 100644 tests/harness/web/protected/pages/SliderA11yTest.page create mode 100644 tests/harness/web/protected/pages/SliderA11yTest.php create mode 100644 tests/playwright/validators/RequiredFieldValidatorA11yTestCase.spec.js create mode 100644 tests/playwright/web/AccordionA11yTestCase.spec.js create mode 100644 tests/playwright/web/SliderA11yTestCase.spec.js create mode 100644 tests/playwright/web/TabPanelA11yTestCase.spec.js create mode 100644 tests/unit/Web/UI/WebControls/TValidationSummaryTest.php diff --git a/framework/Web/Javascripts/source/prado/controls/accordion.js b/framework/Web/Javascripts/source/prado/controls/accordion.js index 013f4d2fb..43559cc4d 100644 --- a/framework/Web/Javascripts/source/prado/controls/accordion.js +++ b/framework/Web/Javascripts/source/prado/controls/accordion.js @@ -61,6 +61,7 @@ Prado.WebUI.TAccordion = Prado.Class(Prado.WebUI.Control, if(header) { this.observe(header, "click", this.elementClicked.bind(this, view)); + this.observe(header, "keydown", this.keyPressed.bind(this, view)); if(this.hiddenField.value == i) { this.currentView = view; @@ -114,17 +115,53 @@ Prado.WebUI.TAccordion = Prado.Class(Prado.WebUI.Control, } if (old) old.style.display = 'none'; - if (oldHdr) { oldHdr.className = ''; oldHdr.classList.add(this.options.HeaderCssClass); } - if (curHdr) { curHdr.className = ''; curHdr.classList.add(this.options.ActiveHeaderCssClass); } + if (oldHdr) { oldHdr.className = ''; oldHdr.classList.add(this.options.HeaderCssClass); this.setExpanded(oldHdr, false); } + if (curHdr) { curHdr.className = ''; curHdr.classList.add(this.options.ActiveHeaderCssClass); this.setExpanded(curHdr, true); } } } }, + /** Reflects the open/closed state of a header for assistive technology. */ + setExpanded(header, expanded) { + if (header && header.hasAttribute('role')) + header.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + }, + + /** + * Keyboard for the header buttons: Enter/Space toggles the region, the arrow + * keys move between headers, and Home/End jump to the first or last. + */ + keyPressed(viewID, event) { + const kc = event.keyCode; + if (kc == 13 || kc == 32) { // Enter or Space + event.preventDefault(); + this.elementClicked(viewID, event); + return; + } + if (kc < 35 || kc > 40) return; // not Home/End/arrows + + const headers = []; + for (const view in this.options.Views) { + const h = document.getElementById(`${view}_0`); + if (h) headers.push(h); + } + let pos = headers.findIndex((h) => h.id == `${viewID}_0`); + if (pos === -1) return; + + if (kc == 37 || kc == 38) pos = (pos - 1 + headers.length) % headers.length; + else if (kc == 39 || kc == 40) pos = (pos + 1) % headers.length; + else if (kc == 36) pos = 0; + else if (kc == 35) pos = headers.length - 1; + + event.preventDefault(); + headers[pos].focus(); + }, + animate() { const oldHdr = document.getElementById(`${this.oldView}_0`); const curHdr = document.getElementById(`${this.currentView}_0`); - if (oldHdr) { oldHdr.className = ''; oldHdr.classList.add(this.options.HeaderCssClass); } - if (curHdr) { curHdr.className = ''; curHdr.classList.add(this.options.ActiveHeaderCssClass); } + if (oldHdr) { oldHdr.className = ''; oldHdr.classList.add(this.options.HeaderCssClass); this.setExpanded(oldHdr, false); } + if (curHdr) { curHdr.className = ''; curHdr.classList.add(this.options.ActiveHeaderCssClass); this.setExpanded(curHdr, true); } const old = document.getElementById(this.oldView); const cur = document.getElementById(this.currentView); diff --git a/framework/Web/Javascripts/source/prado/controls/slider.js b/framework/Web/Javascripts/source/prado/controls/slider.js index e06bd5a51..22d5c1451 100644 --- a/framework/Web/Javascripts/source/prado/controls/slider.js +++ b/framework/Web/Javascripts/source/prado/controls/slider.js @@ -59,6 +59,7 @@ Prado.WebUI.TSlider = Prado.Class(Prado.WebUI.PostBackControl, // Initialize handle this.setValue(parseFloat(slider.options.sliderValue)); this.observe (this.handle, "mousedown", this.eventMouseDown); + this.observe (this.handle, "keydown", this.keyPressed.bind(this)); this.observe (this.track, "mousedown", this.eventMouseDown); if (this.progress) this.observe (this.progress, "mousedown", this.eventMouseDown); @@ -95,9 +96,17 @@ Prado.WebUI.TSlider = Prado.Class(Prado.WebUI.PostBackControl, setDisabled() { this.disabled = true; + if (this.handle) { + this.handle.setAttribute('aria-disabled', 'true'); + this.handle.removeAttribute('tabindex'); + } }, setEnabled() { this.disabled = false; + if (this.handle) { + this.handle.removeAttribute('aria-disabled'); + this.handle.setAttribute('tabindex', '0'); + } }, getNearestValue(value) { if(this.allowedValues){ @@ -238,6 +247,8 @@ Prado.WebUI.TSlider = Prado.Class(Prado.WebUI.PostBackControl, updateFinished() { this.hiddenField.value=this.value; + if (this.handle && this.handle.setAttribute) + this.handle.setAttribute('aria-valuenow', this.value); this.updateStyles(); if(this.initialized && this.options.onChange) this.options.onChange(this.value, this); @@ -246,6 +257,32 @@ Prado.WebUI.TSlider = Prado.Class(Prado.WebUI.PostBackControl, { this.hiddenField.dispatchEvent(new Event('change', { bubbles: true })); } + }, + + /** + * Keyboard control for the slider handle: arrow keys nudge the value by one + * step, Page Up/Down by ten steps, and Home/End jump to the range ends. + */ + keyPressed(event) { + if (this.disabled) return; + const kc = event.keyCode; + // Coerce because option values arrive as strings; string arithmetic would + // concatenate rather than add. + const optStep = Number(this.options.step); + const step = (optStep > 0) ? optStep + : (Number(this.range[1]) - Number(this.range[0])) / 100 || 1; + let handled = true; + if (kc == 39 || kc == 38) this.setValueBy(step); // Right / Up + else if (kc == 37 || kc == 40) this.setValueBy(-step); // Left / Down + else if (kc == 33) this.setValueBy(step * 10); // Page Up + else if (kc == 34) this.setValueBy(-step * 10); // Page Down + else if (kc == 36) this.setValue(Number(this.range[0])); // Home + else if (kc == 35) this.setValue(Number(this.range[1])); // End + else handled = false; + if (handled) { + event.preventDefault(); + this.updateFinished(); + } } }); diff --git a/framework/Web/Javascripts/source/prado/controls/tabpanel.js b/framework/Web/Javascripts/source/prado/controls/tabpanel.js index 8d8e9cdb9..9fc983904 100644 --- a/framework/Web/Javascripts/source/prado/controls/tabpanel.js +++ b/framework/Web/Javascripts/source/prado/controls/tabpanel.js @@ -16,6 +16,7 @@ Prado.WebUI.TTabPanel = Prado.Class(Prado.WebUI.Control, if (element && options.ViewsVis[i]) { this.observe(element, "click", this.elementClicked.bind(this, item)); + this.observe(element, "keydown", this.keyPressed.bind(this, item)); if (options.AutoSwitch) this.observe(element, "mouseenter", this.elementClicked.bind(this, item)); } @@ -29,15 +30,27 @@ Prado.WebUI.TTabPanel = Prado.Class(Prado.WebUI.Control, element.classList.add(this.activeCssClass); element.classList.remove(this.normalCssClass); view.style.display = ''; + this.setTabState(element, true); } else { element.classList.add(this.normalCssClass); element.classList.remove(this.activeCssClass); view.style.display = 'none'; + this.setTabState(element, false); } } } }, + /** + * Reflects the selected state on a tab: aria-selected and the roving tabindex + * that keeps only the selected tab in the sequential tab order. + */ + setTabState(tab, selected) { + if (!tab.hasAttribute('role')) return; // navigation tab, not part of the pattern + tab.setAttribute('aria-selected', selected ? 'true' : 'false'); + tab.setAttribute('tabindex', selected ? '0' : '-1'); + }, + elementClicked(viewID, _event) { const length = this.views.length; for(let i = 0; i 40) return; + + const visible = []; + for (let i = 0; i < this.views.length; i++) { + if (this.viewsvis[i]) { + const tab = document.getElementById(`${this.views[i]}_0`); + if (tab) visible.push({ id: this.views[i], tab }); + } + } + if (visible.length === 0) return; + + let pos = visible.findIndex((v) => v.id == viewID); + if (pos === -1) return; + + if (kc == 37 || kc == 38) pos = (pos - 1 + visible.length) % visible.length; + else if (kc == 39 || kc == 40) pos = (pos + 1) % visible.length; + else if (kc == 36) pos = 0; + else if (kc == 35) pos = visible.length - 1; + + event.preventDefault(); + const target = visible[pos]; + this.elementClicked(target.id, event); + target.tab.focus(); } }); diff --git a/framework/Web/Javascripts/source/prado/validator/validation3.js b/framework/Web/Javascripts/source/prado/validator/validation3.js index 57aadf86d..a50e6c8ae 100644 --- a/framework/Web/Javascripts/source/prado/validator/validation3.js +++ b/framework/Web/Javascripts/source/prado/validator/validation3.js @@ -868,6 +868,10 @@ Prado.WebUI.TBaseValidator = Prado.Class(Prado.WebUI.Control, { this.group = options.ValidationGroup; + // Point the field's accessible description at this validator's + // message, so assistive technology reads the error with the field. + this.linkDescribedBy(); + /** * ValidationManager of this validator * @var {ValidationManager} manager @@ -876,6 +880,42 @@ Prado.WebUI.TBaseValidator = Prado.Class(Prado.WebUI.Control, } }, + /** + * Adds this validator's message id to the aria-describedby of the control it + * validates, keeping any existing tokens and avoiding duplicates. + */ + linkDescribedBy() { + if(!this.control || !this.message || !this.message.id) + return; + const existing = (this.control.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean); + if(existing.indexOf(this.message.id) === -1) + { + existing.push(this.message.id); + this.control.setAttribute('aria-describedby', existing.join(' ')); + } + }, + + /** + * Reflects the validity on the control's aria-invalid. Mirrors the CSS-class + * ownership rule so a field failing several validators only clears when its + * last-failing validator passes. + */ + updateControlAria(control, valid) { + if(valid) + { + if(control.lastAriaValidator == this.options.ID) + { + control.lastAriaValidator = null; + control.setAttribute('aria-invalid', 'false'); + } + } + else + { + control.lastAriaValidator = this.options.ID; + control.setAttribute('aria-invalid', 'true'); + } + }, + /** * Get error message. * @function {string} ? @@ -916,7 +956,10 @@ Prado.WebUI.TBaseValidator = Prado.Class(Prado.WebUI.Control, this.message.style.visibility = this.isValid ? "hidden" : "visible"; } if(this.control) + { this.updateControlCssClass(this.control, this.isValid); + this.updateControlAria(this.control, this.isValid); + } }, /** diff --git a/framework/Web/UI/WebControls/TAccordionView.php b/framework/Web/UI/WebControls/TAccordionView.php index fe8dfaaec..2b88dadce 100644 --- a/framework/Web/UI/WebControls/TAccordionView.php +++ b/framework/Web/UI/WebControls/TAccordionView.php @@ -53,6 +53,12 @@ protected function addAttributesToRender($writer) parent::addAttributesToRender($writer); $writer->addAttribute('id', $this->getClientID()); + // The view body is the disclosure region its header controls. Navigation + // headers (NavigateUrl) are plain links, not part of the pattern. + if ($this->getNavigateUrl() === '') { + $writer->addAttribute('role', 'region'); + $writer->addAttribute('aria-labelledby', $this->getClientID() . '_0'); + } } /** @@ -145,6 +151,15 @@ public function renderHeader($writer) if ($this->getVisible(false) && $this->getPage()->getClientSupportsJavaScript()) { $writer->addAttribute('id', $this->getClientID() . '_0'); + // A JS-toggling header is the focusable disclosure button for its + // region; each header is independently reachable by Tab. + if ($this->getNavigateUrl() === '') { + $writer->addAttribute('role', 'button'); + $writer->addAttribute('aria-expanded', $this->getActive() ? 'true' : 'false'); + $writer->addAttribute('aria-controls', $this->getClientID()); + $writer->addAttribute('tabindex', '0'); + } + $style = $this->getActive() ? $this->getParent()->getActiveHeaderStyle() : $this->getParent()->getHeaderStyle(); $style->addAttributesToRender($writer); diff --git a/framework/Web/UI/WebControls/TBaseValidator.php b/framework/Web/UI/WebControls/TBaseValidator.php index ca111a999..2d8d7bb33 100644 --- a/framework/Web/UI/WebControls/TBaseValidator.php +++ b/framework/Web/UI/WebControls/TBaseValidator.php @@ -156,6 +156,9 @@ protected function addAttributesToRender($writer) $writer->addStyleAttribute('visibility', 'hidden'); } $writer->addAttribute('id', $this->getClientID()); + // The message is an alert so assistive technology announces it when a + // failed validation makes it visible. + $writer->addAttribute('role', 'alert'); parent::addAttributesToRender($writer); $this->renderClientControlScript($writer); } diff --git a/framework/Web/UI/WebControls/TSlider.php b/framework/Web/UI/WebControls/TSlider.php index a1111b73c..2c492d1d9 100644 --- a/framework/Web/UI/WebControls/TSlider.php +++ b/framework/Web/UI/WebControls/TSlider.php @@ -384,9 +384,23 @@ public function renderContents($writer) $writer->renderEndTag(); - // Render the 'Handle' + // Render the 'Handle' as the focusable slider: it carries the value + // semantics and the keyboard adjusts it. $writer->addAttribute('class', 'Handle'); $writer->addAttribute('id', $this->getClientID() . '_handle'); + $writer->addAttribute('role', 'slider'); + $writer->addAttribute('aria-valuemin', (string) $this->getMinValue()); + $writer->addAttribute('aria-valuemax', (string) $this->getMaxValue()); + $writer->addAttribute('aria-valuenow', (string) $this->getValue()); + $writer->addAttribute('aria-orientation', $this->getDirection() == TSliderDirection::Horizontal ? 'horizontal' : 'vertical'); + if (($toolTip = $this->getToolTip()) !== '') { + $writer->addAttribute('aria-label', $toolTip); + } + if ($this->getEnabled(true)) { + $writer->addAttribute('tabindex', '0'); + } else { + $writer->addAttribute('aria-disabled', 'true'); + } $writer->renderBeginTag('div'); $writer->renderEndTag(); } @@ -450,6 +464,7 @@ protected function getSliderOptions() $options['maximum'] = $maxValue; $options['minimum'] = $minValue; $options['range'] = [$minValue, $maxValue]; + $options['step'] = $this->getStepSize(); $options['sliderValue'] = $this->getValue(); $options['disabled'] = !$this->getEnabled(); $values = $this->getValues(); diff --git a/framework/Web/UI/WebControls/TTabPanel.php b/framework/Web/UI/WebControls/TTabPanel.php index ec1389296..09359754b 100644 --- a/framework/Web/UI/WebControls/TTabPanel.php +++ b/framework/Web/UI/WebControls/TTabPanel.php @@ -500,11 +500,18 @@ public function renderContents($writer) $views = $this->getViews(); if ($views->getCount() > 0) { $writer->writeLine(); - // render tab bar + // render tab bar as a tablist so assistive technology groups the tabs + $writer->addAttribute('role', 'tablist'); + if (($toolTip = $this->getToolTip()) !== '') { + $writer->addAttribute('aria-label', $toolTip); + } + $writer->renderBeginTag('div'); foreach ($views as $view) { $view->renderTab($writer); $writer->writeLine(); } + $writer->renderEndTag(); + $writer->writeLine(); // render tab views foreach ($views as $view) { $view->renderControl($writer); diff --git a/framework/Web/UI/WebControls/TTabView.php b/framework/Web/UI/WebControls/TTabView.php index 80c2a0422..0fcfc8c4d 100644 --- a/framework/Web/UI/WebControls/TTabView.php +++ b/framework/Web/UI/WebControls/TTabView.php @@ -61,6 +61,13 @@ protected function addAttributesToRender($writer) parent::addAttributesToRender($writer); $writer->addAttribute('id', $this->getClientID()); + // The view body is the panel controlled by its tab. Real navigation + // tabs (NavigateUrl) are plain links, not part of the tab pattern. + if ($this->getNavigateUrl() === '') { + $writer->addAttribute('role', 'tabpanel'); + $writer->addAttribute('aria-labelledby', $this->getClientID() . '_0'); + $writer->addAttribute('tabindex', '0'); + } } /** @@ -153,6 +160,15 @@ public function renderTab($writer) if ($this->getVisible(false) && $this->getPage()->getClientSupportsJavaScript()) { $writer->addAttribute('id', $this->getClientID() . '_0'); + // A JS-switching tab is the focusable `tab` for its panel; roving + // tabindex keeps only the selected tab in the sequential tab order. + if ($this->getNavigateUrl() === '') { + $writer->addAttribute('role', 'tab'); + $writer->addAttribute('aria-selected', $this->getActive() ? 'true' : 'false'); + $writer->addAttribute('aria-controls', $this->getClientID()); + $writer->addAttribute('tabindex', $this->getActive() ? '0' : '-1'); + } + $style = $this->getActive() ? $this->getParent()->getActiveTabStyle() : $this->getParent()->getTabStyle(); $style->addAttributesToRender($writer); @@ -165,18 +181,20 @@ public function renderTab($writer) } /** - * Renders the content in the tab. - * By default, a hyperlink is displayed. + * Renders the content in the tab. A navigation tab ({@see setNavigateUrl + * NavigateUrl}) renders a hyperlink; a JS-switching tab renders its caption + * as text, so the `role="tab"` element itself is the only focusable control. * @param \Prado\Web\UI\THtmlWriter $writer the HTML writer */ protected function renderTabContent($writer) { - if (($url = $this->getNavigateUrl()) === '') { - $url = 'javascript://'; - } if (($caption = $this->getCaption()) === '') { $caption = ' '; } - $writer->write("{$caption}"); + if (($url = $this->getNavigateUrl()) === '') { + $writer->write($caption); + } else { + $writer->write("{$caption}"); + } } } diff --git a/framework/Web/UI/WebControls/TValidationSummary.php b/framework/Web/UI/WebControls/TValidationSummary.php index 70184bafb..95e3f4709 100644 --- a/framework/Web/UI/WebControls/TValidationSummary.php +++ b/framework/Web/UI/WebControls/TValidationSummary.php @@ -227,6 +227,9 @@ protected function addAttributesToRender($writer) } } $writer->addAttribute('id', $this->getClientID()); + // The summary is an alert region so assistive technology announces the + // collected errors when validation fails. + $writer->addAttribute('role', 'alert'); parent::addAttributesToRender($writer); } diff --git a/tests/harness/web/protected/pages/AccordionA11yTest.page b/tests/harness/web/protected/pages/AccordionA11yTest.page new file mode 100644 index 000000000..ac79a9c38 --- /dev/null +++ b/tests/harness/web/protected/pages/AccordionA11yTest.page @@ -0,0 +1,15 @@ + +

Accordion Accessibility Test Case

+ + + +

First panel content.

+
+ +

Second panel content.

+
+ +

Third panel content.

+
+
+
diff --git a/tests/harness/web/protected/pages/AccordionA11yTest.php b/tests/harness/web/protected/pages/AccordionA11yTest.php new file mode 100644 index 000000000..e01fdba55 --- /dev/null +++ b/tests/harness/web/protected/pages/AccordionA11yTest.php @@ -0,0 +1,5 @@ + +

Slider Accessibility Test Case

+ + diff --git a/tests/harness/web/protected/pages/SliderA11yTest.php b/tests/harness/web/protected/pages/SliderA11yTest.php new file mode 100644 index 000000000..ae886fdfb --- /dev/null +++ b/tests/harness/web/protected/pages/SliderA11yTest.php @@ -0,0 +1,5 @@ + {}; document.body.appendChild(header); viewsObj[vid] = true; @@ -472,3 +478,58 @@ describe('TAccordion registry replacement', () => { expect(global.Prado.Registry['accordion']).toBe(second); }); }); + +// ─── accessibility: aria-expanded + keyboard ───────────────────────────────── + +describe('TAccordion accessibility', () => { + afterEach(() => { + document.body.innerHTML = ''; + if (global.Prado && global.Prado.Registry) global.Prado.Registry = {}; + }); + + function hdr(vid) { return document.getElementById(`${vid}_0`); } + + it('reflects the open header with aria-expanded on init', () => { + makeAccordion(['a', 'b', 'c'], 1); + expect(hdr('a').getAttribute('aria-expanded')).toBe('false'); + expect(hdr('b').getAttribute('aria-expanded')).toBe('true'); + }); + + it('moves aria-expanded when another header is activated', () => { + const { accordion } = makeAccordion(['a', 'b', 'c'], 0); + accordion.elementClicked('c', {}); + expect(hdr('c').getAttribute('aria-expanded')).toBe('true'); + expect(hdr('a').getAttribute('aria-expanded')).toBe('false'); + }); + + it('Enter and Space toggle the region open', () => { + const { accordion } = makeAccordion(['a', 'b', 'c'], 0); + accordion.keyPressed('b', { keyCode: 13, preventDefault: () => {} }); + expect(hdr('b').getAttribute('aria-expanded')).toBe('true'); + accordion.keyPressed('c', { keyCode: 32, preventDefault: () => {} }); + expect(hdr('c').getAttribute('aria-expanded')).toBe('true'); + }); + + it('arrow keys and Home/End move focus without toggling', () => { + const { accordion } = makeAccordion(['a', 'b', 'c'], 0); + let focused = null; + ['a', 'b', 'c'].forEach((v) => { hdr(v).focus = () => { focused = v; }; }); + accordion.keyPressed('a', { keyCode: 40, preventDefault: () => {} }); // Down + expect(focused).toBe('b'); + accordion.keyPressed('a', { keyCode: 38, preventDefault: () => {} }); // Up wraps + expect(focused).toBe('c'); + accordion.keyPressed('b', { keyCode: 35, preventDefault: () => {} }); // End + expect(focused).toBe('c'); + accordion.keyPressed('c', { keyCode: 36, preventDefault: () => {} }); // Home + expect(focused).toBe('a'); + // none of the moves changed the open region + expect(hdr('a').getAttribute('aria-expanded')).toBe('true'); + }); + + it('ignores unrelated keys', () => { + const { accordion } = makeAccordion(['a', 'b'], 0); + let prevented = false; + accordion.keyPressed('a', { keyCode: 65, preventDefault: () => { prevented = true; } }); + expect(prevented).toBe(false); + }); +}); diff --git a/tests/js/controls/slider.test.js b/tests/js/controls/slider.test.js index ef3c2bb11..1180ad558 100644 --- a/tests/js/controls/slider.test.js +++ b/tests/js/controls/slider.test.js @@ -45,11 +45,16 @@ function buildDOM(extra = {}) { Object.defineProperty(track, 'offsetHeight', { configurable: true, value: 20 }); document.body.appendChild(track); - // Handle — 20px wide + // Handle — 20px wide; mirrors the server render's role=slider semantics const handle = document.createElement('div'); handle.id = SLIDER_ID + '_handle'; handle.style.display = 'block'; handle.style.width = '20px'; + handle.setAttribute('role', 'slider'); + handle.setAttribute('aria-valuemin', '0'); + handle.setAttribute('aria-valuemax', '100'); + handle.setAttribute('aria-valuenow', '0'); + handle.setAttribute('tabindex', '0'); Object.defineProperty(handle, 'offsetWidth', { configurable: true, value: 20 }); Object.defineProperty(handle, 'offsetHeight', { configurable: true, value: 20 }); document.body.appendChild(handle); @@ -950,3 +955,81 @@ describe('TSlider vertical axis', () => { expect(s.handle.style.left).toBe(''); }); }); + +// ─── accessibility: keyboard + aria-valuenow ───────────────────────────────── + +describe('TSlider accessibility', () => { + afterEach(() => { + document.body.innerHTML = ''; + if (global.Prado && global.Prado.Registry) global.Prado.Registry = {}; + }); + + function handle() { return document.getElementById(SLIDER_ID + '_handle'); } + function key(kc) { return { keyCode: kc, preventDefault() {} }; } + + it('ArrowRight/Up increases the value by one step and updates aria-valuenow', () => { + const s = makeSlider({ sliderValue: 50, step: 1 }); + s.keyPressed(key(39)); + expect(s.value).toBe(51); + expect(handle().getAttribute('aria-valuenow')).toBe('51'); + }); + + it('ArrowLeft/Down decreases the value by one step', () => { + const s = makeSlider({ sliderValue: 50, step: 1 }); + s.keyPressed(key(37)); + expect(s.value).toBe(49); + }); + + it('PageUp/PageDown move by ten steps', () => { + const s = makeSlider({ sliderValue: 50, step: 1 }); + s.keyPressed(key(33)); + expect(s.value).toBe(60); + s.keyPressed(key(34)); + expect(s.value).toBe(50); + }); + + it('Home and End jump to the range ends', () => { + const s = makeSlider({ sliderValue: 50, step: 1 }); + s.keyPressed(key(36)); // Home + expect(s.value).toBe(0); + s.keyPressed(key(35)); // End + expect(s.value).toBe(100); + }); + + it('clamps at the range boundaries', () => { + const s = makeSlider({ sliderValue: 100, step: 1 }); + s.keyPressed(key(39)); // Right past the max + expect(s.value).toBe(100); + }); + + it('the hidden field mirrors the keyboard-set value', () => { + const s = makeSlider({ sliderValue: 10, step: 5 }); + s.keyPressed(key(39)); + expect(s.hiddenField.value).toBe('15'); + }); + + it('adds the step numerically even when options arrive as strings', () => { + // Regression: option values come from JSON as strings; string arithmetic + // would concatenate ("40"+"5") and clamp to the max. + const s = makeSlider({ sliderValue: 40, step: '5', range: ['0', '100'] }); + s.keyPressed(key(39)); + expect(s.value).toBe(45); + }); + + it('ignores keys while disabled', () => { + const s = makeSlider({ sliderValue: 50, step: 1 }); + s.setDisabled(); + s.keyPressed(key(39)); + expect(s.value).toBe(50); + }); + + it('disabled toggles aria-disabled and the tab stop on the handle', () => { + const s = makeSlider({ sliderValue: 50 }); + s.setDisabled(); + expect(handle().getAttribute('aria-disabled')).toBe('true'); + expect(handle().hasAttribute('tabindex')).toBe(false); + s.setEnabled(); + expect(handle().hasAttribute('aria-disabled')).toBe(false); + expect(handle().getAttribute('tabindex')).toBe('0'); + }); +}); diff --git a/tests/js/controls/tabpanel.test.js b/tests/js/controls/tabpanel.test.js index 88c6e7c3d..568899e3d 100644 --- a/tests/js/controls/tabpanel.test.js +++ b/tests/js/controls/tabpanel.test.js @@ -39,14 +39,21 @@ function buildDOM( hidden.value = String(active); document.body.appendChild(hidden); - // View panels + tab headers + // View panels + tab headers (mirror the server render's ARIA tab pattern) for (const vid of viewIDs) { const panel = document.createElement('div'); panel.id = vid; + panel.setAttribute('role', 'tabpanel'); + panel.setAttribute('aria-labelledby', vid + '_0'); document.body.appendChild(panel); const header = document.createElement('div'); header.id = vid + '_0'; + header.setAttribute('role', 'tab'); + header.setAttribute('aria-controls', vid); + header.setAttribute('tabindex', vid === viewIDs[active] ? '0' : '-1'); + header.setAttribute('aria-selected', vid === viewIDs[active] ? 'true' : 'false'); + header.focus = () => {}; // jsdom focus no-op guard document.body.appendChild(header); } @@ -453,3 +460,77 @@ describe('TTabPanel registry replacement', () => { expect(global.Prado.Registry['tabpanel']).toBe(second); }); }); + +// ─── accessibility: aria state + keyboard navigation ───────────────────────── + +describe('TTabPanel accessibility', () => { + function build(active = 0, extra = {}) { + const opts = buildDOM(['tab0', 'tab1', 'tab2'], active, null, extra); + const panel = new TTabPanel(opts); + return { opts, panel }; + } + + afterEach(() => { + document.body.innerHTML = ''; + if (global.Prado && global.Prado.Registry) global.Prado.Registry = {}; + }); + + function tab(i) { return document.getElementById(`tab${i}_0`); } + + it('sets aria-selected and roving tabindex from the active tab on init', () => { + build(1); + expect(tab(0).getAttribute('aria-selected')).toBe('false'); + expect(tab(1).getAttribute('aria-selected')).toBe('true'); + expect(tab(0).getAttribute('tabindex')).toBe('-1'); + expect(tab(1).getAttribute('tabindex')).toBe('0'); + }); + + it('moves the selected state when a tab is clicked', () => { + const { panel } = build(0); + panel.elementClicked('tab2', {}); + expect(tab(2).getAttribute('aria-selected')).toBe('true'); + expect(tab(2).getAttribute('tabindex')).toBe('0'); + expect(tab(0).getAttribute('aria-selected')).toBe('false'); + expect(tab(0).getAttribute('tabindex')).toBe('-1'); + }); + + it('ArrowRight activates the next tab, wrapping at the end', () => { + const { panel } = build(0); + const prevent = () => {}; + panel.keyPressed('tab0', { keyCode: 39, preventDefault: prevent }); + expect(tab(1).getAttribute('aria-selected')).toBe('true'); + panel.keyPressed('tab1', { keyCode: 39, preventDefault: prevent }); + panel.keyPressed('tab2', { keyCode: 39, preventDefault: prevent }); + expect(tab(0).getAttribute('aria-selected')).toBe('true'); // wrapped + }); + + it('ArrowLeft activates the previous tab', () => { + const { panel } = build(2); + panel.keyPressed('tab2', { keyCode: 37, preventDefault: () => {} }); + expect(tab(1).getAttribute('aria-selected')).toBe('true'); + }); + + it('Home and End jump to the first and last tab', () => { + const { panel } = build(1); + panel.keyPressed('tab1', { keyCode: 35, preventDefault: () => {} }); // End + expect(tab(2).getAttribute('aria-selected')).toBe('true'); + panel.keyPressed('tab2', { keyCode: 36, preventDefault: () => {} }); // Home + expect(tab(0).getAttribute('aria-selected')).toBe('true'); + }); + + it('ignores non-navigation keys', () => { + const { panel } = build(0); + let prevented = false; + panel.keyPressed('tab0', { keyCode: 65, preventDefault: () => { prevented = true; } }); + expect(prevented).toBe(false); + expect(tab(0).getAttribute('aria-selected')).toBe('true'); + }); + + it('skips hidden tabs when navigating', () => { + const opts = buildDOM(['tab0', 'tab1', 'tab2'], 0, [true, false, true]); + const panel = new TTabPanel(opts); + panel.keyPressed('tab0', { keyCode: 39, preventDefault: () => {} }); + // tab1 is not visible, so the next visible tab is tab2 + expect(tab(2).getAttribute('aria-selected')).toBe('true'); + }); +}); diff --git a/tests/js/validator/validation.test.js b/tests/js/validator/validation.test.js index dcfb63622..a2182b511 100644 --- a/tests/js/validator/validation.test.js +++ b/tests/js/validator/validation.test.js @@ -295,6 +295,114 @@ describe('TRequiredFieldValidator', () => { }); }); +// ─── Validator ARIA wiring (aria-describedby / aria-invalid) ───────────────── +// +// A validator links its message to the validated control with aria-describedby +// and toggles aria-invalid on the control as validity changes. When several +// validators share a control, aria-invalid only clears once the validator that +// last marked it invalid passes (mirrors the CSS lastValidator ownership). + +describe('validator ARIA wiring', () => { + let form, input, span, span2; + + beforeEach(() => { + form = document.createElement('form'); + form.id = 'ariaForm'; + + input = document.createElement('input'); + input.id = 'ariaInput'; + input.type = 'text'; + + span = document.createElement('span'); + span.id = 'ariaValidator'; + + span2 = document.createElement('span'); + span2.id = 'ariaValidator2'; + + form.appendChild(input); + form.appendChild(span); + form.appendChild(span2); + document.body.appendChild(form); + + new ValidationManager({ FormID: 'ariaForm' }); + }); + + afterEach(() => { + document.body.removeChild(form); + delete Validation.managers['ariaForm']; + }); + + function makeValidator(id) { + return new WebUI.TRequiredFieldValidator({ + ID: id, + FormID: 'ariaForm', + ControlToValidate:'ariaInput', + ErrorMessage: '*', + Enabled: true, + }); + } + + it('links the message to the control via aria-describedby on construction', () => { + makeValidator('ariaValidator'); + expect(input.getAttribute('aria-describedby')).toBe('ariaValidator'); + }); + + it('appends to an existing aria-describedby without duplicating', () => { + input.setAttribute('aria-describedby', 'existingHelp'); + makeValidator('ariaValidator'); + expect(input.getAttribute('aria-describedby')).toBe('existingHelp ariaValidator'); + // A second construction with the same message id must not duplicate. + makeValidator('ariaValidator'); + expect(input.getAttribute('aria-describedby')).toBe('existingHelp ariaValidator'); + }); + + it('sets aria-invalid=true on the control when validation fails', () => { + const v = makeValidator('ariaValidator'); + input.value = ''; + v.validate(); + expect(input.getAttribute('aria-invalid')).toBe('true'); + }); + + it('clears aria-invalid to false when validation passes', () => { + const v = makeValidator('ariaValidator'); + input.value = ''; + v.validate(); + expect(input.getAttribute('aria-invalid')).toBe('true'); + input.value = 'ok'; + v.validate(); + expect(input.getAttribute('aria-invalid')).toBe('false'); + }); + + it('keeps aria-invalid=true when a non-owning validator passes while the owner fails', () => { + // A required validator (owner of the invalid state) plus a datatype + // validator that treats an empty value as valid. + const required = makeValidator('ariaValidator'); + const dataType = new WebUI.TDataTypeValidator({ + ID: 'ariaValidator2', + FormID: 'ariaForm', + ControlToValidate:'ariaInput', + ErrorMessage: '*', + Enabled: true, + DataType: 'Integer', + }); + + input.value = ''; + required.validate(); // fails: empty -> owner of aria-invalid + expect(input.getAttribute('aria-invalid')).toBe('true'); + + dataType.validate(); // passes: empty is a valid Integer field + // The datatype validator does not own the invalid state, so it must not + // clear the required validator's aria-invalid. + expect(input.getAttribute('aria-invalid')).toBe('true'); + + // Supplying a valid integer satisfies both: aria-invalid clears. + input.value = '42'; + required.validate(); + dataType.validate(); + expect(input.getAttribute('aria-invalid')).toBe('false'); + }); +}); + // ─── TDataTypeValidator — pure data-type validation ────────────────────────── describe('TDataTypeValidator', () => { diff --git a/tests/playwright/validators/RequiredFieldValidatorA11yTestCase.spec.js b/tests/playwright/validators/RequiredFieldValidatorA11yTestCase.spec.js new file mode 100644 index 000000000..16bb36615 --- /dev/null +++ b/tests/playwright/validators/RequiredFieldValidatorA11yTestCase.spec.js @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; +import { genericHelper } from '../helpers.js'; + +/** + * A validator is accessible: its message carries role="alert" so it is + * announced when shown, it links the message to the validated control with + * aria-describedby, and it toggles aria-invalid on the control as validity + * changes. + */ +test('RequiredFieldValidatorA11yTestCase', async ({ page }) => { + const h = genericHelper(page); + const base = 'ctl0_Content_'; + await h.url('validators/index.php?page=RequiredFieldValidator'); + await h.assertSourceContains('RequiredFieldValidator Tests'); + + const input = page.locator(`#${base}text3`); + const message = page.locator(`#${base}validator5`); + + // The message element is an alert region. + await expect(message).toHaveAttribute('role', 'alert'); + + // On registration the validator links its message to the control. + await expect(input).toHaveAttribute('aria-describedby', new RegExp(`${base}validator5`)); + + // Not yet validated: no aria-invalid on the control. + expect(await input.getAttribute('aria-invalid')).toBeNull(); + + // Submitting the no-group button validates text3, which is empty and fails. + await page.locator(`#${base}submit3`).click(); + await expect(input).toHaveAttribute('aria-invalid', 'true'); + + // Supplying a value and revalidating clears aria-invalid. + await input.fill('something'); + await page.locator(`#${base}submit3`).click(); + await expect(input).toHaveAttribute('aria-invalid', 'false'); +}); diff --git a/tests/playwright/web/AccordionA11yTestCase.spec.js b/tests/playwright/web/AccordionA11yTestCase.spec.js new file mode 100644 index 000000000..3c1339748 --- /dev/null +++ b/tests/playwright/web/AccordionA11yTestCase.spec.js @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; +import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js'; + +/** + * TAccordion implements the disclosure pattern: each header is a role=button + * with aria-expanded controlling a labeled role=region, operable with + * Enter/Space and navigable with the arrow keys. + */ +test('AccordionA11yTestCase', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url('web/index.php?page=AccordionA11yTest'); + await h.assertSourceContains('Accordion Accessibility Test Case'); + + const h1 = page.locator('#ctl0_Content_v1_0'); + const h2 = page.locator('#ctl0_Content_v2_0'); + const p1 = page.locator('#ctl0_Content_v1'); + const p2 = page.locator('#ctl0_Content_v2'); + + // Headers are disclosure buttons controlling labeled regions + await expect(h1).toHaveAttribute('role', 'button'); + await expect(h1).toHaveAttribute('aria-controls', 'ctl0_Content_v1'); + await expect(p1).toHaveAttribute('role', 'region'); + await expect(p1).toHaveAttribute('aria-labelledby', 'ctl0_Content_v1_0'); + + // First region open initially + await expect(h1).toHaveAttribute('aria-expanded', 'true'); + await expect(h2).toHaveAttribute('aria-expanded', 'false'); + await expect(p1).toBeVisible(); + await expect(p2).toBeHidden(); + + // Space on a focused header opens its region + await h2.focus(); + await expect(h2).toBeFocused(); + await page.keyboard.press('Space'); + await expect(h2).toHaveAttribute('aria-expanded', 'true'); + await expect(h1).toHaveAttribute('aria-expanded', 'false'); + await expect(p2).toBeVisible(); + + // ArrowDown moves focus to the next header without toggling it + await h2.focus(); + await page.keyboard.press('ArrowDown'); + await expect(page.locator('#ctl0_Content_v3_0')).toBeFocused(); + await expect(h2).toHaveAttribute('aria-expanded', 'true'); // unchanged + + // Pointer click still toggles + await h1.click(); + await expect(h1).toHaveAttribute('aria-expanded', 'true'); + await expect(p1).toBeVisible(); +}); diff --git a/tests/playwright/web/SliderA11yTestCase.spec.js b/tests/playwright/web/SliderA11yTestCase.spec.js new file mode 100644 index 000000000..26eefa838 --- /dev/null +++ b/tests/playwright/web/SliderA11yTestCase.spec.js @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; +import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js'; + +/** + * TSlider exposes its handle as a role=slider with the value semantics and is + * operable by keyboard: arrow keys step by StepSize, Home/End jump to the ends, + * and aria-valuenow tracks the value. + */ +test('SliderA11yTestCase', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url('web/index.php?page=SliderA11yTest'); + await h.assertSourceContains('Slider Accessibility Test Case'); + + const handle = page.locator('#ctl0_Content_vol_handle'); + await expect(handle).toHaveAttribute('role', 'slider'); + await expect(handle).toHaveAttribute('aria-valuemin', '0'); + await expect(handle).toHaveAttribute('aria-valuemax', '100'); + await expect(handle).toHaveAttribute('aria-valuenow', '40'); + await expect(handle).toHaveAttribute('aria-orientation', 'horizontal'); + await expect(handle).toHaveAttribute('aria-label', 'Volume'); + await expect(handle).toHaveAttribute('tabindex', '0'); + + // Keyboard: focus the handle and step it up by the StepSize (5) + await handle.focus(); + await expect(handle).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(handle).toHaveAttribute('aria-valuenow', '45'); + await page.keyboard.press('ArrowLeft'); + await expect(handle).toHaveAttribute('aria-valuenow', '40'); + + // Home / End jump to the range ends + await page.keyboard.press('Home'); + await expect(handle).toHaveAttribute('aria-valuenow', '0'); + await page.keyboard.press('End'); + await expect(handle).toHaveAttribute('aria-valuenow', '100'); + + // The hidden field the server reads mirrors the keyboard value + const hidden = await page.locator('#ctl0_Content_vol_1').inputValue(); + expect(hidden).toBe('100'); +}); diff --git a/tests/playwright/web/TabPanelA11yTestCase.spec.js b/tests/playwright/web/TabPanelA11yTestCase.spec.js new file mode 100644 index 000000000..1c38a0e5f --- /dev/null +++ b/tests/playwright/web/TabPanelA11yTestCase.spec.js @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js'; + +/** + * TTabPanel implements the WAI-ARIA tabs pattern: a tablist of role=tab + * elements controlling role=tabpanel views, with aria-selected, roving + * tabindex, and arrow-key navigation. Uses the tickets/Issue216 harness page. + */ +test('TabPanelA11yTestCase', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url('tickets/index.php?page=Issue216'); + await h.assertSourceContains("TTabPanel"); + + const tablist = page.locator('[role="tablist"]'); + await expect(tablist).toHaveCount(1); + const tabs = page.locator('[role="tab"]'); + await expect(tabs).toHaveCount(2); + + const tab1 = page.locator('#ctl0_Content_tab1_0'); + const tab2 = page.locator('#ctl0_Content_tab2_0'); + const panel1 = page.locator('#ctl0_Content_tab1'); + const panel2 = page.locator('#ctl0_Content_tab2'); + + // Each tab controls its labelled panel + await expect(tab1).toHaveAttribute('aria-controls', 'ctl0_Content_tab1'); + await expect(panel1).toHaveAttribute('role', 'tabpanel'); + await expect(panel1).toHaveAttribute('aria-labelledby', 'ctl0_Content_tab1_0'); + + // Initial selection + roving tabindex + await expect(tab1).toHaveAttribute('aria-selected', 'true'); + await expect(tab1).toHaveAttribute('tabindex', '0'); + await expect(tab2).toHaveAttribute('aria-selected', 'false'); + await expect(tab2).toHaveAttribute('tabindex', '-1'); + await expect(panel1).toBeVisible(); + await expect(panel2).toBeHidden(); + + // Keyboard: focus the selected tab, ArrowRight moves selection to tab 2 + await tab1.focus(); + await expect(tab1).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(tab2).toBeFocused(); + await expect(tab2).toHaveAttribute('aria-selected', 'true'); + await expect(tab2).toHaveAttribute('tabindex', '0'); + await expect(tab1).toHaveAttribute('aria-selected', 'false'); + await expect(panel2).toBeVisible(); + await expect(panel1).toBeHidden(); + + // ArrowRight again wraps back to tab 1 + await page.keyboard.press('ArrowRight'); + await expect(tab1).toBeFocused(); + await expect(tab1).toHaveAttribute('aria-selected', 'true'); + + // A pointer click still switches tabs and updates the state + await tab2.click(); + await expect(tab2).toHaveAttribute('aria-selected', 'true'); + await expect(panel2).toBeVisible(); +}); diff --git a/tests/unit/Web/UI/WebControls/TValidationSummaryTest.php b/tests/unit/Web/UI/WebControls/TValidationSummaryTest.php new file mode 100644 index 000000000..61ff741cf --- /dev/null +++ b/tests/unit/Web/UI/WebControls/TValidationSummaryTest.php @@ -0,0 +1,37 @@ +setID($id); + $page->getControls()->add($summary); + return $summary; + } + + // ================================================================================ + // Accessibility Rendering Tests + // ================================================================================ + + public function testRenderAddsAlertRole() + { + $summary = $this->createSummary(); + $html = $this->renderBeginTag($summary); + $this->assertStringContainsString('role="alert"', $html); + } + + public function testRenderIncludesClientId() + { + $summary = $this->createSummary('mySummary'); + $html = $this->renderBeginTag($summary); + $this->assertStringContainsString('id="' . $summary->getClientID() . '"', $html); + } +} From 8a276717906f1bd81581ac01dc003572efd6205e Mon Sep 17 00:00:00 2001 From: Belisoful Date: Sun, 30 Aug 2026 05:53:47 +0000 Subject: [PATCH 3/4] TColorPicker, TKeyboard, TWizard - Accessibility enhacements --- .../source/prado/colorpicker/colorpicker.js | 84 +++++++++++++++++++ .../source/prado/controls/keyboard.js | 71 +++++++++++++++- framework/Web/UI/WebControls/TColorPicker.php | 18 ++++ framework/Web/UI/WebControls/TKeyboard.php | 2 + framework/Web/UI/WebControls/TWizard.php | 13 ++- .../protected/pages/ColorPickerA11yTest.page | 4 + .../protected/pages/ColorPickerA11yTest.php | 5 ++ .../web/protected/pages/KeyboardA11yTest.page | 5 ++ .../web/protected/pages/KeyboardA11yTest.php | 5 ++ tests/js/controls/colorpicker.test.js | 77 +++++++++++++++++ tests/js/controls/keyboard.test.js | 70 +++++++++++++++- .../Controls/WizardA11yTestCase.spec.js | 21 +++++ .../web/ColorPickerA11yTestCase.spec.js | 49 +++++++++++ .../web/KeyboardA11yTestCase.spec.js | 30 +++++++ 14 files changed, 446 insertions(+), 8 deletions(-) create mode 100644 tests/harness/web/protected/pages/ColorPickerA11yTest.page create mode 100644 tests/harness/web/protected/pages/ColorPickerA11yTest.php create mode 100644 tests/harness/web/protected/pages/KeyboardA11yTest.page create mode 100644 tests/harness/web/protected/pages/KeyboardA11yTest.php create mode 100644 tests/playwright/quickstart/Controls/WizardA11yTestCase.spec.js create mode 100644 tests/playwright/web/ColorPickerA11yTestCase.spec.js create mode 100644 tests/playwright/web/KeyboardA11yTestCase.spec.js diff --git a/framework/Web/Javascripts/source/prado/colorpicker/colorpicker.js b/framework/Web/Javascripts/source/prado/colorpicker/colorpicker.js index 9da40974c..139edb128 100644 --- a/framework/Web/Javascripts/source/prado/colorpicker/colorpicker.js +++ b/framework/Web/Javascripts/source/prado/colorpicker/colorpicker.js @@ -278,7 +278,10 @@ Prado.WebUI.TColorPicker = Prado.Class(Prado.WebUI.Control, { this.button = document.getElementById(`${options['ID']}_button`); this._buttonOnClick = this.buttonOnClick.bind(this); if(options['ShowColorPicker']) + { this.observe(this.button, "click", this._buttonOnClick); + this.observe(this.button, "keydown", this.buttonKeyPressed.bind(this)); + } this.observe(this.input, "change", this.updatePicker.bind(this)); Prado.Registry[options.ID] = this; @@ -289,6 +292,18 @@ Prado.WebUI.TColorPicker = Prado.Class(Prado.WebUI.Control, { this.button.style.backgroundColor = color.toString(); }, + /** + * Enter or Space on the trigger button opens the color picker, matching the + * behavior of a native button. + */ + buttonKeyPressed(event) { + if(event.keyCode == 13 || event.keyCode == 32) + { + event.preventDefault(); + this.buttonOnClick(event); + } + }, + buttonOnClick(_event) { const mode = this.options['Mode']; if(this.element == null) @@ -297,6 +312,12 @@ Prado.WebUI.TColorPicker = Prado.Class(Prado.WebUI.Control, { this.element = this[constructor](this.options['ID'], this.options['Palette']) this.input.parentNode.appendChild(this.element); this.element.style.display = "none"; + // Expose the popup as a labeled dialog and make it focusable so + // focus can move into it when it opens. + this.element.setAttribute('role', 'dialog'); + this.element.setAttribute('tabindex', '-1'); + this.element.setAttribute('aria-label', + this.button.getAttribute('aria-label') || 'Color picker'); if(mode == "Full") this.initializeFullPicker(); @@ -321,6 +342,7 @@ Prado.WebUI.TColorPicker = Prado.Class(Prado.WebUI.Control, { this.observe(document.body, "click", this._documentClickEvent); this.observe(document,"keydown", this._documentKeyDownEvent); this.showing = true; + this.button.setAttribute('aria-expanded', 'true'); if(type == "Full") { @@ -329,6 +351,13 @@ Prado.WebUI.TColorPicker = Prado.Class(Prado.WebUI.Control, { this.inputs.oldColor.style.backgroundColor = color.asHex(); this.setColor(color,true); } + + // Move focus into the dialog: the first palette cell for the basic + // grid, otherwise the dialog itself. + if(this.cells && this.cells.length > 0) + this.focusCell(this.activeCell || 0); + else + this.element.focus(); } }, @@ -345,6 +374,10 @@ Prado.WebUI.TColorPicker = Prado.Class(Prado.WebUI.Control, { this.stopObserving(document.body, "mousemove", this._onMouseMove); this._observingMouseMove = false; } + + this.button.setAttribute('aria-expanded', 'false'); + // Return focus to the trigger so keyboard users are not stranded. + this.button.focus(); } }, @@ -384,9 +417,16 @@ Prado.WebUI.TColorPicker = Prado.Class(Prado.WebUI.Control, { const colors = Prado.WebUI.TColorPicker.palettes[palette]; const pickerOnClick = this.cellOnClick.bind(this); + const cellKeyDown = this.cellKeyPressed.bind(this); const obj=this; + // Palette cells form a keyboard-navigable grid with a single tab stop + // (roving tabindex); this.cells holds them in row-major order. + this.cells = []; + this.cellColumns = 0; + this.activeCell = 0; for (const color of colors) { const row = document.createElement("tr"); + this.cellColumns = Math.max(this.cellColumns, color.length); for (const c of color) { const td = document.createElement("td"); const img = document.createElement("img"); @@ -394,7 +434,12 @@ Prado.WebUI.TColorPicker = Prado.Class(Prado.WebUI.Control, { img.width=16; img.height=16; img.style.backgroundColor = `#${c}`; + img.setAttribute('role', 'button'); + img.setAttribute('aria-label', `#${c}`); + img.setAttribute('tabindex', this.cells.length === 0 ? '0' : '-1'); + img.pickerCellIndex = this.cells.length; obj.observe(img,"click", pickerOnClick); + obj.observe(img,"keydown", cellKeyDown); obj.observe(img,"mouseover", e => { e.target.classList.add("pickerhover"); }); @@ -403,12 +448,51 @@ Prado.WebUI.TColorPicker = Prado.Class(Prado.WebUI.Control, { }); td.appendChild(img); row.appendChild(td); + this.cells.push(img); } table.childNodes[0].appendChild(row); } return div; }, + /** + * Move the roving tab stop to the cell at index and focus it, clamped to the + * available cells. + */ + focusCell(index) { + if(!this.cells || this.cells.length === 0) + return; + index = index < 0 ? 0 : (index >= this.cells.length ? this.cells.length - 1 : index); + for(let i = 0; i < this.cells.length; i++) + this.cells[i].setAttribute('tabindex', i === index ? '0' : '-1'); + this.activeCell = index; + this.cells[index].focus(); + }, + + /** + * Keyboard grid navigation for the basic palette: arrows move between cells, + * Home/End jump to the row ends, Enter/Space selects, and Escape closes. + */ + cellKeyPressed(event) { + const index = event.target.pickerCellIndex; + if(index === undefined) + return; + const cols = this.cellColumns; + const kc = event.keyCode; + let handled = true; + if(kc == 39) this.focusCell(index + 1); // Right + else if(kc == 37) this.focusCell(index - 1); // Left + else if(kc == 40) this.focusCell(index + cols); // Down + else if(kc == 38) this.focusCell(index - cols); // Up + else if(kc == 36) this.focusCell(index - (index % cols)); // Home (row start) + else if(kc == 35) this.focusCell(index - (index % cols) + cols - 1); // End (row end) + else if(kc == 13 || kc == 32) this.cellOnClick(event); // Enter / Space + else if(kc == 27) this.hide(event); // Escape + else handled = false; + if(handled) + event.preventDefault(); + }, + cellOnClick(e) { const el = e.target; if(el.tagName.toLowerCase() != "img") diff --git a/framework/Web/Javascripts/source/prado/controls/keyboard.js b/framework/Web/Javascripts/source/prado/controls/keyboard.js index 9e00be128..dd745b6e4 100644 --- a/framework/Web/Javascripts/source/prado/controls/keyboard.js +++ b/framework/Web/Javascripts/source/prado/controls/keyboard.js @@ -6,6 +6,7 @@ Prado.WebUI.TKeyboard = Prado.Class(Prado.WebUI.Control, this.cssClass = options['CssClass']; this.forControl = document.getElementById(options['ForControl']); this.autoHide = options['AutoHide']; + this.label = options['Label'] || 'On-screen keyboard'; this.flagShift = false; this.flagCaps = false; @@ -24,7 +25,7 @@ Prado.WebUI.TKeyboard = Prado.Class(Prado.WebUI.Control, { this.forControl.keyboard = this; this.forControl.onfocus = function() {this.keyboard.show(); }; - this.forControl.onblur = function() {if (this.keyboard.flagHover == false) this.keyboard.hide();}; + this.forControl.onblur = function() {this.keyboard.scheduleHide();}; this.forControl.onkeydown = function(e) {if (!e) e = window.event; const key = (e.keyCode)?e.keyCode:e.which; if(key == 9) this.keyboard.hide();;}; this.forControl.onselect = this.saveSelection; this.forControl.onclick = this.saveSelection; @@ -67,9 +68,53 @@ Prado.WebUI.TKeyboard = Prado.Class(Prado.WebUI.Control, this.keyboard.type(this.innerHTML); }, + /** + * Enter or Space activates a key, matching a mouse click, so the on-screen + * keyboard is operable by keyboard (WCAG 2.1.1). + */ + onkeydown(e) { + if (!e) e = window.event; + const key = (e.keyCode) ? e.keyCode : e.which; + if (key == 13 || key == 32) + { + if (e.preventDefault) e.preventDefault(); + this.className += ' Active'; + this.keyboard.type(this.innerHTML); + this.className = this.className.replace(/( Active)/ig, ''); + } + }, + + /** + * Focus entering a key keeps the keyboard visible; focus leaving it defers a + * hide check so focus can settle before deciding. + */ + onfocus() { + this.keyboard.show(); + }, + + onblur() { + this.keyboard.scheduleHide(); + }, + + /** + * Human-readable label for a key's displayed text, decoding HTML entities and + * naming the command keys so assistive technology announces each button. + */ + keyLabel(text) { + const names = + { + 'Bksp' : 'Backspace', 'Del' : 'Delete', 'Caps' : 'Caps Lock', + 'Shift' : 'Shift', 'Exit' : 'Exit' + }; + if (names[text]) return names[text]; + return text.replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&'); + }, + render() { this.tagKeyboard = this.createElement('div', {className: this.cssClass, onselectstart() {return false;}}, this.element); this.tagKeyboard.keyboard = this; + this.tagKeyboard.setAttribute('role', 'group'); + this.tagKeyboard.setAttribute('aria-label', this.label); for (let line = 0; line < this.keys.length; line++) { @@ -79,8 +124,12 @@ Prado.WebUI.TKeyboard = Prado.Class(Prado.WebUI.Control, const split = this.keys[line][key].split(' '); const tagKey = this.createElement('div', {className: `Key ${split[2]}`}, tagLine); // tagKey1/tagKey2 are appended to tagKey for their side effect. - this.createElement('div', {className: 'Key1', innerHTML: split[0], keyboard: this, onmouseover: this.onmouseover, onmouseout: this.onmouseout, onmousedown: this.onmousedown, onmouseup: this.onmouseup}, tagKey); - this.createElement('div', {className: 'Key2', innerHTML: split[1], keyboard: this, onmouseover: this.onmouseover, onmouseout: this.onmouseout, onmousedown: this.onmousedown, onmouseup: this.onmouseup}, tagKey); + const k1 = this.createElement('div', {className: 'Key1', innerHTML: split[0], keyboard: this, tabIndex: 0, onmouseover: this.onmouseover, onmouseout: this.onmouseout, onmousedown: this.onmousedown, onmouseup: this.onmouseup, onkeydown: this.onkeydown, onfocus: this.onfocus, onblur: this.onblur}, tagKey); + const k2 = this.createElement('div', {className: 'Key2', innerHTML: split[1], keyboard: this, tabIndex: 0, onmouseover: this.onmouseover, onmouseout: this.onmouseout, onmousedown: this.onmousedown, onmouseup: this.onmouseup, onkeydown: this.onkeydown, onfocus: this.onfocus, onblur: this.onblur}, tagKey); + k1.setAttribute('role', 'button'); + k1.setAttribute('aria-label', this.keyLabel(split[0])); + k2.setAttribute('role', 'button'); + k2.setAttribute('aria-label', this.keyLabel(split[1])); } } }, @@ -97,6 +146,22 @@ Prado.WebUI.TKeyboard = Prado.Class(Prado.WebUI.Control, if (this.isShown() == true && this.autoHide) {this.tagKeyboard.style.visibility = 'hidden'; } }, + /** + * Defer the hide decision so focus moving between the text box and the + * on-screen keys settles first; only hide once focus has left both and the + * pointer is not hovering the keyboard. + */ + scheduleHide() { + const kb = this; + window.setTimeout(function() { + if (!kb.autoHide) return; + const active = document.activeElement; + const withinKeyboard = kb.tagKeyboard && active && kb.tagKeyboard.contains(active); + const inField = active === kb.forControl; + if (!kb.flagHover && !withinKeyboard && !inField) kb.hide(); + }, 0); + }, + type(key) { const input = this.forControl; const command = key.toLowerCase(); diff --git a/framework/Web/UI/WebControls/TColorPicker.php b/framework/Web/UI/WebControls/TColorPicker.php index c92c6ad1b..68c684a27 100644 --- a/framework/Web/UI/WebControls/TColorPicker.php +++ b/framework/Web/UI/WebControls/TColorPicker.php @@ -10,6 +10,7 @@ namespace Prado\Web\UI\WebControls; +use Prado\Prado; use Prado\TPropertyValue; use Prado\Web\Javascripts\TJavaScript; @@ -217,7 +218,13 @@ public function renderEndTag($writer) parent::renderEndTag($writer); $color = $this->getText(); + $interactive = $this->getShowColorPicker(); $writer->addAttribute('class', 'TColorPicker_button'); + if (!$interactive) { + // A non-interactive button only mirrors the current color, so it is + // hidden from assistive technology. + $writer->addAttribute('aria-hidden', 'true'); + } $writer->renderBeginTag('span'); $writer->addAttribute('id', $this->getClientID() . '_button'); @@ -226,6 +233,17 @@ public function renderEndTag($writer) $writer->addAttribute('style', "background-color:{$color};"); } $writer->addAttribute('alt', ''); + if ($interactive) { + // The trigger opens the color picker dialog and is operable by + // keyboard. The client script maintains aria-expanded as it opens + // and closes. + $writer->addAttribute('role', 'button'); + $writer->addAttribute('tabindex', '0'); + $writer->addAttribute('aria-haspopup', 'dialog'); + $writer->addAttribute('aria-expanded', 'false'); + $label = $this->getToolTip() !== '' ? $this->getToolTip() : Prado::localize('Choose color'); + $writer->addAttribute('aria-label', $label); + } $writer->renderBeginTag('img'); $writer->renderEndTag(); $writer->renderEndTag(); diff --git a/framework/Web/UI/WebControls/TKeyboard.php b/framework/Web/UI/WebControls/TKeyboard.php index c71ba2aa1..b620e78b5 100644 --- a/framework/Web/UI/WebControls/TKeyboard.php +++ b/framework/Web/UI/WebControls/TKeyboard.php @@ -12,6 +12,7 @@ namespace Prado\Web\UI\WebControls; use Prado\Exceptions\TConfigurationException; +use Prado\Prado; use Prado\TPropertyValue; use Prado\Web\Javascripts\TJavaScript; @@ -187,6 +188,7 @@ protected function getClientOptions() $options['ForControl'] = $target->getClientID(); $options['AutoHide'] = $this->getAutoHide(); $options['CssClass'] = $this->getKeyboardCssClass(); + $options['Label'] = $this->getToolTip() !== '' ? $this->getToolTip() : Prado::localize('On-screen keyboard'); return $options; } diff --git a/framework/Web/UI/WebControls/TWizard.php b/framework/Web/UI/WebControls/TWizard.php index 192557ca4..7ecd5a229 100644 --- a/framework/Web/UI/WebControls/TWizard.php +++ b/framework/Web/UI/WebControls/TWizard.php @@ -844,11 +844,18 @@ protected function applySideBarProperties() $this->_sideBarDataList->setDataSource($this->getWizardSteps()); $this->_sideBarDataList->setSelectedItemIndex($this->getActiveStepIndex()); $this->_sideBarDataList->dataBind(); - if (($style = $this->getViewState('SideBarButtonStyle', null)) !== null) { - foreach ($this->_sideBarDataList->getItems() as $item) { - if (($button = $item->findControl('SideBarButton')) !== null) { + $style = $this->getViewState('SideBarButtonStyle', null); + $activeIndex = $this->getActiveStepIndex(); + foreach ($this->_sideBarDataList->getItems() as $item) { + if (($button = $item->findControl('SideBarButton')) !== null) { + if ($style !== null) { $button->getStyle()->mergeWith($style); } + // Mark the current step so assistive technology announces + // which step is active within the wizard's side bar. + if ($button instanceof TWebControl && $item->getItemIndex() === $activeIndex) { + $button->setAttribute('aria-current', 'step'); + } } } } diff --git a/tests/harness/web/protected/pages/ColorPickerA11yTest.page b/tests/harness/web/protected/pages/ColorPickerA11yTest.page new file mode 100644 index 000000000..78d08c56e --- /dev/null +++ b/tests/harness/web/protected/pages/ColorPickerA11yTest.page @@ -0,0 +1,4 @@ + +

ColorPicker Accessibility Test Case

+ +
diff --git a/tests/harness/web/protected/pages/ColorPickerA11yTest.php b/tests/harness/web/protected/pages/ColorPickerA11yTest.php new file mode 100644 index 000000000..8bd3db871 --- /dev/null +++ b/tests/harness/web/protected/pages/ColorPickerA11yTest.php @@ -0,0 +1,5 @@ + +

Keyboard Accessibility Test Case

+ + + diff --git a/tests/harness/web/protected/pages/KeyboardA11yTest.php b/tests/harness/web/protected/pages/KeyboardA11yTest.php new file mode 100644 index 000000000..c352f3f32 --- /dev/null +++ b/tests/harness/web/protected/pages/KeyboardA11yTest.php @@ -0,0 +1,5 @@ + { expect(picker.showing).toBe(false); }); }); + +// ─── TColorPicker accessibility ────────────────────────────────────────────── +// +// The trigger button is exposed as a button, the popup as a labeled dialog, +// and the basic palette is a keyboard-navigable grid with a single tab stop. + +describe('TColorPicker accessibility', () => { + const ID = 'cp-a11y-test'; + let picker; + + beforeEach(() => { + buildPickerDOM(ID); + picker = new TColorPicker({ ID, ShowColorPicker: true }); + }); + + afterEach(() => { + cleanupPicker(ID); + document.body.innerHTML = ''; + }); + + it('marks each palette cell as a button labeled with its colour', () => { + const div = picker.getBasicPickerContainer(ID, 'Small'); + const cells = div.querySelectorAll('img'); + expect(cells[0].getAttribute('role')).toBe('button'); + expect(cells[0].getAttribute('aria-label')).toMatch(/^#/); + // Roving tabindex: only the first cell is a tab stop. + expect(cells[0].getAttribute('tabindex')).toBe('0'); + expect(cells[1].getAttribute('tabindex')).toBe('-1'); + }); + + it('focusCell moves the single tab stop to the given cell', () => { + picker.getBasicPickerContainer(ID, 'Small'); + picker.focusCell(5); + expect(picker.activeCell).toBe(5); + expect(picker.cells[5].getAttribute('tabindex')).toBe('0'); + expect(picker.cells[0].getAttribute('tabindex')).toBe('-1'); + }); + + it('ArrowRight/ArrowDown move focus across and down the grid', () => { + picker.getBasicPickerContainer(ID, 'Small'); // 10 columns + const ev = (kc, i) => ({ keyCode: kc, target: picker.cells[i], preventDefault() {} }); + picker.cellKeyPressed(ev(39, 0)); // Right from 0 -> 1 + expect(picker.activeCell).toBe(1); + picker.cellKeyPressed(ev(40, 1)); // Down from 1 -> 11 + expect(picker.activeCell).toBe(11); + }); + + it('Home/End jump to the ends of the current row', () => { + picker.getBasicPickerContainer(ID, 'Small'); // 10 columns + const ev = (kc, i) => ({ keyCode: kc, target: picker.cells[i], preventDefault() {} }); + picker.cellKeyPressed(ev(35, 3)); // End of row 0 -> index 9 + expect(picker.activeCell).toBe(9); + picker.cellKeyPressed(ev(36, 9)); // Home of row 0 -> index 0 + expect(picker.activeCell).toBe(0); + }); + + it('exposes the popup as a labeled dialog and toggles aria-expanded', () => { + picker.buttonOnClick({}); + expect(picker.element.getAttribute('role')).toBe('dialog'); + expect(picker.element.getAttribute('aria-label')).toBeTruthy(); + expect(picker.button.getAttribute('aria-expanded')).toBe('true'); + picker.hide({}); + expect(picker.button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('Escape from a palette cell closes the picker', () => { + picker.buttonOnClick({}); + expect(picker.showing).toBe(true); + picker.cellKeyPressed({ keyCode: 27, target: picker.cells[0], preventDefault() {} }); + expect(picker.showing).toBe(false); + }); + + it('Enter on the trigger button opens the picker', () => { + picker.buttonKeyPressed({ keyCode: 13, preventDefault() {} }); + expect(picker.showing).toBe(true); + }); +}); diff --git a/tests/js/controls/keyboard.test.js b/tests/js/controls/keyboard.test.js index 83f0c9b39..9bbb84ef9 100644 --- a/tests/js/controls/keyboard.test.js +++ b/tests/js/controls/keyboard.test.js @@ -349,19 +349,23 @@ describe('TKeyboard forControl wiring', () => { expect(kb.isShown()).toBe(true); }); - it('attaches onblur handler that hides when flagHover is false', () => { + it('onblur schedules a hide once focus has left and flagHover is false', async () => { const kb = makeKeyboard({ AutoHide: true }); kb.tagKeyboard.style.visibility = 'visible'; kb.flagHover = false; kb.forControl.onblur(); + // The hide is deferred so focus can settle first. + expect(kb.isShown()).toBe(true); + await new Promise((r) => setTimeout(r, 0)); expect(kb.isShown()).toBe(false); }); - it('onblur does not hide when flagHover is true', () => { + it('onblur keeps the keyboard shown when flagHover is true', async () => { const kb = makeKeyboard({ AutoHide: true }); kb.tagKeyboard.style.visibility = 'visible'; kb.flagHover = true; kb.forControl.onblur(); + await new Promise((r) => setTimeout(r, 0)); expect(kb.isShown()).toBe(true); }); }); @@ -429,3 +433,65 @@ describe('TKeyboard with no forControl', () => { }).not.toThrow(); }); }); + +// ─── accessibility ──────────────────────────────────────────────────────────── +// +// The keyboard is a labeled group of buttons operable by keyboard: keys expose +// role=button with a decoded aria-label, are in the tab order, and Enter/Space +// types the key just like a mouse click. + +describe('TKeyboard accessibility', () => { + it('labels the keyboard container as a group', () => { + const kb = makeKeyboard({ Label: 'On-screen keyboard' }); + expect(kb.tagKeyboard.getAttribute('role')).toBe('group'); + expect(kb.tagKeyboard.getAttribute('aria-label')).toBe('On-screen keyboard'); + }); + + it('exposes each key as a focusable button', () => { + const kb = makeKeyboard(); + const key = kb.tagKeyboard.querySelector('.Key1'); + expect(key.getAttribute('role')).toBe('button'); + expect(key.getAttribute('tabindex')).toBe('0'); + }); + + it('decodes HTML entities and names command keys in aria-label', () => { + const kb = makeKeyboard(); + expect(kb.keyLabel('>')).toBe('>'); + expect(kb.keyLabel('&')).toBe('&'); + expect(kb.keyLabel('Bksp')).toBe('Backspace'); + expect(kb.keyLabel('Caps')).toBe('Caps Lock'); + }); + + it('types a character when Enter is pressed on a key', () => { + const kb = makeKeyboard(); + kb.forControl.value = ''; + kb.forControl.selectionStart = 0; + kb.forControl.selectionEnd = 0; + const el = kb.tagKeyboard.querySelector('.Key1'); + el.innerHTML = 'a'; + el.onkeydown.call(el, { keyCode: 13, preventDefault() {} }); + expect(kb.forControl.value).toBe('a'); + }); + + it('does not type on a non-activation key press', () => { + const kb = makeKeyboard(); + kb.forControl.value = ''; + kb.forControl.selectionStart = 0; + kb.forControl.selectionEnd = 0; + const el = kb.tagKeyboard.querySelector('.Key1'); + el.innerHTML = 'a'; + el.onkeydown.call(el, { keyCode: 65, preventDefault() {} }); + expect(kb.forControl.value).toBe(''); + }); + + it('keeps the keyboard shown when a key retains focus after blur', async () => { + const kb = makeKeyboard({ AutoHide: true }); + kb.tagKeyboard.style.visibility = 'visible'; + kb.flagHover = false; + const key = kb.tagKeyboard.querySelector('.Key1'); + key.focus(); + kb.forControl.onblur(); + await new Promise((r) => setTimeout(r, 0)); + expect(kb.isShown()).toBe(true); + }); +}); diff --git a/tests/playwright/quickstart/Controls/WizardA11yTestCase.spec.js b/tests/playwright/quickstart/Controls/WizardA11yTestCase.spec.js new file mode 100644 index 000000000..b2d64e3cd --- /dev/null +++ b/tests/playwright/quickstart/Controls/WizardA11yTestCase.spec.js @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; +import { demosHelper } from '../../helpers.js'; + +/** + * The TWizard side bar marks the active step with aria-current="step" so + * assistive technology can announce which step is current; other steps in the + * side bar do not carry the marker. + */ +test('QuickstartWizardA11yTestCase', async ({ page }) => { + const h = demosHelper(page); + + await h.url('quickstart/index.php?page=Controls.Samples.TWizard.Sample2¬heme=true&lang=en'); + await h.assertTitle('PRADO QuickStart Sample'); + + const step1 = page.locator('#ctl0_body_Wizard1_SideBarList_ctl0_SideBarButton'); + const step2 = page.locator('#ctl0_body_Wizard1_SideBarList_ctl1_SideBarButton'); + + // Step 1 is active; only its side-bar button carries aria-current. + await expect(step1).toHaveAttribute('aria-current', 'step'); + expect(await step2.getAttribute('aria-current')).toBeNull(); +}); diff --git a/tests/playwright/web/ColorPickerA11yTestCase.spec.js b/tests/playwright/web/ColorPickerA11yTestCase.spec.js new file mode 100644 index 000000000..853395fb5 --- /dev/null +++ b/tests/playwright/web/ColorPickerA11yTestCase.spec.js @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; +import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js'; + +/** + * TColorPicker exposes its trigger as a button that opens a labeled dialog, and + * its basic palette is a keyboard-navigable grid: the button opens with Enter, + * arrow keys move between colour cells, Enter selects, and Escape closes and + * returns focus to the trigger. + */ +test('ColorPickerA11yTestCase', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url('web/index.php?page=ColorPickerA11yTest'); + await h.assertSourceContains('ColorPicker Accessibility Test Case'); + + const button = page.locator('#ctl0_Content_basic_button'); + const input = page.locator('#ctl0_Content_basic'); + + // The trigger is an accessible button that controls a dialog. + await expect(button).toHaveAttribute('role', 'button'); + await expect(button).toHaveAttribute('aria-haspopup', 'dialog'); + await expect(button).toHaveAttribute('aria-expanded', 'false'); + await expect(button).toHaveAttribute('aria-label', 'Pick a color'); + await expect(button).toHaveAttribute('tabindex', '0'); + + // Open with the keyboard: focus the button and press Enter. + await button.focus(); + await page.keyboard.press('Enter'); + await expect(button).toHaveAttribute('aria-expanded', 'true'); + + const dialog = page.locator('#ctl0_Content_basic_picker'); + await expect(dialog).toHaveAttribute('role', 'dialog'); + await expect(dialog).toHaveAttribute('aria-label', 'Pick a color'); + + // Focus landed on the first palette cell (single tab stop). + const cells = dialog.locator('img[role="button"]'); + await expect(cells.first()).toBeFocused(); + await expect(cells.first()).toHaveAttribute('aria-label', /^#/); + + // Arrow to the next cell and select it with Enter; the input updates. + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + const value = await input.inputValue(); + expect(value).toMatch(/^#[0-9A-Fa-f]{3,6}$/); + + // Escape closes the dialog and returns focus to the trigger. + await page.keyboard.press('Escape'); + await expect(button).toHaveAttribute('aria-expanded', 'false'); + await expect(button).toBeFocused(); +}); diff --git a/tests/playwright/web/KeyboardA11yTestCase.spec.js b/tests/playwright/web/KeyboardA11yTestCase.spec.js new file mode 100644 index 000000000..54cd18b9e --- /dev/null +++ b/tests/playwright/web/KeyboardA11yTestCase.spec.js @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; +import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js'; + +/** + * TKeyboard exposes the on-screen keyboard as a labeled group whose keys are + * focusable buttons; pressing Enter on a key types it into the associated text + * box, so the widget is operable by keyboard (WCAG 2.1.1). + */ +test('KeyboardA11yTestCase', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url('web/index.php?page=KeyboardA11yTest'); + await h.assertSourceContains('Keyboard Accessibility Test Case'); + + const group = page.locator('#ctl0_Content_kb [role="group"]'); + await expect(group).toHaveAttribute('aria-label', 'On-screen keyboard'); + + // Keys are exposed as focusable buttons with decoded labels. + const keys = group.locator('[role="button"]'); + expect(await keys.count()).toBeGreaterThan(0); + await expect(keys.first()).toHaveAttribute('tabindex', '0'); + + // The "1" key types into the field when activated by keyboard. + const oneKey = group.locator('.Key1', { hasText: /^1$/ }).first(); + await oneKey.focus(); + await expect(oneKey).toHaveAttribute('role', 'button'); + await page.keyboard.press('Enter'); + + const field = page.locator('#ctl0_Content_field'); + await expect(field).toHaveValue('1'); +}); From 7a451ecaec8ae101e3da03593e514d0f5e5ab26d Mon Sep 17 00:00:00 2001 From: Belisoful Date: Mon, 31 Aug 2026 03:14:16 +0000 Subject: [PATCH 4/4] Adds Agent Directive for Accessibility on WebControls --- AGENTS.md | 1 + CLAUDE.md | 1 + 2 files changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f9e242f7c..7ce8e7648 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,7 @@ Docblocks inform and describe; it is not persuasive writing. - A full check consists of the 4 checks (in order): `php -l` compile, php-cs-fixer, phpstan, phpunit (all checks must pass successfully) - A full check must be done for code to be ready for git commit. - The per directory "/" information is found at "agents//INDEX.md" to keep the framework uncluttered. +- All new and changed WebControls must be audited for accessibility at least once during development. - **The current version is 4.3.3. The next release version is 4.4.0**. ### ActiveControls JavaScript diff --git a/CLAUDE.md b/CLAUDE.md index eaa1be820..6b24e309a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,7 @@ TApplication - Method Doc Blocks must be **tight**, and have at minimum one sentence in the description. - Documentation additions/changes/removals should be integrated into the whole, at each level (of detail). - The per directory "/CLAUDE.md" is found at "agents//INDEX.md" to keep the framework uncluttered. +- All new and changed WebControls must be audited for accessibility at least once during development. ## Test Bootstrap