M12 — the Android client, end to end #2
@@ -210,6 +210,15 @@ async function signOut() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-full flex-col">
|
<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
|
<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"
|
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>
|
</nav>
|
||||||
</aside>
|
</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>
|
</div>
|
||||||
|
|
||||||
<LabelsModal v-if="managing" @close="managing = false" />
|
<LabelsModal v-if="managing" @close="managing = false" />
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// The backdrop + centered dialog panel every modal shares: a fixed dimmed overlay and
|
// 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
|
// a bordered, rounded panel with role="dialog". Emits `close` on Escape and on a
|
||||||
// inside the panel — matching the app's existing modals) and on a backdrop pointer-down
|
// backdrop pointer-down (mousedown outside the panel). The caller sizes/shadows/pads
|
||||||
// (mousedown outside the panel). The caller sizes/shadows/pads the panel via `panelClass`
|
// the panel via `panelClass` and fills it (its own header + content) through the
|
||||||
// and fills it (its own header + content) through the default slot.
|
// 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
|
// 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.
|
// its Escape/⌘-Enter handling is bespoke (unsaved-edit safety), so it keeps its own shell.
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** Panel size / shadow / padding classes (structure — rounded/border/bg — is fixed). */
|
/** 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" },
|
{ panelClass: "w-full max-w-sm shadow-xl", align: "start" },
|
||||||
);
|
);
|
||||||
const emit = defineEmits<{ (e: "close"): void }>();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -27,13 +95,19 @@ const emit = defineEmits<{ (e: "close"): void }>();
|
|||||||
:class="align === 'center' ? 'items-center' : 'items-start pt-[12vh]'"
|
:class="align === 'center' ? 'items-center' : 'items-start pt-[12vh]'"
|
||||||
@mousedown.self="emit('close')"
|
@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
|
<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"
|
:class="panelClass"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
:aria-label="ariaLabel"
|
:aria-label="ariaLabel"
|
||||||
@keydown.esc="emit('close')"
|
@keydown.esc="emit('close')"
|
||||||
|
@keydown.tab="onTab"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user