Wall of People Groups: names open the group's prayer page, and the prayer and comment dialogs close on Escape and keep focus inside

This commit is contained in:
Doxa Maps 2026-09-28 19:58:12 +00:00
parent 84c745c44e
commit ef7325990a
4 changed files with 51 additions and 6 deletions

View file

@ -1,6 +1,6 @@
<template>
<div v-if="visible" class="dialog-overlay" @click="closeDialog">
<div class="dialog-container" @click.stop>
<div class="dialog-container" @click.stop ref="dialogBox" role="dialog" aria-modal="true" :aria-label="$t('wall.chatsTitle')" tabindex="-1">
<!-- Dialog Header -->
<div class="dialog-header">
<h2>{{ $t('wall.chatsTitle') }}</h2>
@ -101,6 +101,7 @@
</template>
<script>
import { trapDialog } from '../trapDialog.js'
// No third-party placeholder service: a missing photo is drawn inline.
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
// Comments and Prayers Dialog Component
@ -136,6 +137,7 @@ export default {
return this.$store.comments?.[this.group.ID] || [];
}
},
beforeUnmount() { this._releaseTrap?.(); this._releaseTrap = null; },
methods: {
closeDialog() {
this.$emit('close');
@ -203,6 +205,8 @@ export default {
},
watch: {
visible(newVal) {
if (newVal) this.$nextTick(() => { this._releaseTrap?.(); this._releaseTrap = trapDialog(this.$refs.dialogBox, () => this.closeDialog()); });
else { this._releaseTrap?.(); this._releaseTrap = null; }
if (newVal) {
// Reset state when dialog opens
this.justPrayed = false;

View file

@ -1,6 +1,6 @@
<template>
<div v-if="isOpen" class="dialog-overlay" @click.self="closeDialog">
<div class="dialog-container prayer-dialog">
<div class="dialog-container prayer-dialog" ref="dialogBox" role="dialog" aria-modal="true" :aria-label="$t('wall.prayerTools')" tabindex="-1">
<div class="dialog-header">
<h2>{{ $t('wall.prayerTools') }}</h2>
<button @click="closeDialog" class="close-btn" :aria-label="$t('wall.close')">&times;</button>
@ -62,6 +62,7 @@
</template>
<script>
import { trapDialog } from '../trapDialog.js'
// No third-party placeholder service: a missing photo is drawn inline.
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
export default {
@ -85,6 +86,8 @@ export default {
},
watch: {
isOpen(newVal) {
if (newVal) this.$nextTick(() => { this._releaseTrap?.(); this._releaseTrap = trapDialog(this.$refs.dialogBox, () => this.closeDialog()); });
else { this._releaseTrap?.(); this._releaseTrap = null; }
if (newVal) {
// Check if user already prayed for this group
// Access the prayers state directly
@ -95,6 +98,7 @@ export default {
}
}
},
beforeUnmount() { this._releaseTrap?.(); this._releaseTrap = null; },
methods: {
closeDialog() {
this.$emit('close');

View file

@ -0,0 +1,30 @@
/**
* trapDialog.js — make a Wall dialog a real dialog for the keyboard: focus moves into it, Tab stays
* inside it, Escape closes it, and focus returns to where it was when it closes. Listens on the dialog
* itself (never on document or window), so releasing it leaves nothing behind.
*
* const release = trapDialog(containerEl, onClose) … release()
*/
const FOCUSABLE = 'button:not([disabled]),[href],input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'
export function trapDialog(container, onClose) {
if (!container) return () => {}
const root = container.getRootNode ? container.getRootNode() : document
const prev = root.activeElement || document.activeElement
const focusables = () => [...container.querySelectorAll(FOCUSABLE)]
const onKey = (e) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); return }
if (e.key !== 'Tab') return
const f = focusables()
if (!f.length) { e.preventDefault(); return }
const active = root.activeElement
if (e.shiftKey && active === f[0]) { e.preventDefault(); f[f.length - 1].focus() }
else if (!e.shiftKey && active === f[f.length - 1]) { e.preventDefault(); f[0].focus() }
}
container.addEventListener('keydown', onKey)
;(focusables()[0] || container).focus()
return () => {
container.removeEventListener('keydown', onKey)
try { prev && prev.focus && prev.focus() } catch (_) { /* the opener is gone */ }
}
}

View file

@ -23,6 +23,13 @@ const ICON = {
comment: 'M4 4.5h16v11.5H9.5L4 20.5z',
plane: 'M21 3.5 3 10.8l7 2.7 2.7 7 8.3-17z',
}
// Where a group's name links: its prayer page on pray.doxa.life (the live data has a slug), else its
// Joshua Project page (the CSV data has a PeopleID3), else nowhere.
export function groupLink(g) {
if (g && g.slug) return `https://pray.doxa.life/${encodeURIComponent(g.slug)}?source=doxalife`
const id = g && (g.peopleID3 || g.PeopleID3)
return id && String(id).trim() ? `https://joshuaproject.net/people_groups/${String(id).trim()}` : ''
}
const TEXT_PX = 40, IMG_PX = 48, RECT_PX = 12, IMG_CACHE = 400, IMG_PARALLEL = 12
export class WallCanvas {
@ -155,7 +162,7 @@ export class WallCanvas {
const nameSize = Math.max(3.5, w * 0.07)
ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'
ctx.font = `700 ${nameSize}px -apple-system, "Segoe UI", Arial, sans-serif`
ctx.fillStyle = (g.peopleID3 || g.PeopleID3) ? '#0095f6' : '#262626'
ctx.fillStyle = groupLink(g) ? '#0095f6' : '#262626'
const name = g.name || g.post_title || this.o.labels('wall.unknownGroup')
const lines = ctx.measureText(name).width > w * 0.95 ? wrapText(name, Math.max(6, Math.floor(name.length / 2) + 2), 2) : [name]
lines.forEach((ln, k) => ctx.fillText(ln, x + w / 2, y + h * 0.06 + k * nameSize * 1.1, w * 0.96))
@ -220,7 +227,7 @@ export class WallCanvas {
onMove(e) {
const h = this.hit(e.offsetX, e.offsetY)
const g = h && this.groups[h.i]
const link = h && h.region === 'name' && g && (g.peopleID3 || g.PeopleID3)
const link = h && h.region === 'name' && groupLink(g)
this.canvas.style.cursor = h && (link || ['heart', 'comment', 'share', 'view'].includes(h.region)) ? 'pointer' : ''
}
@ -230,8 +237,8 @@ export class WallCanvas {
if (!h) return
const g = this.groups[h.i]
if (h.region === 'name') {
const id = g.peopleID3 || g.PeopleID3
if (id && String(id).trim()) window.open(`https://joshuaproject.net/people_groups/${id}`, '_blank', 'noopener,noreferrer')
const url = groupLink(g)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
} else if (h.region === 'heart') this.o.onPray(g)
else if (h.region === 'comment' || h.region === 'view') this.o.onComments(g)
else if (h.region === 'share') this.o.onShare(g)