android: what a screen reader says — names, roles, and the sheets out of the tree when they are down
Every control on the phone has a name now, in the app's own words, and the desktop's word where it already has one; the nav is a tab set whose badge count is part of the tab's name — "You, 3 transfers" rather than a stray 3 read out before it. The two sheets are dialogs with somewhere sensible for the focus to land, and the half sheet's grab handle is finally a real Close — the first dismissal a screen reader could reach, because the scrim never was one. A sheet that is down is inert rather than sitting off-screen in the accessibility tree with the hidden mini bar and the nav under it, and one live region is mounted for the life of the app so that an undo offer is actually announced; switches say on or off, chips say pressed, and the waveform is a slider where it can seek. Nothing visible changed. The walk counts the controls with no name, with a junk name, in a hidden layer, or with unsaid state — all four held at zero on ten screens, and the nameless count on a room and on both prompts besides, which is where the six fields named here actually live.
This commit is contained in:
1 parent
9df162360e
commit
4fc6e9c630
22 files changed
+355
-83
No files matched your search
@@ -107,6 +107,30 @@
|
||||
const playing = $derived(player.status === "playing" || player.status === "buffering");
|
||||
const played = $derived(player.duration ? Math.min(1, player.position / player.duration) : 0);
|
||||
|
||||
/* ── what the machine says ───────────────────────────────────────── */
|
||||
/* A tab's own name: the section, and when the dot is up, what the
|
||||
number counts. "You, 3 transfers" — not a stray 3 read out before
|
||||
the word it belongs to. */
|
||||
function tabCount(id) {
|
||||
if (id === "you") return activeTransfers().length;
|
||||
if (id === "community") return unreadTotal();
|
||||
return 0;
|
||||
}
|
||||
function tabName(id, label) {
|
||||
const n = tabCount(id);
|
||||
if (!n) return label;
|
||||
return label + ", " + n + (id === "you" ? " transfer" + (n === 1 ? "" : "s") : " unread");
|
||||
}
|
||||
|
||||
/* The one live region. It is mounted for the life of the app on
|
||||
purpose: a region that appears with its words already inside it is
|
||||
announced reliably by neither TalkBack nor VoiceOver, so an
|
||||
announcement here is a TEXT CHANGE in a node that was always there.
|
||||
The desktop does the same (FollowToast's always-mounted .layer,
|
||||
PreviewOverlay's .sr-announce). Never drawn — shell.css clips it to
|
||||
a pixel. */
|
||||
const announce = $derived([player.error || lib.error, notice.text, notice.action?.label].filter(Boolean).join(". "));
|
||||
|
||||
/* ── the ground: two layers, the incoming one fades up over the old ── */
|
||||
let ambA = $state(null);
|
||||
let ambB = $state(null);
|
||||
@@ -272,7 +296,7 @@
|
||||
</div>
|
||||
|
||||
{#if player.track}
|
||||
<div class="mini" class:hidden={nav.sheet} use:drag={miniDrag} onclick={onMiniTap} oncontextmenu={(e) => { e.preventDefault(); openHalf("track", { album: player.album, track: player.track }); }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Now playing">
|
||||
<div class="mini" class:hidden={nav.sheet} use:drag={miniDrag} onclick={onMiniTap} oncontextmenu={(e) => { e.preventDefault(); openHalf("track", { album: player.album, track: player.track }); }} role="button" tabindex="0" onkeydown={() => {}} aria-hidden={nav.sheet ? "true" : undefined} aria-label={"Now playing: " + (player.track.title || "") + (player.track.artist || player.album?.artist ? " · " + (player.track.artist || player.album?.artist) : "")}>
|
||||
<span class="art" bind:this={miniArt}><Cover path={player.album?.art_path} eager size={20} /></span>
|
||||
<span class="b">
|
||||
<span class="t">{player.track.title}</span>
|
||||
@@ -293,32 +317,40 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<nav class="nav" class:gone={nav.sheet}>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -- the
|
||||
five destinations ARE a tab set, and the desktop's own segmented
|
||||
control says so too; wrapping them in an inner div to keep <nav>'s
|
||||
implicit role would change the bar's flex layout -->
|
||||
<nav class="nav" class:gone={nav.sheet} role="tablist" aria-label="Sections" aria-hidden={nav.sheet ? "true" : undefined}>
|
||||
{#each TABS as [id, icon, label] (id)}
|
||||
<button class:on={nav.tab === id} onclick={() => go(id)}>
|
||||
<button class:on={nav.tab === id} onclick={() => go(id)} role="tab" aria-selected={nav.tab === id} aria-label={tabName(id, label)}>
|
||||
<span class="pill"></span>
|
||||
<span class="gl">
|
||||
<Icon name={icon} size={22} />
|
||||
{#if id === "you" && activeTransfers().length}<span class="nb">{activeTransfers().length}</span>{/if}
|
||||
{#if id === "community" && unreadTotal()}<span class="nb">{unreadTotal()}</span>{/if}
|
||||
{#if id === "you" && activeTransfers().length}<span class="nb" aria-hidden="true">{activeTransfers().length}</span>{/if}
|
||||
{#if id === "community" && unreadTotal()}<span class="nb" aria-hidden="true">{unreadTotal()}</span>{/if}
|
||||
</span>
|
||||
<span class="lb">{label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="scrim" class:on={!!nav.half} onclick={closeHalf} role="presentation"></div>
|
||||
<div class="scrim" class:on={!!nav.half} onclick={closeHalf} aria-hidden="true"></div>
|
||||
<HalfSheet />
|
||||
<Sheet />
|
||||
<Viewer />
|
||||
<SyncRoom />
|
||||
|
||||
<!-- the announcement: one node, always here, so a change of its words
|
||||
is what gets read out. The toasts below stay where they were and
|
||||
say nothing on their own — two announcers say everything twice -->
|
||||
<div class="sr-live" data-live aria-live="polite" aria-atomic="true">{announce}</div>
|
||||
{#if player.error || lib.error}
|
||||
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
|
||||
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message">{player.error || lib.error}</div>
|
||||
{/if}
|
||||
{#if notice.text}
|
||||
<div class="toast notice" role="status">
|
||||
<span class="nt">{notice.text}</span>
|
||||
<div class="toast notice">
|
||||
<span class="nt" aria-hidden="true">{notice.text}</span>
|
||||
{#if notice.action}<button class="na" onclick={() => { const f = notice.action.fn; clearNote(); f?.(); }}>{notice.action.label}</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in new issue
Block a user