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 AppBar from '@/components/AppBar.vue'
|
||||||
import ControlsCard from '@/components/ControlsCard.vue'
|
import ControlsCard from '@/components/ControlsCard.vue'
|
||||||
import ErrorBanner from '@/components/ErrorBanner.vue'
|
import ErrorBanner from '@/components/ErrorBanner.vue'
|
||||||
|
import FocusModal from '@/components/FocusModal.vue'
|
||||||
import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
|
import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
|
||||||
import HeroHeadline from '@/components/HeroHeadline.vue'
|
import HeroHeadline from '@/components/HeroHeadline.vue'
|
||||||
import LegendRow from '@/components/LegendRow.vue'
|
import LegendRow from '@/components/LegendRow.vue'
|
||||||
|
|
@ -45,6 +46,7 @@ onMounted(async () => {
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
<FooterDisclaimer />
|
<FooterDisclaimer />
|
||||||
|
<FocusModal v-if="store.state.focusPanelId" />
|
||||||
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
|
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
|
||||||
</template>
|
</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
|
// and Home/End work globally except in text inputs; on the scrubber the
|
||||||
// browser handles them natively.
|
// 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 store = useSimStore()
|
||||||
const playback = usePlayback()
|
const playback = usePlayback()
|
||||||
|
|
||||||
|
|
@ -62,8 +66,12 @@ function onGlobalKeydown(event: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => document.addEventListener('keydown', onGlobalKeydown))
|
onMounted(() => {
|
||||||
onBeforeUnmount(() => document.removeEventListener('keydown', onGlobalKeydown))
|
if (props.globalKeys) document.addEventListener('keydown', onGlobalKeydown)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (props.globalKeys) document.removeEventListener('keydown', onGlobalKeydown)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const failed = computed(() => store.state.failedPanelIds.has(props.panel.id))
|
||||||
const menuItems = computed<PanelMenuItem[]>(() => [
|
const menuItems = computed<PanelMenuItem[]>(() => [
|
||||||
{ key: 'edit', label: 'Edit' },
|
{ key: 'edit', label: 'Edit' },
|
||||||
{ key: 'rename', label: 'Rename' },
|
{ key: 'rename', label: 'Rename' },
|
||||||
|
{ key: 'focus', label: 'Focus' },
|
||||||
{ key: 'duplicate', label: 'Duplicate', disabled: store.state.panels.length >= MAX_PANELS },
|
{ key: 'duplicate', label: 'Duplicate', disabled: store.state.panels.length >= MAX_PANELS },
|
||||||
{ key: 'remove', label: 'Remove', disabled: store.state.panels.length <= 1 },
|
{ key: 'remove', label: 'Remove', disabled: store.state.panels.length <= 1 },
|
||||||
])
|
])
|
||||||
|
|
@ -34,6 +35,9 @@ function onMenuSelect(itemKey: string) {
|
||||||
focusLabelOnOpen.value = itemKey === 'rename'
|
focusLabelOnOpen.value = itemKey === 'rename'
|
||||||
store.state.editingPanelId = props.panel.id
|
store.state.editingPanelId = props.panel.id
|
||||||
break
|
break
|
||||||
|
case 'focus':
|
||||||
|
store.setFocusPanel(props.panel.id)
|
||||||
|
break
|
||||||
case 'duplicate':
|
case 'duplicate':
|
||||||
store.duplicatePanel(props.panel.id)
|
store.duplicatePanel(props.panel.id)
|
||||||
kebabButton.value?.focus()
|
kebabButton.value?.focus()
|
||||||
|
|
@ -136,7 +140,14 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
<span v-for="chip in chips" :key="chip.key" class="chip">{{ chip.text }}</span>
|
<span v-for="chip in chips" :key="chip.key" class="chip">{{ chip.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="netbtn"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Enlarge ${panel.label}`"
|
||||||
|
@click="store.setFocusPanel(panel.id)"
|
||||||
|
>
|
||||||
<NetworkView :panel="panel" />
|
<NetworkView :panel="panel" />
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="skeleton" aria-hidden="true">
|
<div class="skeleton" aria-hidden="true">
|
||||||
|
|
@ -273,6 +284,16 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.netbtn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: zoom-in;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 550;
|
font-weight: 550;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue