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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<dir_path>/" information is found at "agents/<dir_path>/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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<dir_path>/CLAUDE.md" is found at "agents/<dir_path>/INDEX.md" to keep the framework uncluttered.
- All new and changed WebControls must be audited for accessibility at least once during development.

## Test Bootstrap

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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){
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

});
Expand Down
84 changes: 84 additions & 0 deletions framework/Web/Javascripts/source/prado/colorpicker/colorpicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -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();
Expand All @@ -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")
{
Expand All @@ -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();
}
},

Expand All @@ -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();
}
},

Expand Down Expand Up @@ -384,17 +417,29 @@ 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");
img.src=Prado.WebUI.TColorPicker.UIImages['button.gif'];
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");
});
Expand All @@ -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")
Expand Down
45 changes: 41 additions & 4 deletions framework/Web/Javascripts/source/prado/controls/accordion.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
71 changes: 68 additions & 3 deletions framework/Web/Javascripts/source/prado/controls/keyboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/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++)
{
Expand All @@ -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]));
}
}
},
Expand All @@ -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();
Expand Down
Loading
Loading