diff --git a/js/artwork.js b/js/artwork.js
index d43c2624ab..f164ccd9d8 100644
--- a/js/artwork.js
+++ b/js/artwork.js
@@ -27,7 +27,7 @@
PALETTEFILLCOLORS, PALETTESTROKECOLORS, PALETTEHIGHLIGHTCOLORS,
HIGHLIGHTSTROKECOLORS, UPICON, DOWNICON, FADEDUPICON,
FADEDDOWNICON, CLOSEICON, CARTESIANBUTTON, CARTESIANPOLARBUTTON,
- EFFECTPALETTEICON, CLEARBUTTON, COLLAPSEBUTTON, EXPANDBUTTON,
+ EFFECTPALETTEICON, CLEARBUTTON, SPEAKBUTTON, COLLAPSEBUTTON, EXPANDBUTTON,
NOGRIDBUTTON, PLAYBUTTON, POLARBUTTON, STEPBUTTON, RUNBUTTON,
STEPMUSICBUTTON, STOPBUTTON, RECORDBUTTON, CARTESIAN, POLAR, TREBLE, SOPRANO,
ALTO, TENOR, BASS, GRAND, GRAPHICSICONX, TRASHICON, PALETTEICONS,
@@ -378,6 +378,9 @@ const ERASEBUTTON =
const CLEARBUTTON = ERASEBUTTON;
+const SPEAKBUTTON =
+ '';
+
const EXTRASPALETTEICON =
'';
diff --git a/js/piemenus.js b/js/piemenus.js
index ef044bd36b..8d5e5b5bee 100644
--- a/js/piemenus.js
+++ b/js/piemenus.js
@@ -163,6 +163,135 @@ const enableWheelScroll = (wheel, itemCount) => {
wheelDiv.addEventListener("wheel", scrollHandler, { passive: false });
};
+// Accessibility helpers for pie menus (screen reader announcements)
+/**
+ * this function is to ensure that the pie menu live region is created and returned
+*/
+const __ensurePieMenuLiveRegion = () => {
+ let liveRegion = docById("pieMenuLiveRegion");
+ if (liveRegion) {
+ return liveRegion;
+ }
+ liveRegion = document.createElement("div");
+ liveRegion.id = "pieMenuLiveRegion";
+ liveRegion.setAttribute("role", "status");
+ liveRegion.setAttribute("aria-live", "polite");
+ liveRegion.setAttribute("aria-atomic", "true");
+ liveRegion.style.position = "absolute";
+ liveRegion.style.left = "-9999px";
+ liveRegion.style.width = "1px";
+ liveRegion.style.height = "1px";
+ liveRegion.style.overflow = "hidden";
+ document.body.appendChild(liveRegion);
+ return liveRegion;
+};
+
+
+/**
+ * this function is to get the aria label from the title of the pie menu item
+*/
+const __pieMenuA11yLabelFromTitle = title => {
+ if (title === null || title === undefined) {
+ return "";
+ }
+
+ const text = String(title).trim();
+ if (text.length === 0) {
+ return "";
+ }
+
+ if (text === "×") {
+ return _("Close");
+ }
+ if (text === "+") {
+ return _("Increase");
+ }
+ if (text === "-") {
+ return _("Decrease");
+ }
+ if (text === "▶") {
+ return _("Play");
+ }
+
+ if (text.startsWith("imgsrc:")) {
+ const raw = text.slice("imgsrc:".length);
+ const file = raw.split("/").pop() || raw;
+ const base = file.replace(/\.[^/.]+$/, "");
+ return base.replace(/[-_]+/g, " ").trim();
+ }
+
+ return text;
+};
+
+const __announcePieMenuLabel = label => {
+ const trimmed = String(label || "").trim();
+ if (!trimmed) {
+ return;
+ }
+ /**
+ * this logic is to prevent any duplicate announcements
+ * i.e if the same label is announced within 400ms, it will not be announced again
+ */
+ const now = Date.now();
+ if (
+ window.__wheelnavA11yLastLabel === trimmed &&
+ window.__wheelnavA11yLastTime &&
+ now - window.__wheelnavA11yLastTime < 400
+ ) {
+ return;
+ }
+
+ window.__wheelnavA11yLastLabel = trimmed;
+ window.__wheelnavA11yLastTime = now;
+ __ensurePieMenuLiveRegion().textContent = trimmed;
+};
+
+/**
+ * this function is to speak the pie menu label to the screen reader
+*/
+const __speakPieMenuLabel = label => {
+ const trimmed = String(label || "").trim();
+ if (!trimmed) {
+ return;
+ }
+ //If the browser doesn’t support TTS, do nothing.
+ if (!("speechSynthesis" in window)) {
+ return;
+ }
+ //If the user toggled speech off, do nothing.
+ if (window.__wheelnavA11ySpeakEnabled === false) {
+ return;
+ }
+
+ try {
+ window.speechSynthesis.cancel(); //Stop any current speech so the new label replaces it immediately.
+ const utterance = new SpeechSynthesisUtterance(trimmed);
+ utterance.rate = 1;
+ utterance.pitch = 1;
+ window.speechSynthesis.speak(utterance);
+ } catch (e) {
+ // Ignore speech synthesis errors
+ }
+};
+
+// Expose hooks for the wheelnav library to call
+if (window.__wheelnavA11ySpeakEnabled === undefined) {
+ window.__wheelnavA11ySpeakEnabled = true;
+}
+
+window.__wheelnavA11yLabelForItem = navItem =>
+ __pieMenuA11yLabelFromTitle(navItem && navItem.title);
+
+// When called, it writes the label into the live region so screen readers announce it.
+window.__wheelnavA11yAnnounce = navItem => {
+ const label = __pieMenuA11yLabelFromTitle(navItem && navItem.title);
+ __announcePieMenuLabel(label);
+};
+
+//When called (on click), it speaks the label using speechSynthesis
+window.__wheelnavA11ySpeak = navItem => {
+ const label = __pieMenuA11yLabelFromTitle(navItem && navItem.title);
+ __speakPieMenuLabel(label);
// Ensure exit wheels behave like stateless buttons (no sticky selection)
const configureExitWheel = exitWheel => {
if (!exitWheel || !exitWheel.navItems) {
diff --git a/js/toolbar.js b/js/toolbar.js
index f6ac4a2a06..af05445109 100644
--- a/js/toolbar.js
+++ b/js/toolbar.js
@@ -395,6 +395,20 @@ class Toolbar {
const stopIcon = docById("stop");
const recordButton = docById("record");
let isPlayIconRunning = false;
+ const speakToolbarLabel = label => {
+ if (!label) return;
+ if (!("speechSynthesis" in window)) return;
+ if (window.__wheelnavA11ySpeakEnabled === false) return;
+ try {
+ window.speechSynthesis.cancel();
+ const utterance = new SpeechSynthesisUtterance(label);
+ utterance.rate = 1;
+ utterance.pitch = 1;
+ window.speechSynthesis.speak(utterance);
+ } catch {
+ // Ignore speech synthesis errors
+ }
+ };
function handleClick() {
if (!isPlayIconRunning) {
@@ -419,6 +433,7 @@ class Toolbar {
this.activity.hideMsgs();
};
isPlayIconRunning = false;
+ speakToolbarLabel(_("Play"));
onclick(this.activity);
handleClick();
stopIcon.style.color = this.stopIconColorWhenPlaying;
@@ -449,6 +464,17 @@ class Toolbar {
const stopIcon = docById("stop");
const recordButton = docById("record");
stopIcon.onclick = () => {
+ if ("speechSynthesis" in window && window.__wheelnavA11ySpeakEnabled !== false) {
+ try {
+ window.speechSynthesis.cancel();
+ const utterance = new SpeechSynthesisUtterance(_("Stop"));
+ utterance.rate = 1;
+ utterance.pitch = 1;
+ window.speechSynthesis.speak(utterance);
+ } catch {
+ // Ignore speech synthesis errors
+ }
+ }
onclick(this.activity);
stopIcon.style.color = "white";
saveButton.disabled = false;
diff --git a/js/turtles.js b/js/turtles.js
index 628e4256f8..fef9bc24e5 100644
--- a/js/turtles.js
+++ b/js/turtles.js
@@ -22,8 +22,8 @@
setupPitchActions, setupIntervalsActions, setupToneActions, setupOrnamentActions,
setupVolumeActions, setupDrumActions, setupDictActions, _, Turtle, TURTLESVG, METRONOMESVG,
FILLCOLORS, STROKECOLORS, getMunsellColor, DEFAULTVALUE, DEFAULTCHROMA,
- jQuery, docById, LEADING, CARTESIANBUTTON, piemenuGrid, CLEARBUTTON, COLLAPSEBUTTON,
- EXPANDBUTTON, MBOUNDARY
+ jQuery, docById, LEADING, CARTESIANBUTTON, piemenuGrid, CLEARBUTTON, SPEAKBUTTON,
+ COLLAPSEBUTTON, EXPANDBUTTON, MBOUNDARY
*/
/* exported Turtles */
@@ -983,7 +983,7 @@ Turtles.TurtlesView = class {
name: "Grid",
label: _("Grid")
},
- this._w - 10 - 3 * 55,
+ this._w - 10 - 4 * 55,
70 + LEADING + 6
);
const that = this;
@@ -1000,7 +1000,7 @@ Turtles.TurtlesView = class {
name: "Clear",
label: _("Clear")
},
- this._w - 5 - 2 * 55,
+ this._w - 5 - 3 * 55,
70 + LEADING + 6
);
@@ -1010,6 +1010,34 @@ Turtles.TurtlesView = class {
};
};
+ const __makeSpeakToggleButton = () => {
+ this._speakToggleButton = _makeButton(
+ SPEAKBUTTON,
+ {
+ name: "PieMenuSpeech",
+ label: _("Pie menu speech on")
+ },
+ this._w - 5 - 2 * 55,
+ 70 + LEADING + 6
+ );
+ this._speakToggleButton.id = "PieMenuSpeech";
+
+ const updateSpeakButton = () => {
+ const enabled = window.__wheelnavA11ySpeakEnabled !== false;
+ this._speakToggleButton.style.opacity = enabled ? "1" : "0.4";
+ const label = enabled ? _("Pie menu speech on") : _("Pie menu speech off");
+ this._speakToggleButton.setAttribute("data-tooltip", label);
+ };
+
+ updateSpeakButton();
+
+ this._speakToggleButton.onclick = () => {
+ window.__wheelnavA11ySpeakEnabled =
+ window.__wheelnavA11ySpeakEnabled === false ? true : false;
+ updateSpeakButton();
+ };
+ };
+
/**
* Makes collapse button by initailising 'COLLAPSEBUTTON' SVG.
* Assigns click listener function to call __collapse() method.
@@ -1143,13 +1171,13 @@ Turtles.TurtlesView = class {
this._clearButton.scaleX = 1;
this._clearButton.scaleY = 1;
this._clearButton.scale = 1;
- this._clearButton.x = this._w - 5 - 2 * 55;
+ this._clearButton.x = this._w - 5 - 3 * 55;
if (this.gridButton !== null) {
this.gridButton.scaleX = 1;
this.gridButton.scaleY = 1;
this.gridButton.scale = 1;
- this.gridButton.x = this._w - 10 - 3 * 55;
+ this.gridButton.x = this._w - 10 - 4 * 55;
this.gridButton.visible = true;
}
@@ -1186,6 +1214,7 @@ Turtles.TurtlesView = class {
cont.setAttribute("id", "buttoncontainerTOP");
__makeCollapseButton();
__makeExpandButton();
+ __makeSpeakToggleButton();
__makeClearButton();
__makeGridButton();
jQuery
diff --git a/lib/wheelnav.js b/lib/wheelnav.js
index 946e74e019..e2b7e40e68 100644
--- a/lib/wheelnav.js
+++ b/lib/wheelnav.js
@@ -130,7 +130,8 @@ if(this.wheelnav.currentPercent<0.05){this.navTitleCurrentTransformString+=",s0.
if(this.navTitleSizeTransform!==undefined){this.navTitleCurrentTransformString+=this.navTitleSizeTransform;}
this.navSlice.attr({transform:this.navSliceCurrentTransformString});this.navLine.attr({transform:this.navLineCurrentTransformString});this.navTitle.attr({transform:this.navTitleCurrentTransformString});this.navItem=this.wheelnav.raphael.set();if(this.sliceClickablePathFunction!==null){var sliceClickablePath=this.getCurrentClickablePath();this.navClickableSlice=this.wheelnav.raphael.path(sliceClickablePath.slicePathString).attr(this.sliceClickablePathAttr).toBack();this.navClickableSlice.id=this.wheelnav.getClickableSliceId(this.wheelItemIndex);this.navClickableSlice.node.id=this.navClickableSlice.id;this.navItem.push(this.navSlice,this.navLine,this.navTitle,this.navClickableSlice);}
else{this.navItem.push(this.navSlice,this.navLine,this.navTitle);}
-this.setTooltip(this.tooltip);this.navItem.id=this.wheelnav.getItemId(this.wheelItemIndex);var thisWheelNav=this.wheelnav;var thisNavItem=this;var thisItemIndex=this.wheelItemIndex;if(this.enabled){this.navItem.mouseup(function(){thisWheelNav.navigateWheel(thisItemIndex);});this.navItem.mouseover(function(){if(thisNavItem.hovered!==true){thisNavItem.hoverEffect(thisItemIndex,true);}});this.navItem.mouseout(function(){thisNavItem.hovered=false;thisNavItem.hoverEffect(thisItemIndex,false);});}
+this.setTooltip(this.tooltip);this.navItem.id=this.wheelnav.getItemId(this.wheelItemIndex);if(typeof window!=="undefined"&&window.__wheelnavA11yLabelForItem){var a11yLabel=window.__wheelnavA11yLabelForItem(this);if(a11yLabel){var a11yNodes=[this.navSlice,this.navTitle,this.navLine,this.navClickableSlice];for(var a=0;a