mirror of
https://github.com/tiennm99/sokoban.git
synced 2026-09-08 22:20:26 +00:00
refactor: dedup dialog CSS and tighten focus management
- DonateModal: focus the dialog on open so screen readers announce it and Tab cycles within; restore focus to the previously-active element on close. - Move shared overlay/dialog scaffolding to app.css so GameView's win dialog and DonateModal stop duplicating the same backdrop and frame. - Move touch-action and tap-highlight neutralization to a global button rule; drop the per-component copies. - level-parser: collapse the key/cellKey alias - export cellKey directly. - BoardModel.isSolved: inline the empty-box-set guard into the return. - LevelSelectView: completedCount is read once and never reassigned; drop the $state wrapper. - Board: hoist the wall-trim DIRS array out of $derived so it isn't rebuilt every reactive recomputation.
This commit is contained in:
+30
@@ -68,6 +68,36 @@ body {
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Shared modal scaffolding. Specific dialogs add their own padding/sizing. */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(12, 16, 24, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 16px;
|
||||
animation: dialog-fade-in 180ms ease;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
@keyframes dialog-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
h1, h2, h3, p {
|
||||
|
||||
@@ -64,10 +64,9 @@ export class BoardModel {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when every box sits on a target. */
|
||||
/** True when every box sits on a target. (Empty box-set is never "solved".) */
|
||||
isSolved() {
|
||||
if (this.boxes.length === 0) return false;
|
||||
return this.boxes.every(b => this.isTarget(b.x, b.y));
|
||||
return this.boxes.length > 0 && this.boxes.every(b => this.isTarget(b.x, b.y));
|
||||
}
|
||||
|
||||
get moveCount() {
|
||||
|
||||
@@ -15,7 +15,7 @@ const BOX_ON_TARGET = '*';
|
||||
const PLAYER = '@';
|
||||
const PLAYER_ON_TARGET = '+';
|
||||
|
||||
const key = (x, y) => `${x},${y}`;
|
||||
export const cellKey = (x, y) => `${x},${y}`;
|
||||
|
||||
function parseGrid(xsb) {
|
||||
const lines = xsb.split('\n').filter(l => l.length > 0 && !l.startsWith(';'));
|
||||
@@ -35,24 +35,24 @@ function extractEntities(lines, width, height) {
|
||||
const ch = row[x] || ' ';
|
||||
switch (ch) {
|
||||
case WALL:
|
||||
walls.add(key(x, y));
|
||||
walls.add(cellKey(x, y));
|
||||
break;
|
||||
case TARGET:
|
||||
targets.add(key(x, y));
|
||||
targets.add(cellKey(x, y));
|
||||
break;
|
||||
case BOX:
|
||||
boxes.push({ x, y });
|
||||
break;
|
||||
case BOX_ON_TARGET:
|
||||
boxes.push({ x, y });
|
||||
targets.add(key(x, y));
|
||||
targets.add(cellKey(x, y));
|
||||
break;
|
||||
case PLAYER:
|
||||
player = { x, y };
|
||||
break;
|
||||
case PLAYER_ON_TARGET:
|
||||
player = { x, y };
|
||||
targets.add(key(x, y));
|
||||
targets.add(cellKey(x, y));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ function floodFillFloors(player, walls, width, height) {
|
||||
while (stack.length) {
|
||||
const { x, y } = stack.pop();
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
||||
const k = key(x, y);
|
||||
const k = cellKey(x, y);
|
||||
if (floors.has(k) || walls.has(k)) continue;
|
||||
floors.add(k);
|
||||
stack.push({ x: x + 1, y });
|
||||
@@ -84,6 +84,4 @@ export function parseLevel(xsb) {
|
||||
const { walls, targets, boxes, player } = extractEntities(lines, width, height);
|
||||
const floors = floodFillFloors(player, walls, width, height);
|
||||
return { width, height, walls, targets, boxes, player, floors };
|
||||
}
|
||||
|
||||
export { key as cellKey };
|
||||
}
|
||||
@@ -42,8 +42,6 @@
|
||||
transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
tileSize = 48
|
||||
} = $props();
|
||||
|
||||
// 8-neighborhood for the wall-trim pass below. Hoisted out of $derived
|
||||
// so it isn't reallocated on every reactive recomputation.
|
||||
const DIRS = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,1],[-1,1],[1,-1]];
|
||||
|
||||
function keyToXY(k) {
|
||||
const [x, y] = k.split(',').map(Number);
|
||||
return { x, y };
|
||||
@@ -38,7 +42,6 @@
|
||||
|
||||
// Only render walls that touch a floor tile — skips the unused outer border.
|
||||
let wallCells = $derived.by(() => {
|
||||
const DIRS = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,1],[-1,1],[1,-1]];
|
||||
const out = [];
|
||||
for (const k of walls) {
|
||||
const { x, y } = keyToXY(k);
|
||||
|
||||
@@ -7,6 +7,22 @@
|
||||
|
||||
let { open = false, onClose } = $props();
|
||||
|
||||
let dialogEl = $state();
|
||||
let prevFocus = null;
|
||||
|
||||
// On open: stash the previously-focused element and move focus into the
|
||||
// dialog so screen readers announce it and Tab cycles within. On close:
|
||||
// restore the prior focus so keyboard users land back where they were.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
prevFocus = document.activeElement;
|
||||
queueMicrotask(() => dialogEl?.focus());
|
||||
} else if (prevFocus instanceof HTMLElement) {
|
||||
prevFocus.focus();
|
||||
prevFocus = null;
|
||||
}
|
||||
});
|
||||
|
||||
function onKey(e) {
|
||||
if (open && e.key === 'Escape') onClose();
|
||||
}
|
||||
@@ -23,7 +39,14 @@
|
||||
|
||||
{#if open}
|
||||
<div class="overlay" onclick={onBackdropClick} role="presentation">
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label="Donate" tabindex="-1">
|
||||
<div
|
||||
class="dialog"
|
||||
bind:this={dialogEl}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Donate"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h2>Thanks for playing!</h2>
|
||||
<p class="sub">Scan to send a tip — supports 50+ banking apps.</p>
|
||||
<img
|
||||
@@ -37,31 +60,17 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* .overlay + .dialog base lives in app.css; only specific tweaks here. */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(12, 16, 24, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
animation: fade-in 180ms ease;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px 28px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 380px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
@@ -83,9 +92,4 @@
|
||||
border-radius: var(--radius);
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -251,27 +251,10 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(12, 16, 24, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
animation: fade-in 180ms ease;
|
||||
}
|
||||
|
||||
/* .overlay + .dialog base lives in app.css; only specific tweaks here. */
|
||||
.dialog {
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
@@ -291,8 +274,4 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
|
||||
let page = $state(0);
|
||||
|
||||
let completedCount = $state(progressStore.getCompletedCount());
|
||||
// Snapshot once on entry; doesn't reactively refresh, but the user
|
||||
// navigates away from this screen to play, so this is fine.
|
||||
const completedCount = progressStore.getCompletedCount();
|
||||
|
||||
let visibleLevels = $derived.by(() => {
|
||||
// Read storage once per page render instead of 2× per visible level.
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Global `button` rules (touch-action, tap-highlight) live in app.css. */
|
||||
.action {
|
||||
min-width: 64px;
|
||||
height: 44px;
|
||||
@@ -60,30 +61,24 @@
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 22px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.arrow.up { grid-area: up; }
|
||||
|
||||
Reference in New Issue
Block a user