Skip to content

Commit 87977e0

Browse files
author
web3blind
committed
Improve accessibility foundations
1 parent 4fc485d commit 87977e0

12 files changed

Lines changed: 396 additions & 64 deletions

File tree

app/css/accessibility.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
.spell-btn.selected { border: 3px solid Highlight; }
1010
}
1111

12+
.high-contrast,
1213
[data-theme="high-contrast"] {
1314
--color-bg: #000000;
1415
--color-surface: #1a1a1a;
@@ -24,9 +25,18 @@
2425
--color-border: #666666;
2526
}
2627

28+
.high-contrast .btn-primary,
2729
[data-theme="high-contrast"] .btn-primary { color: #000; font-weight: 700; }
30+
31+
.high-contrast .progress-bar,
2832
[data-theme="high-contrast"] .progress-bar { border: 2px solid var(--color-border); }
2933

34+
.high-contrast .skip-link,
35+
[data-theme="high-contrast"] .skip-link {
36+
background: #ffdd00;
37+
color: #000;
38+
}
39+
3040
/* Reduced motion */
3141
@media (prefers-reduced-motion: reduce) {
3242
*, *::before, *::after {
@@ -39,6 +49,25 @@
3949
.btn-glow { box-shadow: none; border: 2px solid var(--color-primary); }
4050
}
4151

52+
.reduced-motion *,
53+
.reduced-motion *::before,
54+
.reduced-motion *::after {
55+
animation-duration: 0.01ms !important;
56+
animation-iteration-count: 1 !important;
57+
transition-duration: 0.01ms !important;
58+
scroll-behavior: auto !important;
59+
}
60+
61+
.reduced-motion .toast,
62+
.reduced-motion .progress-fill {
63+
transition: none !important;
64+
}
65+
66+
.reduced-motion .btn-glow {
67+
box-shadow: none;
68+
border: 2px solid var(--color-primary);
69+
}
70+
4271
/* Focus visible for keyboard navigation */
4372
*:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 2px; }
4473
*:focus:not(:focus-visible) { outline: none; }

app/css/main.css

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,30 @@ body {
3535
-webkit-font-smoothing: antialiased;
3636
}
3737

38+
.app-shell { min-height: 100vh; }
39+
40+
.skip-link {
41+
position: absolute;
42+
top: 8px;
43+
left: 8px;
44+
z-index: 1001;
45+
padding: 12px 16px;
46+
background: var(--color-accent);
47+
color: #000;
48+
border-radius: var(--radius-sm);
49+
text-decoration: none;
50+
font-weight: 700;
51+
transform: translateY(-160%);
52+
transition: transform 0.2s ease;
53+
}
54+
55+
.skip-link:focus,
56+
.skip-link:focus-visible {
57+
transform: translateY(0);
58+
outline: 3px solid #fff;
59+
outline-offset: 2px;
60+
}
61+
3862
/* Screens */
3963
.screen { display: none; min-height: 100vh; padding: 16px; padding-bottom: 80px; }
4064
.screen.active { display: block; }
@@ -259,7 +283,8 @@ body {
259283
color: var(--color-text-muted); font-size: 0.7rem;
260284
min-height: var(--touch-target); transition: color var(--transition);
261285
}
262-
.nav-tab.active { color: var(--color-primary); }
286+
.nav-tab.active,
287+
.nav-tab[aria-current="page"] { color: var(--color-primary); }
263288
.nav-tab:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
264289
.nav-icon { font-size: 1.25rem; margin-bottom: 2px; }
265290

app/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
<link rel="stylesheet" href="css/accessibility.css">
1717
</head>
1818
<body>
19+
<a class="skip-link" href="#app-main">Skip to current screen</a>
20+
1921
<!-- Connection status banner -->
2022
<div id="connection-status" role="status" aria-live="polite"></div>
2123

24+
<div id="app-main" class="app-shell" tabindex="-1">
2225
<!-- Screen: Landing -->
2326
<main id="screen-landing" class="screen active" role="main" aria-hidden="false" aria-label="Landing">
2427
</main>
@@ -94,6 +97,7 @@
9497
<!-- Screen: Leaderboard -->
9598
<section id="screen-leaderboard" class="screen" role="main" aria-hidden="true" aria-label="Leaderboard">
9699
</section>
100+
</div>
97101

98102
<!-- Bottom navigation -->
99103
<nav id="bottom-nav" role="navigation" aria-label="Main navigation"></nav>

app/js/ui/app.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ var App = (function() {
177177
nav.classList.remove('show');
178178
}
179179

180+
_moveFocusToScreen(target, screenId);
181+
180182
// Announce screen change
181183
var screenTitle = Helpers.t('nav_' + screenId) || screenId;
182184
A11y.announce(screenTitle);
@@ -214,6 +216,29 @@ var App = (function() {
214216
}
215217
}
216218

219+
function _moveFocusToScreen(target, screenId) {
220+
if (!target) return;
221+
222+
var appMain = Helpers.$('app-main');
223+
if (appMain) {
224+
appMain.setAttribute('data-current-screen', screenId);
225+
}
226+
227+
var focusTarget = target.querySelector('h1, [data-screen-focus], [role="heading"]');
228+
if (!focusTarget) {
229+
focusTarget = target;
230+
}
231+
if (!focusTarget.getAttribute('tabindex')) {
232+
focusTarget.setAttribute('tabindex', '-1');
233+
}
234+
235+
setTimeout(function() {
236+
if (focusTarget && focusTarget.focus) {
237+
focusTarget.focus();
238+
}
239+
}, 0);
240+
}
241+
217242
/**
218243
* Show initial connection status
219244
*/

app/js/ui/components/modal.js

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ var ModalComponent = (function() {
55
'use strict';
66

77
var currentTrap = null;
8+
var lastFocusedElement = null;
9+
var overlayClickHandler = null;
810

911
function show(titleOrHtml, content, buttons) {
1012
var overlay = Helpers.$('modal-overlay');
1113
var modal = Helpers.$('modal-container');
1214
if (!overlay || !modal) return;
1315

1416
var html;
17+
var accessibleTitle = '';
18+
1519
// Support two calling conventions:
1620
// 1. show(rawHtml) — guild.js style: pass pre-built HTML string
1721
// 2. show({title,text,buttons}) — object style used by marketplace/crafting
@@ -22,42 +26,57 @@ var ModalComponent = (function() {
2226
} else if (titleOrHtml && typeof titleOrHtml === 'object') {
2327
// Object mode: {title, text, buttons}
2428
var opts = titleOrHtml;
29+
accessibleTitle = opts.title || '';
2530
html = '<div class="modal-content">';
2631
if (opts.title) html += '<h2 class="modal-title">' + Helpers.escapeHtml(opts.title) + '</h2>';
2732
if (opts.text) html += '<p>' + Helpers.escapeHtml(opts.text) + '</p>';
2833
html += '<div class="modal-actions">';
2934
if (opts.buttons) {
3035
for (var i = 0; i < opts.buttons.length; i++) {
3136
var b = opts.buttons[i];
32-
html += '<button class="btn ' + (b.className || 'btn-secondary') + '" data-action="' + i + '">';
37+
html += '<button type="button" class="btn ' + (b.className || 'btn-secondary') + '" data-action="' + i + '">';
3338
html += Helpers.escapeHtml(b.text || b.label || '');
3439
html += '</button>';
3540
}
3641
}
37-
html += '<button class="btn btn-secondary modal-close" aria-label="' + Helpers.t('close') + '">' + Helpers.t('close') + '</button>';
42+
html += '<button type="button" class="btn btn-secondary modal-close" aria-label="' + Helpers.t('close') + '">' + Helpers.t('close') + '</button>';
3843
html += '</div></div>';
3944
buttons = opts.buttons;
4045
} else {
4146
// Legacy positional: show(title, content, buttons)
47+
accessibleTitle = titleOrHtml || '';
4248
html = '<div class="modal-content">';
4349
html += '<h2 class="modal-title">' + Helpers.escapeHtml(titleOrHtml) + '</h2>';
4450
html += '<div class="modal-body">' + (content || '') + '</div>';
4551
html += '<div class="modal-actions">';
4652
if (buttons) {
4753
for (var bi = 0; bi < buttons.length; bi++) {
4854
var btn = buttons[bi];
49-
html += '<button class="btn ' + (btn.primary ? 'btn-primary' : 'btn-secondary') + '" data-action="' + bi + '">';
55+
html += '<button type="button" class="btn ' + (btn.primary ? 'btn-primary' : 'btn-secondary') + '" data-action="' + bi + '">';
5056
html += Helpers.escapeHtml(btn.label || btn.text || '');
5157
html += '</button>';
5258
}
5359
}
54-
html += '<button class="btn btn-secondary modal-close" aria-label="' + Helpers.t('close') + '">' + Helpers.t('close') + '</button>';
60+
html += '<button type="button" class="btn btn-secondary modal-close" aria-label="' + Helpers.t('close') + '">' + Helpers.t('close') + '</button>';
5561
html += '</div></div>';
5662
}
5763

64+
lastFocusedElement = document.activeElement;
5865
modal.innerHTML = html;
66+
modal.className = 'modal show';
67+
modal.setAttribute('role', 'dialog');
68+
modal.setAttribute('aria-modal', 'true');
5969
overlay.classList.add('show');
60-
modal.classList.add('show');
70+
71+
var titleEl = modal.querySelector('.modal-title, h1, h2, h3');
72+
if (titleEl) {
73+
if (!titleEl.id) titleEl.id = 'modal-title';
74+
modal.setAttribute('aria-labelledby', titleEl.id);
75+
modal.removeAttribute('aria-label');
76+
} else {
77+
modal.removeAttribute('aria-labelledby');
78+
modal.setAttribute('aria-label', accessibleTitle || 'Dialog');
79+
}
6180

6281
currentTrap = A11y.trapFocus(modal);
6382
A11y.focusFirst(modal);
@@ -66,7 +85,7 @@ var ModalComponent = (function() {
6685
var actionBtns = modal.querySelectorAll('[data-action]');
6786
for (var j = 0; j < actionBtns.length; j++) {
6887
actionBtns[j].addEventListener('click', function() {
69-
var idx = parseInt(this.getAttribute('data-action'));
88+
var idx = parseInt(this.getAttribute('data-action'), 10);
7089
if (buttons && buttons[idx] && (buttons[idx].action || buttons[idx].onClick)) {
7190
var fn = buttons[idx].action || buttons[idx].onClick;
7291
fn();
@@ -76,9 +95,14 @@ var ModalComponent = (function() {
7695
}
7796
var closeBtn = modal.querySelector('.modal-close');
7897
if (closeBtn) closeBtn.addEventListener('click', hide);
79-
overlay.addEventListener('click', function(e) {
98+
99+
if (overlayClickHandler) {
100+
overlay.removeEventListener('click', overlayClickHandler);
101+
}
102+
overlayClickHandler = function(e) {
80103
if (e.target === overlay) hide();
81-
});
104+
};
105+
overlay.addEventListener('click', overlayClickHandler);
82106

83107
document.addEventListener('keydown', _escHandler);
84108
}
@@ -87,16 +111,30 @@ var ModalComponent = (function() {
87111
var overlay = Helpers.$('modal-overlay');
88112
var modal = Helpers.$('modal-container');
89113
if (overlay) overlay.classList.remove('show');
90-
if (modal) modal.classList.remove('show');
114+
if (modal) {
115+
modal.className = 'modal';
116+
modal.removeAttribute('aria-labelledby');
117+
modal.removeAttribute('aria-label');
118+
modal.removeAttribute('aria-modal');
119+
modal.removeAttribute('role');
120+
}
91121
if (currentTrap) { currentTrap(); currentTrap = null; }
122+
if (overlay && overlayClickHandler) {
123+
overlay.removeEventListener('click', overlayClickHandler);
124+
overlayClickHandler = null;
125+
}
92126
document.removeEventListener('keydown', _escHandler);
127+
128+
if (lastFocusedElement && lastFocusedElement.focus) {
129+
lastFocusedElement.focus();
130+
}
93131
}
94132

95133
function _escHandler(e) {
96134
if (e.key === 'Escape') hide();
97135
}
98136

99-
return { show: show, hide: hide };
137+
return { show: show, hide: hide, close: hide };
100138
})();
101139

102140
// Convenience alias used across all screen modules

app/js/ui/components/nav.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,14 @@ var NavComponent = (function() {
2727
for (var i = 0; i < tabs.length; i++) {
2828
var tab = tabs[i];
2929
var isActive = tab.id === activeTab ? ' active' : '';
30-
html += '<button class="nav-tab' + isActive + '" data-screen="' + tab.id + '" ';
31-
html += 'role="tab" aria-selected="' + (tab.id === activeTab) + '" ';
30+
html += '<button type="button" class="nav-tab' + isActive + '" data-screen="' + tab.id + '" ';
31+
if (tab.id === activeTab) html += 'aria-current="page" ';
3232
html += 'aria-label="' + tab.label + '">';
3333
html += '<span class="nav-icon" aria-hidden="true">' + tab.icon + '</span>';
3434
html += '<span class="nav-label">' + tab.label + '</span>';
3535
html += '</button>';
3636
}
3737
nav.innerHTML = html;
38-
nav.setAttribute('role', 'tablist');
3938
nav.setAttribute('aria-label', 'Main navigation');
4039

4140
// Event listeners

app/js/ui/screens/hunt.js

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ var HuntScreen = (function() {
4747
for (var i = 0; i < creatures.length; i++) {
4848
var c = creatures[i];
4949
html += '<button class="creature-card" data-id="' + c.id + '" role="radio" aria-checked="false" ' +
50+
'tabindex="' + (i === 0 ? '0' : '-1') + '" type="button" ' +
5051
'aria-label="' + c.name + '. Level ' + c.minLevel + ' to ' + c.maxLevel + '">' +
5152
'<span class="creature-name">' + c.name + '</span>' +
5253
'<span class="creature-level">Lv ' + c.minLevel + '-' + c.maxLevel + '</span>' +
@@ -62,6 +63,7 @@ var HuntScreen = (function() {
6263
for (var j = 0; j < spells.length; j++) {
6364
var s = spells[j];
6465
html += '<button class="spell-btn ' + Helpers.schoolClass(s.school) + '" data-id="' + s.id + '" role="radio" aria-checked="false" ' +
66+
'tabindex="' + (j === 0 ? '0' : '-1') + '" type="button" ' +
6567
'aria-label="' + s.name + '. ' + t('hunt_mana_cost', {cost: Helpers.bpToPercent(s.manaCost)}) + '">' +
6668
'<span class="spell-name">' + s.name + '</span>' +
6769
'<span class="spell-cost">' + Helpers.manaCost(s.manaCost) + '</span>' +
@@ -113,35 +115,17 @@ var HuntScreen = (function() {
113115
}
114116

115117
function _bindEvents(el) {
116-
var creatureCards = el.querySelectorAll('.creature-card');
117-
for (var i = 0; i < creatureCards.length; i++) {
118-
creatureCards[i].addEventListener('click', function() {
119-
selectedCreature = this.getAttribute('data-id');
120-
SoundManager.play('tap');
121-
for (var k = 0; k < creatureCards.length; k++) {
122-
creatureCards[k].classList.remove('selected');
123-
creatureCards[k].setAttribute('aria-checked', 'false');
124-
}
125-
this.classList.add('selected');
126-
this.setAttribute('aria-checked', 'true');
127-
_checkReady();
128-
});
129-
}
118+
A11y.bindRadioGroup(el.querySelector('.creature-list[role="radiogroup"]'), '.creature-card', function(option) {
119+
selectedCreature = option.getAttribute('data-id');
120+
SoundManager.play('tap');
121+
_checkReady();
122+
});
130123

131-
var spellBtns = el.querySelectorAll('.spell-btn');
132-
for (var j = 0; j < spellBtns.length; j++) {
133-
spellBtns[j].addEventListener('click', function() {
134-
selectedSpell = this.getAttribute('data-id');
135-
SoundManager.play('tap');
136-
for (var k = 0; k < spellBtns.length; k++) {
137-
spellBtns[k].classList.remove('selected');
138-
spellBtns[k].setAttribute('aria-checked', 'false');
139-
}
140-
this.classList.add('selected');
141-
this.setAttribute('aria-checked', 'true');
142-
_checkReady();
143-
});
144-
}
124+
A11y.bindRadioGroup(el.querySelector('.spell-grid[role="radiogroup"]'), '.spell-btn', function(option) {
125+
selectedSpell = option.getAttribute('data-id');
126+
SoundManager.play('tap');
127+
_checkReady();
128+
});
145129

146130
Helpers.$('btn-attack').addEventListener('click', _doHunt);
147131

0 commit comments

Comments
 (0)