[#1+#7] Mobile-first redesign + accessibility
- Bottom tab bar on mobile (≤640px), top tabs on desktop - Name modal → bottom sheet on mobile with drag handle - Larger tap targets (72px cards, 44px touch areas) - Arrow hint (›) on clickable option cards - Touch feedback with :active scale animation - ARIA roles: tablist/tab/tabpanel, aria-selected, aria-live=polite - Keyboard: Arrow keys navigate tabs, 1-5 number keys switch tabs - Skip-to-content link for screen readers - Visible :focus-visible ring - Desktop body padding reset for bottom tabs
This commit is contained in:
@@ -40,6 +40,26 @@
|
|||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Skip to content */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
padding: 8px 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
.skip-link:focus { top: 0; }
|
||||||
|
|
||||||
|
/* Visible focus ring for accessibility */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Header ─────────────────────────────────────────────── */
|
/* ── Header ─────────────────────────────────────────────── */
|
||||||
header {
|
header {
|
||||||
background: linear-gradient(135deg, #13161f 0%, #1a1e2a 100%);
|
background: linear-gradient(135deg, #13161f 0%, #1a1e2a 100%);
|
||||||
@@ -97,6 +117,10 @@
|
|||||||
width: 360px;
|
width: 360px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.modal .drag-handle {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); }
|
.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); }
|
||||||
.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; }
|
.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; }
|
||||||
@@ -405,20 +429,134 @@
|
|||||||
}
|
}
|
||||||
.empty-state .empty-emoji { font-size: 2rem; margin-bottom: 10px; }
|
.empty-state .empty-emoji { font-size: 2rem; margin-bottom: 10px; }
|
||||||
|
|
||||||
/* ── Mobile ──────────────────────────────────────────────── */
|
/* ── Bottom tab bar (mobile ≤640px) ──────────────────────── */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 640px) {
|
||||||
header { flex-direction: column; gap: 6px; align-items: flex-start; }
|
body { padding-bottom: 68px; }
|
||||||
.meta { text-align: left; }
|
|
||||||
main { padding: 12px; }
|
/* Compact sticky header */
|
||||||
.tab { min-width: 70px; font-size: 0.68rem; }
|
header {
|
||||||
|
padding: 8px 14px;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
header h1 { font-size: 0.95rem; }
|
||||||
|
header .meta { font-size: 0.72rem; }
|
||||||
|
|
||||||
|
/* Bottom tab bar */
|
||||||
|
.tabs {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: auto;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
/* hide scroll — show all 5 tabs in a row */
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 4px 10px;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top: 3px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
background: rgba(0,212,255,0.08);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.tab .tab-emoji { font-size: 1.2rem; }
|
||||||
|
.tab .tab-count {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 6px;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main adjusts for bottom tabs */
|
||||||
|
main { padding: 12px; max-width: 100%; }
|
||||||
|
|
||||||
|
/* Option cards — larger tap targets */
|
||||||
|
.option-card { min-height: 72px; padding: 14px 16px; }
|
||||||
|
.option-name { font-size: 0.92rem; }
|
||||||
|
.option-desc { font-size: 0.76rem; }
|
||||||
|
|
||||||
|
/* Add arrow hint to cards */
|
||||||
|
.option-card::after {
|
||||||
|
content: '›';
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.option-card.voted::after { display: none; }
|
||||||
|
|
||||||
|
/* Touch feedback */
|
||||||
|
.option-card:active { transform: scale(0.98); opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop (≥641px) ──────────────────────────────────── */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
body { padding-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile bottom sheet modal ───────────────────────────── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal-overlay { align-items: flex-end; justify-content: stretch; padding: 0; }
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
padding: 12px 24px 32px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 -8px 40px rgba(0,0,0,0.6);
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.modal .drag-handle {
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
}
|
||||||
|
.modal h2 { font-size: 1.15rem; }
|
||||||
|
.modal p { font-size: 0.8rem; margin-bottom: 14px; }
|
||||||
|
.modal input, .modal button { margin-bottom: 10px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch highlight */
|
||||||
|
.option-card { -webkit-tap-highlight-color: rgba(0,212,255,0.12); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<a class="skip-link" href="#optionsList">Skip to voting options</a>
|
||||||
|
|
||||||
<!-- Name Modal -->
|
<!-- Name Modal -->
|
||||||
<div class="modal-overlay" id="nameModal">
|
<div class="modal-overlay" id="nameModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
<div class="drag-handle"></div>
|
||||||
<h2>🏄 Who's Voting?</h2>
|
<h2>🏄 Who's Voting?</h2>
|
||||||
<p>Enter your name so groomsmen know who voted for what. Stored locally — only visible to the group.</p>
|
<p>Enter your name so groomsmen know who voted for what. Stored locally — only visible to the group.</p>
|
||||||
<input type="text" id="voterNameInput" placeholder="e.g. Mike, Chris, Dave…" maxlength="30" autocomplete="off" />
|
<input type="text" id="voterNameInput" placeholder="e.g. Mike, Chris, Dave…" maxlength="30" autocomplete="off" />
|
||||||
@@ -448,7 +586,7 @@
|
|||||||
<div><span id="totalVotersCount"></span></div>
|
<div><span id="totalVotersCount"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="options-list" id="optionsList">
|
<div class="options-list" id="optionsList" role="tabpanel" aria-label="Voting options" aria-live="polite">
|
||||||
<div class="empty-state"><div class="empty-emoji">⏳</div>Loading options…</div>
|
<div class="empty-state"><div class="empty-emoji">⏳</div>Loading options…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -500,6 +638,15 @@
|
|||||||
if (e.key === 'Enter') submitName();
|
if (e.key === 'Enter') submitName();
|
||||||
});
|
});
|
||||||
document.getElementById('voterNameInput').focus();
|
document.getElementById('voterNameInput').focus();
|
||||||
|
|
||||||
|
// Number keys 1-5 to switch tabs
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
const num = parseInt(e.key);
|
||||||
|
if (num >= 1 && num <= state.categories.length) {
|
||||||
|
setTab(state.categories[num - 1].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket ──────────────────────────────────────────────
|
// ── WebSocket ──────────────────────────────────────────────
|
||||||
@@ -592,18 +739,44 @@
|
|||||||
function renderTabs() {
|
function renderTabs() {
|
||||||
const bar = document.getElementById('tabsBar');
|
const bar = document.getElementById('tabsBar');
|
||||||
bar.innerHTML = state.categories.map(cat => `
|
bar.innerHTML = state.categories.map(cat => `
|
||||||
<div class="tab${cat.id === activeTab ? ' active' : ''}" onclick="setTab('${cat.id}')">
|
<div class="tab${cat.id === activeTab ? ' active' : ''}"
|
||||||
|
role="tab"
|
||||||
|
id="tab-${cat.id}"
|
||||||
|
aria-selected="${cat.id === activeTab}"
|
||||||
|
aria-controls="optionsList"
|
||||||
|
tabindex="${cat.id === activeTab ? 0 : -1}"
|
||||||
|
onclick="setTab('${cat.id}')"
|
||||||
|
onkeydown="handleTabKey(event, '${cat.id}')">
|
||||||
<span class="tab-emoji">${cat.emoji}</span>
|
<span class="tab-emoji">${cat.emoji}</span>
|
||||||
${cat.name}
|
${cat.name}
|
||||||
<span class="tab-count" id="tab-count-${cat.id}">${state.options.filter(o => o.categoryId === cat.id).length}</span>
|
<span class="tab-count" id="tab-count-${cat.id}">${state.options.filter(o => o.categoryId === cat.id).length}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
bar.setAttribute('role', 'tablist');
|
||||||
|
bar.setAttribute('aria-label', 'Voting categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabKey(event, catId) {
|
||||||
|
const cats = state.categories.map(c => c.id);
|
||||||
|
const idx = cats.indexOf(catId);
|
||||||
|
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
setTab(cats[(idx + 1) % cats.length]);
|
||||||
|
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
setTab(cats[(idx - 1 + cats.length) % cats.length]);
|
||||||
|
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
setTab(catId);
|
||||||
|
}
|
||||||
|
document.getElementById(`tab-${cats[(idx + 1) % cats.length]}`)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTab(id) {
|
function setTab(id) {
|
||||||
activeTab = id;
|
activeTab = id;
|
||||||
renderTabs();
|
renderTabs();
|
||||||
render();
|
render();
|
||||||
|
document.getElementById('optionsList')?.setAttribute('aria-label', state.categories.find(c => c.id === id)?.name + ' options');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render options ────────────────────────────────────────
|
// ── Render options ────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user