fix(highway_3d): show greyed-out reason on hover for disabled bg controls

A native-disabled <button>/<input> receives no pointer events, so its
`title` tooltip never appears — the "greyed out, says why on hover"
affordance was dead in the browser while the tests passed on the
swallowed control title. Move the reason onto a non-disabled wrapper and
set pointer-events:none on the disabled control so the hover reaches it.
Also add aria-disabled so screen readers get the state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
This commit is contained in:
Byron Gamatos 2026-07-19 10:37:38 +02:00
parent 4788b3e080
commit f4a61eff47
2 changed files with 59 additions and 1 deletions

View File

@ -4011,6 +4011,12 @@
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
};
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
// Non-disabled wrappers around the two greyable controls. A native-disabled
// <button>/<input> receives no pointer events, so its `title` tooltip never
// shows on hover — the whole "greyed out, says why on hover" affordance
// would be dead. The reason lives on these wrappers instead, and the
// disabled control gets pointer-events:none so the hover reaches them.
let _pcReactiveWrap = null, _pcIntensityWrap = null;
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
// The player chrome exposes this slot once it has initialised. A host
@ -4065,6 +4071,10 @@
function _pcPaint(btn, on, disabled, reason) {
btn._on = !!on && !disabled;
btn.disabled = !!disabled;
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
// pointer-events:none lets the hover fall through to _pcReactiveWrap,
// which carries the reason a disabled button's own title can't show.
btn.style.pointerEvents = disabled ? 'none' : '';
btn.style.cursor = disabled ? 'not-allowed' : 'pointer';
btn.style.opacity = disabled ? '.45' : '1';
btn.title = reason || 'React to the audio';
@ -4105,13 +4115,25 @@
_pcPaint(_pcReactive, !!_bgReadSetting(null, 'reactive'), !uses.reactive,
uses.reactive ? 'React to the audio' : why);
}
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
// enabled so the control's own title takes over.
if (_pcReactiveWrap) {
_pcReactiveWrap.title = uses.reactive ? '' : why;
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
}
if (_pcIntensity) {
_pcIntensity.value = String(_bgReadSetting(null, 'intensity'));
_pcIntensity.disabled = !uses.intensity;
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
_pcIntensity.style.opacity = uses.intensity ? '1' : '.45';
_pcIntensity.style.cursor = uses.intensity ? '' : 'not-allowed';
_pcIntensity.title = uses.intensity ? 'Background intensity' : why;
}
if (_pcIntensityWrap) {
_pcIntensityWrap.title = uses.intensity ? '' : why;
_pcIntensityWrap.style.cursor = uses.intensity ? '' : 'not-allowed';
}
}
// Mirror the current values into the Settings panel's controls when it's
// in the DOM.
@ -4174,6 +4196,7 @@
box.appendChild(_pcSel);
const optWrap = document.createElement('div');
optWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.375rem;';
_pcReactiveWrap = optWrap; // carries the greyed-out reason on hover
_pcReactive = _pcPill('Reactive', 'React to the audio');
_pcReactive.addEventListener('click', () => {
if (_pcReactive.disabled) return;
@ -4184,6 +4207,10 @@
box.appendChild(optWrap);
box.appendChild(_pcGroupLabel('Intensity'));
// Wrapper carries the reason on hover when the slider is disabled — a
// native-disabled <input> shows no title of its own.
_pcIntensityWrap = document.createElement('div');
_pcIntensityWrap.style.cssText = 'width:100%;';
_pcIntensity = document.createElement('input');
_pcIntensity.type = 'range';
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
@ -4200,7 +4227,8 @@
try { window.h3dBgSetIntensity(parseFloat(_pcIntensity.value)); }
catch (e) { console.error('[3D-Hwy] bg intensity set failed', e); }
});
box.appendChild(_pcIntensity);
_pcIntensityWrap.appendChild(_pcIntensity);
box.appendChild(_pcIntensityWrap);
slot.appendChild(box);
_pcEl = box;
_pcSync();
@ -4218,6 +4246,7 @@
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
_pcReactiveWrap = null; _pcIntensityWrap = null;
}
function _pcAcquire() {
_pcRefs++;

View File

@ -339,3 +339,32 @@ test('greyed-out controls explain themselves on hover', () => {
assert.match(api.react.title, /butterchurn/i);
assert.match(api.intens.title, /butterchurn/i);
});
// A native-disabled <button>/<input> fires no pointer events, so its own
// `title` never shows on hover. The reason must therefore also sit on the
// non-disabled wrapper, and the disabled control must let the hover fall
// through (pointer-events:none) — otherwise the "says why on hover" feature is
// dead in the browser while these tests pass on the swallowed control title.
test('the greyed-out reason reaches a hoverable wrapper', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'video'; // uses neither setting
emit('style');
assert.match(api.react.parentNode.title, /nothing to adjust/i,
'reactive reason must be on the wrapper, not only the disabled pill');
assert.equal(api.react.style.pointerEvents, 'none',
'disabled pill must pass hover through to its wrapper');
assert.match(api.intens.parentNode.title, /nothing to adjust/i,
'intensity reason must be on the wrapper, not only the disabled slider');
assert.equal(api.intens.style.pointerEvents, 'none',
'disabled slider must pass hover through to its wrapper');
// ...and an enabled style clears the wrapper so the control's own title wins.
store.style = 'particles';
emit('style');
assert.equal(api.react.parentNode.title, '');
assert.equal(api.intens.parentNode.title, '');
assert.equal(api.intens.style.pointerEvents, '');
});