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:
parent
84c745c44e
commit
ef7325990a
4 changed files with 51 additions and 6 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')">×</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');
|
||||
|
|
|
|||
30
plugin-designs/wall-of-people-groups/src/trapDialog.js
Normal file
30
plugin-designs/wall-of-people-groups/src/trapDialog.js
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue