M12 — the Android client, end to end #2
@@ -210,6 +210,15 @@ async function signOut() {
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-full flex-col">
|
||||
<!-- First tab stop on every page. The header and sidebar are a dozen-odd tab
|
||||
stops that repeat on every navigation; without this a keyboard user walks
|
||||
all of them again to reach their own notes. Hidden until focused. -->
|
||||
<a
|
||||
href="#main"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:left-3 focus:top-3 focus:z-50 focus:rounded-md focus:bg-white focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-brand dark:focus:bg-neutral-900"
|
||||
>
|
||||
Skip to notes
|
||||
</a>
|
||||
<header
|
||||
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
|
||||
>
|
||||
@@ -416,7 +425,10 @@ async function signOut() {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="min-w-0 flex-1"><RouterView /></main>
|
||||
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
|
||||
the viewport — several browsers scroll to a plain anchor without focusing
|
||||
it, which leaves the next Tab back at the top of the page. -->
|
||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none"><RouterView /></main>
|
||||
</div>
|
||||
|
||||
<LabelsModal v-if="managing" @close="managing = false" />
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
// The backdrop + centered dialog panel every modal shares: a fixed dimmed overlay and
|
||||
// a bordered, rounded panel with role="dialog". Emits `close` on Escape (while focus is
|
||||
// inside the panel — matching the app's existing modals) and on a backdrop pointer-down
|
||||
// (mousedown outside the panel). The caller sizes/shadows/pads the panel via `panelClass`
|
||||
// and fills it (its own header + content) through the default slot.
|
||||
// a bordered, rounded panel with role="dialog". Emits `close` on Escape and on a
|
||||
// backdrop pointer-down (mousedown outside the panel). The caller sizes/shadows/pads
|
||||
// the panel via `panelClass` and fills it (its own header + content) through the
|
||||
// default slot.
|
||||
//
|
||||
// Owns the dialog's focus contract on behalf of all four callers: focus moves in on
|
||||
// open, Tab is trapped inside, and focus returns to the opener on close. Doing it
|
||||
// here rather than per-modal is why CommandPalette's own input focus is the only
|
||||
// bespoke focus handling left.
|
||||
//
|
||||
// NoteEditor deliberately does NOT use this — its backdrop mousedown is drag-guarded and
|
||||
// its Escape/⌘-Enter handling is bespoke (unsaved-edit safety), so it keeps its own shell.
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** Panel size / shadow / padding classes (structure — rounded/border/bg — is fixed). */
|
||||
@@ -19,6 +26,67 @@ withDefaults(
|
||||
{ panelClass: "w-full max-w-sm shadow-xl", align: "start" },
|
||||
);
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
const panel = ref<HTMLElement | null>(null);
|
||||
/** Whatever had focus before we opened, so closing can hand it back. */
|
||||
let restoreTo: HTMLElement | null = null;
|
||||
|
||||
const FOCUSABLE = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(",");
|
||||
|
||||
/** Tabbable descendants, in document order, skipping anything not rendered. */
|
||||
function focusable(): HTMLElement[] {
|
||||
if (!panel.value) return [];
|
||||
return Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep Tab inside the panel.
|
||||
*
|
||||
* `aria-modal="true"` tells assistive tech the rest of the page is inert, but it
|
||||
* doesn't enforce anything — without this, Tab walks straight out into content the
|
||||
* user has just been told isn't there.
|
||||
*/
|
||||
function onTab(e: KeyboardEvent): void {
|
||||
const items = focusable();
|
||||
if (!items.length) {
|
||||
// Nothing to land on: hold focus on the panel rather than releasing it.
|
||||
e.preventDefault();
|
||||
panel.value?.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && (active === first || active === panel.value)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
restoreTo = document.activeElement as HTMLElement | null;
|
||||
// The fix for more than tab order: Escape is handled ON the panel, so until
|
||||
// focus is actually inside it, Escape did nothing at all in every modal that
|
||||
// doesn't focus its own input (LabelsModal, the integration prompt, shortcuts).
|
||||
const target = focusable()[0] ?? panel.value;
|
||||
target?.focus();
|
||||
});
|
||||
|
||||
// Focus would otherwise fall back to <body>, so the next Tab restarts from the top
|
||||
// of the page rather than from whatever the user was working on.
|
||||
onBeforeUnmount(() => restoreTo?.focus?.());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,13 +95,19 @@ const emit = defineEmits<{ (e: "close"): void }>();
|
||||
:class="align === 'center' ? 'items-center' : 'items-start pt-[12vh]'"
|
||||
@mousedown.self="emit('close')"
|
||||
>
|
||||
<!-- tabindex="-1" so the panel itself can hold focus when it contains nothing
|
||||
focusable — without it, focus would stay behind the overlay and Escape,
|
||||
which is handled here, would never fire. -->
|
||||
<div
|
||||
class="rounded-xl border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900"
|
||||
ref="panel"
|
||||
class="rounded-xl border border-neutral-200 bg-white focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
|
||||
:class="panelClass"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
:aria-label="ariaLabel"
|
||||
@keydown.esc="emit('close')"
|
||||
@keydown.tab="onTab"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user