11<script setup lang="ts" generic =" PinboardLocation extends BasicLocation " >
22// vue imports
3- import { inject , ref , computed , watch , toRef } from ' vue'
3+ import { inject , ref , computed , watch , toRef , nextTick } from ' vue'
44import { useRoute , useRouter } from ' vue-router'
55import { useI18n } from ' vue-i18n'
66
@@ -216,8 +216,57 @@ watch(
216216 { immediate: true }
217217)
218218
219+ // --- detail panel focus management ---
220+ // The panel opens over the card that triggered it, so keyboard focus has to be
221+ // moved into it on open and handed back to the card on close. On desktop it is
222+ // also trapped inside while open. Content comes from the location-detail slot,
223+ // so the panel finds its heading and controls by query rather than by ref.
224+ const detailPanelRef = ref <HTMLElement | null >(null )
225+ let detailOpener: HTMLElement | null = null
226+
227+ const DETAIL_FOCUSABLE =
228+ ' a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
229+
230+ // Prefer the site-name heading so a screen reader announces which location this
231+ // is; fall back to the first control. The heading is not otherwise focusable, so
232+ // it takes tabindex=-1, and its id labels the dialog.
233+ function focusDetailPanel() {
234+ nextTick (() => {
235+ const root = detailPanelRef .value
236+ if (! root ) return
237+ const heading = root .querySelector <HTMLElement >(' h1, h2, h3' )
238+ if (heading ) {
239+ heading .id = ' pinboard-detail-heading'
240+ heading .setAttribute (' tabindex' , ' -1' )
241+ heading .focus ()
242+ } else {
243+ root .querySelector <HTMLElement >(DETAIL_FOCUSABLE )?.focus ()
244+ }
245+ })
246+ }
247+
248+ // Desktop trap: a guard brackets each end of the panel. Focusing one means Tab or
249+ // Shift+Tab is on its way out, so send focus to the far end instead. The guards
250+ // are themselves tabbable and must be excluded here, or wrapping to "the last
251+ // focusable" would land on the other guard and bounce between the two.
252+ function detailFocusables(): HTMLElement [] {
253+ const root = detailPanelRef .value
254+ if (! root ) return []
255+ return Array .from (root .querySelectorAll <HTMLElement >(DETAIL_FOCUSABLE )).filter (
256+ (el ) => ! el .classList .contains (' detail-focus-guard' )
257+ )
258+ }
259+ function onDetailGuardStart() {
260+ const els = detailFocusables ()
261+ els [els .length - 1 ]?.focus ()
262+ }
263+ function onDetailGuardEnd() {
264+ const els = detailFocusables ()
265+ els [0 ]?.focus ()
266+ }
267+
219268// watchers
220- watch (selectedLocation , (loc ) => {
269+ watch (selectedLocation , (loc , prev ) => {
221270 if (loc ) {
222271 // Mutual exclusion: the detail panel and the filter panel share the left
223272 // slot, so selecting a location closes the filter panel. Covers every
@@ -226,6 +275,13 @@ watch(selectedLocation, (loc) => {
226275 if (props .isMobile ) {
227276 bottomSheetRef .value ?.snapTo (snapPoints .length - 1 )
228277 }
278+ // Capture the card that opened the panel on first open only, so swapping
279+ // straight to another location still returns focus to where it started.
280+ if (! prev ) detailOpener = document .activeElement as HTMLElement | null
281+ focusDetailPanel ()
282+ } else if (prev ) {
283+ if (detailOpener && document .body .contains (detailOpener )) detailOpener .focus ()
284+ detailOpener = null
229285 }
230286})
231287
@@ -536,13 +592,32 @@ function selectedLocationValue(): PinboardLocation {
536592
537593 <div v-if =" selectedLocation" >
538594 <Teleport to="#detail-overlay-desktop " :disabled =" isMobile " >
539- <div :class =" isMobile ? 'bottom-sheet-detail' : 'detail-overlay'" >
595+ <div
596+ ref =" detailPanelRef"
597+ :class =" isMobile ? 'bottom-sheet-detail' : 'detail-overlay'"
598+ role =" dialog"
599+ :aria-modal =" isMobile ? undefined : 'true'"
600+ aria-labelledby =" pinboard-detail-heading"
601+ @keydown.esc =" handleCloseLocationDetail"
602+ >
603+ <span
604+ v-if =" !isMobile"
605+ class =" detail-focus-guard"
606+ tabindex =" 0"
607+ @focus =" onDetailGuardStart"
608+ />
540609 <slot
541610 name =" location-detail"
542611 :location =" selectedLocationValue()"
543612 :on-close =" handleCloseLocationDetail"
544613 :on-print =" isMobile ? undefined : () => print(selectedLocationValue())"
545614 />
615+ <span
616+ v-if =" !isMobile"
617+ class =" detail-focus-guard"
618+ tabindex =" 0"
619+ @focus =" onDetailGuardEnd"
620+ />
546621 </div >
547622 </Teleport >
548623 </div >
@@ -691,6 +766,16 @@ function selectedLocationValue(): PinboardLocation {
691766 max-width : 100% ;
692767}
693768
769+ /* Bracketing tab stops for the desktop focus trap: reachable by Tab but visually
770+ absent. Focusing one wraps focus back into the panel (see onDetailGuard*). */
771+ .detail-focus-guard {
772+ position : absolute ;
773+ width : 1px ;
774+ height : 1px ;
775+ overflow : hidden ;
776+ clip-path : inset (50% );
777+ }
778+
694779.bottom-sheet-detail :deep(img ) {
695780 max-width : 100% ;
696781 height : auto ;
0 commit comments