web: focus modal, slice 8 of M3
Clicking a panel's network (cursor zoom-in, a real button for keyboard users) or its menu's Focus item opens the modal: the same SVG at the same global round rendered up to 90vw/85vh over the spec scrim, with a second PlayerBar docked in the footer sharing the store. Only the page PlayerBar owns the global keyboard map, so keys are never handled twice. role=dialog with aria-modal, labelled by the panel name, focus moves in and is trapped, Esc/X/scrim close, and focus returns to the opener. focusPanelId already lives in the URL, so shared links open pre-focused; on mobile the modal becomes a full-screen sheet.
This commit is contained in:
parent
eb1f7427c4
commit
8929eeddb0
4 changed files with 260 additions and 3 deletions
|
|
@ -3,6 +3,7 @@ import { onMounted } from 'vue'
|
|||
import AppBar from '@/components/AppBar.vue'
|
||||
import ControlsCard from '@/components/ControlsCard.vue'
|
||||
import ErrorBanner from '@/components/ErrorBanner.vue'
|
||||
import FocusModal from '@/components/FocusModal.vue'
|
||||
import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
|
||||
import HeroHeadline from '@/components/HeroHeadline.vue'
|
||||
import LegendRow from '@/components/LegendRow.vue'
|
||||
|
|
@ -45,6 +46,7 @@ onMounted(async () => {
|
|||
/>
|
||||
</main>
|
||||
<FooterDisclaimer />
|
||||
<FocusModal v-if="store.state.focusPanelId" />
|
||||
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
226
web/src/components/FocusModal.vue
Normal file
226
web/src/components/FocusModal.vue
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import NetworkView from './NetworkView.vue'
|
||||
import PlayerBar from './PlayerBar.vue'
|
||||
import { useSimStore } from '@/composables/useSimStore'
|
||||
import { accentForPanel } from '@/lib/accents'
|
||||
import { roundPct } from '@/lib/format'
|
||||
import { reachedCountAtRound } from '@/lib/reach'
|
||||
|
||||
// One panel rendered large (spec 5.8): same SVG, same global round, with
|
||||
// a PlayerBar docked at the modal footer (both bars share the store, so
|
||||
// they stay in sync). focusPanelId is URL state, so a shared link opens
|
||||
// pre-focused. Focus is trapped; Esc, the X, and the scrim close it.
|
||||
|
||||
const store = useSimStore()
|
||||
|
||||
const panelIndex = computed(() =>
|
||||
store.state.panels.findIndex((panel) => panel.id === store.state.focusPanelId),
|
||||
)
|
||||
const panel = computed(() =>
|
||||
panelIndex.value >= 0 ? store.state.panels[panelIndex.value] : undefined,
|
||||
)
|
||||
const result = computed(() =>
|
||||
panel.value ? store.state.resultsByPanelId[panel.value.id] : undefined,
|
||||
)
|
||||
const numStudents = computed(() =>
|
||||
panel.value ? store.effectiveConfig(panel.value).numStudents : 0,
|
||||
)
|
||||
const reachedCount = computed(() =>
|
||||
result.value ? reachedCountAtRound(result.value.reachedAtRound, store.state.round) : 0,
|
||||
)
|
||||
const reachedPctNow = computed(() => (reachedCount.value / Math.max(numStudents.value, 1)) * 100)
|
||||
const tone = computed(() =>
|
||||
roundPct(reachedPctNow.value) <= store.preset.toneThresholdPct ? 'good' : 'bad',
|
||||
)
|
||||
|
||||
const modal = ref<HTMLElement | null>(null)
|
||||
let openerElement: HTMLElement | null = null
|
||||
|
||||
function close() {
|
||||
store.setFocusPanel(null)
|
||||
}
|
||||
|
||||
function focusableElements(): HTMLElement[] {
|
||||
const selector =
|
||||
'button:not(:disabled), [href], input:not(:disabled), select, [tabindex]:not([tabindex="-1"])'
|
||||
return Array.from(modal.value?.querySelectorAll<HTMLElement>(selector) ?? [])
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
const elements = focusableElements()
|
||||
const first = elements[0]
|
||||
const last = elements[elements.length - 1]
|
||||
if (!first || !last) return
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
openerElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
focusableElements()[0]?.focus()
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = 'hidden'
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = ''
|
||||
openerElement?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="panel" class="scrim" @click.self="close">
|
||||
<div ref="modal" class="modal" role="dialog" aria-modal="true" :aria-label="panel.label">
|
||||
<div class="head">
|
||||
<span
|
||||
class="swatch"
|
||||
:style="{ background: accentForPanel(panelIndex) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="label">{{ panel.label }}</span>
|
||||
<span v-if="result" class="stat" :class="tone">
|
||||
{{ roundPct(reachedPctNow) }}%
|
||||
<span class="stat-detail">{{ reachedCount }} of {{ numStudents }} reached</span>
|
||||
</span>
|
||||
<button class="close" type="button" aria-label="Close" @click="close">
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<NetworkView :panel="panel" class="big-net" />
|
||||
<div class="dock">
|
||||
<PlayerBar :global-keys="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(1100px, 90vw);
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lift);
|
||||
padding: 18px 22px 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--ink);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat.bad {
|
||||
color: var(--spread);
|
||||
}
|
||||
|
||||
.stat.good {
|
||||
color: var(--edu);
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-4);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 10px;
|
||||
color: var(--ink-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.big-net {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dock {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dock :deep(.playerwrap) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.scrim {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Inside the full-screen sheet the docked player keeps its fixed
|
||||
bottom position from the mobile player styles; that is the design. */
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,6 +9,10 @@ import { useSimStore } from '@/composables/useSimStore'
|
|||
// and Home/End work globally except in text inputs; on the scrubber the
|
||||
// browser handles them natively.
|
||||
|
||||
// The modal docks a second PlayerBar over the same store; only the page
|
||||
// instance owns the global keyboard map so keys are not handled twice.
|
||||
const props = withDefaults(defineProps<{ globalKeys?: boolean }>(), { globalKeys: true })
|
||||
|
||||
const store = useSimStore()
|
||||
const playback = usePlayback()
|
||||
|
||||
|
|
@ -62,8 +66,12 @@ function onGlobalKeydown(event: KeyboardEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onGlobalKeydown))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onGlobalKeydown))
|
||||
onMounted(() => {
|
||||
if (props.globalKeys) document.addEventListener('keydown', onGlobalKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (props.globalKeys) document.removeEventListener('keydown', onGlobalKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const failed = computed(() => store.state.failedPanelIds.has(props.panel.id))
|
|||
const menuItems = computed<PanelMenuItem[]>(() => [
|
||||
{ key: 'edit', label: 'Edit' },
|
||||
{ key: 'rename', label: 'Rename' },
|
||||
{ key: 'focus', label: 'Focus' },
|
||||
{ key: 'duplicate', label: 'Duplicate', disabled: store.state.panels.length >= MAX_PANELS },
|
||||
{ key: 'remove', label: 'Remove', disabled: store.state.panels.length <= 1 },
|
||||
])
|
||||
|
|
@ -34,6 +35,9 @@ function onMenuSelect(itemKey: string) {
|
|||
focusLabelOnOpen.value = itemKey === 'rename'
|
||||
store.state.editingPanelId = props.panel.id
|
||||
break
|
||||
case 'focus':
|
||||
store.setFocusPanel(props.panel.id)
|
||||
break
|
||||
case 'duplicate':
|
||||
store.duplicatePanel(props.panel.id)
|
||||
kebabButton.value?.focus()
|
||||
|
|
@ -136,7 +140,14 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
|
|||
<div class="chips">
|
||||
<span v-for="chip in chips" :key="chip.key" class="chip">{{ chip.text }}</span>
|
||||
</div>
|
||||
<NetworkView :panel="panel" />
|
||||
<button
|
||||
class="netbtn"
|
||||
type="button"
|
||||
:aria-label="`Enlarge ${panel.label}`"
|
||||
@click="store.setFocusPanel(panel.id)"
|
||||
>
|
||||
<NetworkView :panel="panel" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="skeleton" aria-hidden="true">
|
||||
|
|
@ -273,6 +284,16 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.netbtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: zoom-in;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 11.5px;
|
||||
font-weight: 550;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue