PageSourceSearch

https://wmse.org/wp-content/themes/WMSE_2026/js/wmse-a11y.js?ver=1790713961

js wmse.org collected 2026-10-02 03:24:35 UTC 14,219 bytes, 336 lines download raw bytes

1/**
2 * Small theme-level accessibility enhancements for markup this theme does not
3 * emit directly (JS-mounted widgets + third-party plugin output). Each fix is a
4 * no-op when its target is absent, and re-runs on DOM mutations so it survives
5 * the player bundle's PJAX <main> swaps and async (Preact) mounts.
6 *
7 * 1. Name the program-archive date <select>. The <wmse-archive> widget (bundle-
8 *    mounted on single-program.php) renders `Select Show Date<select>…` with a
9 *    bare text node, not a real <label> — so the control has no accessible name
10 *    (axe: select-name, critical). Add an aria-label.
11 * 2. Fix TEC Pro's series-meta list markup. On single events that belong to a
12 *    series, Events Calendar Pro injects <dt>/<dd> as DIRECT children of
13 *    <ul class="tribe-events-meta-list"> (axe: `list` — ul with non-li children;
14 *    `dlitem` — dt/dd outside a <dl>). Wrap those stray <dt>/<dd> in <li><dl>.
15 *    No supported PHP template override exists for this custom-tables output.
16 * 3. Reveal Gravity Forms wrappers left inline-hidden by a PJAX swap (GF's own
17 *    un-hide never takes effect there — see the note on the function itself).
18 * 4. Restore keyboard access to the /events/ contest CTA — TEC's mobile
19 *    focus-order pass stamps tabindex="-1" on it below ~700px.
20 * 5. Hero-slider a11y: make the chevrons keyboard-operable (the bundle's controls are
21 *    role-less <div>s bound on `mousedown`, so they were mouse-only), and announce
22 *    slide changes through a live region — user-initiated changes only, never the
23 *    7s auto-advance.
24 */
25(function () {
26	function nameArchiveSelects(root) {
27		var scope = root && root.querySelectorAll ? root : document;
28		var sels = scope.querySelectorAll('wmse-archive select, .the-show--actions-row select');
29		for (var i = 0; i < sels.length; i++) {
30			if (!sels[i].getAttribute('aria-label')) {
31				sels[i].setAttribute('aria-label', 'Select show date');
32			}
33		}
34	}
35
36	function fixSeriesMetaList(root) {
37		var scope = root && root.querySelectorAll ? root : document;
38		var lists = scope.querySelectorAll('ul.tribe-events-meta-list');
39		for (var i = 0; i < lists.length; i++) {
40			var ul = lists[i];
41			// stray <dt>/<dd> that are DIRECT children of the <ul>
42			var stray = [];
43			for (var c = 0; c < ul.children.length; c++) {
44				var tag = ul.children[c].tagName;
45				if (tag === 'DT' || tag === 'DD') stray.push(ul.children[c]);
46			}
47			if (!stray.length) continue;
48			var li = document.createElement('li');
49			li.className = 'wmse-meta-series';
50			var dl = document.createElement('dl');
51			dl.className = 'wmse-meta-series__dl';
52			// insert the <li> where the first stray element was, then move them in
53			ul.insertBefore(li, stray[0]);
54			li.appendChild(dl);
55			for (var s = 0; s < stray.length; s++) dl.appendChild(stray[s]);
56		}
57	}
58
59	/**
60	 * 3. Reveal Gravity Forms stranded by PJAX (2026-08-03, Marty: "the
61	 *    volunteer application never shows up unless you hard refresh").
62	 *    GF's ajax mode renders `.gform_wrapper` with an inline
63	 *    display:none (anti-flicker) and un-hides it from an INLINE script.
64	 *    ⚠ CORRECTED 2026-08-05b: that script is NOT skipped — inline scripts
65	 *    DO execute in a PJAX-swapped <main> (CLAUDE.md landmine 12). It runs
66	 *    and THROWS, because GF's JS dependencies aren't initialised on the
67	 *    swapped page — `gform.addAction is not a function` sits in
68	 *    module-sandbox-verify's reported page errors. Either way the form
69	 *    arrives in the markup and stays invisible, and this fix is unchanged:
70	 *    GF's reveal runs at DOMContentLoaded on hard loads; anything still
71	 *    hidden ~400ms after nodes arrive is stuck — show it. (Field-level conditional-logic
72	 *    hiding lives on .gfield, untouched here.) The forms MODULE renders
73	 *    non-ajax and never hides; this covers the legacy content-embedded
74	 *    forms (12 pages).
75	 */
76	function revealStuckGforms() {
77		var wraps = document.querySelectorAll('.gform_wrapper');
78		for (var i = 0; i < wraps.length; i++) {
79			if (wraps[i].style.display === 'none') {
80				wraps[i].style.display = '';
81			}
82		}
83	}
84
85	var gfTimer = null;
86	function scheduleGformReveal() {
87		if (gfTimer) clearTimeout(gfTimer);
88		gfTimer = setTimeout(revealStuckGforms, 400);
89	}
90
91	/**
92	 * FIX #4 — the /events/ contest CTA is not keyboard-reachable below ~700px.
93	 *
94	 * TEC's mobile focus-order script walks the archive header and stamps
95	 * tabindex="-1" + data-tribe-mobile-focus-order="skipped" onto elements it
96	 * decides are decorative — including our "Contest Rules and Entry" button,
97	 * which is the only call to action in that header. Mouse users are fine (this
98	 * is NOT the homepage stacking bug, which is a CSS fix in
99	 * _tribe-events-v2.scss); keyboard and screen-reader users lose it entirely.
100	 *
101	 * We can't stop TEC stamping it, so we un-stamp it — and because the observer
102	 * re-runs run(), a re-stamp after an AJAX view swap gets corrected too. Only
103	 * OUR button is touched: TEC's own skip decisions are left alone.
104	 */
105	function restoreEventsCtaFocus(root) {
106		var ctas = (root || document).querySelectorAll('.wmse-events-cta[tabindex="-1"]');
107		for (var i = 0; i < ctas.length; i++) {
108			ctas[i].removeAttribute('tabindex');
109			ctas[i].removeAttribute('data-tribe-mobile-focus-order');
110		}
111	}
112
113	/**
114	 * FIX #5 — the hero-slider chevrons are mouse-only.
115	 *
116	 * The bundle renders/consumes bare `<div x-wmse-next-control>` /
117	 * `<div x-wmse-previous-control>` with no tabindex, no role and no accessible
118	 * name, and it binds them on **`mousedown`** — not click:
119	 *
120	 *     this.$previousControl.on("mousedown", this.gotoPrevious.bind(this));
121	 *     this.$nextControl.on("mousedown", this.gotoNext.bind(this));
122	 *
123	 * So a keyboard user could not advance the carousel at all. (axe doesn't flag it
124	 * — a role-less <div> isn't expected to be operable — which is why it sat
125	 * unnoticed.) Content was never unreachable: it auto-advances every 7s and each
126	 * slide's link is individually tab-reachable. But the controls were not.
127	 *
128	 * TWO SHAPES TO SUPPORT, deliberately:
129	 *   • `module-hero_slider.php` emits real <button>s — semantics where we own the
130	 *     markup.
131	 *   • `page_home.php`'s static still emits <div>s and is OUT OF SCOPE (owner
132	 *     decision), and the shape is DO-NOT-TOUCH anyway. So we upgrade it in place
133	 *     here: role="button" + tabindex="0" + a name. Same fix reaches both, and the
134	 *     home page gets it without editing a guardrailed template.
135	 *
136	 * Keyboard activation is bridged to the synthetic `mousedown` the bundle listens
137	 * for — exactly the technique js/wmse-slider-gestures.js already uses for swipes.
138	 */
139	function enhanceSliderControls(root) {
140		var scope = root && root.querySelectorAll ? root : document;
141		var controls = scope.querySelectorAll('[x-wmse-next-control], [x-wmse-previous-control]');
142		for (var i = 0; i < controls.length; i++) {
143			var el = controls[i];
144			if ('BUTTON' !== el.tagName) {
145				// the legacy <div> shape — give it button semantics in place
146				if (!el.hasAttribute('role')) { el.setAttribute('role', 'button'); }
147				if (!el.hasAttribute('tabindex')) { el.setAttribute('tabindex', '0'); }
148			}
149			if (!el.getAttribute('aria-label')) {
150				el.setAttribute('aria-label',
151					el.hasAttribute('x-wmse-next-control') ? 'Next slide' : 'Previous slide');
152			}
153		}
154
155		// Create each slider's live region EAGERLY, not on the first announcement: a
156		// live region has to be in the DOM before content is written into it, or some
157		// assistive tech misses the first change entirely.
158		var sliders = scope.querySelectorAll('wmse-slider');
159		for (var s = 0; s < sliders.length; s++) {
160			sliderStatusRegion(sliders[s]);
161		}
162	}
163
164	var SLIDER_CONTROLS = '[x-wmse-next-control], [x-wmse-previous-control]';
165
166	function sliderControlFrom(target) {
167		return target && target.closest ? target.closest(SLIDER_CONTROLS) : null;
168	}
169
170	function advanceSlider(control) {
171		control.dispatchEvent(new MouseEvent('mousedown', {
172			bubbles: true,
173			cancelable: true,
174			view: window,
175		}));
176	}
177
178	/**
179	 * SLIDE-CHANGE ANNOUNCEMENTS (part of fix #5).
180	 *
181	 * The carousel gave assistive tech no signal that the slide had changed — pressing
182	 * the chevron did something visible and announced nothing. The bundle emits no
183	 * events and keeps no reachable instance, so this is done from the outside: a
184	 * visually-hidden live region per slider, written after the transition settles.
185	 *
186	 * ⚠ USER-INITIATED CHANGES ONLY. The 7s auto-advance is deliberately SILENT —
187	 * announcing every rotation would interrupt a screen-reader user roughly nine times
188	 * a minute, which is worse than saying nothing (the APG carousel pattern keeps
189	 * auto-rotation out of the live region for exactly this reason).
190	 *
191	 * The distinction is free: mouse, keyboard (via the bridge above) and swipe (via
192	 * js/wmse-slider-gestures.js) ALL funnel through a `mousedown` on a control, and
193	 * the auto-advance does not. So listening for that one event is precisely the set
194	 * of changes worth announcing.
195	 */
196	var statusSeq = 0;
197
198	function sliderStatusRegion(slider) {
199		var id = slider.getAttribute('data-wmse-status-id');
200		var region = id ? document.getElementById(id) : null;
201		if (region) { return region; }
202
203		statusSeq++;
204		id = 'wmse-slider-status-' + statusSeq;
205		region = document.createElement('div');
206		region.id = id;
207		region.className = 'screen-reader-text';
208		region.setAttribute('role', 'status');
209		region.setAttribute('aria-live', 'polite');
210		region.setAttribute('aria-atomic', 'true');
211		// OUTSIDE <wmse-slider> on purpose: the bundle hands its childNodes to Preact
212		// at mount, so keeping our node out of that subtree avoids any interaction.
213		if (slider.parentNode) {
214			slider.parentNode.insertBefore(region, slider.nextSibling);
215		}
216		slider.setAttribute('data-wmse-status-id', id);
217		return region;
218	}
219
220	function activeSlideIndex(slider) {
221		var pics = slider.querySelectorAll('picture');
222		for (var i = 0; i < pics.length; i++) {
223			var t = pics[i].style.transform || '';
224			if (t.indexOf('translate(0%') !== -1 || t.indexOf('translate(0px') !== -1) {
225				return i;
226			}
227		}
228		return -1;
229	}
230
231	function announceSlide(slider, i) {
232		var pics = slider.querySelectorAll('picture');
233		var img = pics[i] ? pics[i].querySelector('img') : null;
234		var name = img ? (img.getAttribute('alt') || '').trim() : '';
235		sliderStatusRegion(slider).textContent =
236			'Slide ' + (i + 1) + ' of ' + pics.length + (name ? ': ' + name : '');
237	}
238
239	/**
240	 * Announce once the slide has ACTUALLY changed.
241	 *
242	 * ⚠ Must poll for a change from `fromIndex`, not merely for "a settled index".
243	 * The listener below runs on the CAPTURE phase, i.e. before the bundle's own
244	 * bubble-phase handler, so an immediate read returns the slide you are LEAVING —
245	 * which announced everything one behind (caught in testing: pressing next on
246	 * slide 1 announced "Slide 1 of 3"). Home masked it by racing the auto-advance.
247	 *
248	 * If the index never changes, the bundle's `isAnimating` guard swallowed the press
249	 * — correctly announce nothing.
250	 */
251	function scheduleAnnounce(slider, fromIndex) {
252		var pics = slider.querySelectorAll('picture');
253		if (pics.length < 2) { return; } // one slide can't change
254		var tries = 0;
255		(function poll() {
256			tries++;
257			var now = activeSlideIndex(slider);
258			if (now !== -1 && now !== fromIndex) {
259				slider.setAttribute('data-wmse-status-index', String(now));
260				announceSlide(slider, now);
261				return;
262			}
263			if (tries < 14) { setTimeout(poll, 120); } // ~1.7s; the animation is 250ms
264		}());
265	}
266
267	// The single funnel: every USER-initiated change performs or dispatches a
268	// `mousedown` on a control — mouse natively, keyboard via the bridge above, swipe
269	// via js/wmse-slider-gestures.js. The 7s auto-advance never does, which is exactly
270	// why it stays out of the live region.
271	document.addEventListener('mousedown', function (e) {
272		var c = sliderControlFrom(e.target);
273		if (!c) { return; }
274		var slider = c.closest ? c.closest('wmse-slider') : null;
275		if (!slider) { return; }
276		// read the CURRENT slide synchronously, before the bundle moves it
277		scheduleAnnounce(slider, activeSlideIndex(slider));
278	}, true);
279
280	// Bound ONCE, delegated on document — so it covers the static, the module, any
281	// number of sliders, and anything arriving via a PJAX swap, with no re-init.
282	document.addEventListener('keydown', function (e) {
283		if ('Enter' !== e.key && ' ' !== e.key && 'Spacebar' !== e.key) { return; }
284		var c = sliderControlFrom(e.target);
285		// a real <button> emits its own click for Enter/Space — handled below, so
286		// don't also fire here or the slider would advance twice
287		if (!c || 'BUTTON' === c.tagName) { return; }
288		e.preventDefault(); // Space would otherwise scroll the page
289		advanceSlider(c);
290	});
291
292	document.addEventListener('click', function (e) {
293		// detail === 0 means KEYBOARD activation. A real mouse click has detail >= 1
294		// and the bundle's own mousedown handler has already advanced the slider —
295		// bridging that too would double-advance.
296		if (0 !== e.detail) { return; }
297		var c = sliderControlFrom(e.target);
298		if (!c || 'BUTTON' !== c.tagName) { return; }
299		e.preventDefault();
300		advanceSlider(c);
301	});
302
303	function run(root) {
304		nameArchiveSelects(root);
305		fixSeriesMetaList(root);
306		restoreEventsCtaFocus(root);
307		enhanceSliderControls(root);
308		scheduleGformReveal();
309	}
310
311	if (document.readyState === 'loading') {
312		document.addEventListener('DOMContentLoaded', function () { run(document); });
313	} else {
314		run(document);
315	}
316
317	// Re-run when new nodes arrive (async widget mounts + PJAX <main> swaps).
318	if (window.MutationObserver && document.body) {
319		var scheduled = false;
320		var mo = new MutationObserver(function (muts) {
321			for (var i = 0; i < muts.length; i++) {
322				if (muts[i].addedNodes && muts[i].addedNodes.length) {
323					if (!scheduled) {
324						scheduled = true;
325						(window.requestAnimationFrame || window.setTimeout)(function () {
326							scheduled = false;
327							run(document);
328						});
329					}
330					break;
331				}
332			}
333		});
334		mo.observe(document.body, { childList: true, subtree: true });
335	}
336})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.