feat: sign interlude, window plate and a two-button control bay
The sign closes in over the road on a gantry truss, arcs into a wider window and hands off to a flat 2D plate that mirrors its face; content steps aside for the interlude and rises after. The bay keeps sound and settings; volume and Pause motion move into the settings popover.
This commit is contained in:
@@ -40,9 +40,8 @@ const emit = defineEmits<{
|
||||
|
||||
const controlRoot = ref<HTMLElement | null>(null);
|
||||
const settingsButton = ref<HTMLButtonElement | null>(null);
|
||||
const volumeButton = ref<HTMLButtonElement | null>(null);
|
||||
const settingsPanel = ref<HTMLElement | null>(null);
|
||||
const settingsOpen = ref(false);
|
||||
const volumeOpen = ref(false);
|
||||
|
||||
const action = computed(() => {
|
||||
if (props.policy.reason === "visitor-pause") {
|
||||
@@ -123,21 +122,28 @@ const changeVolume = (event: Event): void => {
|
||||
|
||||
const toggleSettings = (): void => {
|
||||
settingsOpen.value = !settingsOpen.value;
|
||||
volumeOpen.value = false;
|
||||
};
|
||||
|
||||
const toggleVolume = (): void => {
|
||||
volumeOpen.value = !volumeOpen.value;
|
||||
settingsOpen.value = false;
|
||||
};
|
||||
|
||||
const closePanels = (restoreFocus = false): void => {
|
||||
const focusTarget = settingsOpen.value
|
||||
? settingsButton.value
|
||||
: volumeButton.value;
|
||||
const wasOpen = settingsOpen.value;
|
||||
settingsOpen.value = false;
|
||||
volumeOpen.value = false;
|
||||
if (restoreFocus) void nextTick(() => focusTarget?.focus());
|
||||
if (restoreFocus && wasOpen) void nextTick(() => settingsButton.value?.focus());
|
||||
};
|
||||
|
||||
/** Tab and Shift+Tab cycle inside the open settings panel and its trigger. */
|
||||
const trapFocus = (event: KeyboardEvent): void => {
|
||||
if (event.key !== "Tab" || !settingsOpen.value) return;
|
||||
const panel = settingsPanel.value;
|
||||
if (!panel || !settingsButton.value) return;
|
||||
const stops = [
|
||||
settingsButton.value,
|
||||
...panel.querySelectorAll<HTMLElement>("button, input, select"),
|
||||
];
|
||||
const index = stops.indexOf(document.activeElement as HTMLElement);
|
||||
if (index === -1) return;
|
||||
const next = (index + (event.shiftKey ? -1 : 1) + stops.length) % stops.length;
|
||||
event.preventDefault();
|
||||
stops[next]?.focus();
|
||||
};
|
||||
|
||||
const onDocumentPointerDown = (event: Event): void => {
|
||||
@@ -146,12 +152,22 @@ const onDocumentPointerDown = (event: Event): void => {
|
||||
};
|
||||
|
||||
const onDocumentKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== "Escape" || (!settingsOpen.value && !volumeOpen.value))
|
||||
return;
|
||||
trapFocus(event);
|
||||
if (event.key !== "Escape" || !settingsOpen.value) return;
|
||||
event.preventDefault();
|
||||
closePanels(true);
|
||||
};
|
||||
|
||||
// Opening the panel moves focus into it; the trap keeps it there.
|
||||
watch(settingsOpen, (open) => {
|
||||
if (open)
|
||||
void nextTick(() =>
|
||||
settingsPanel.value
|
||||
?.querySelector<HTMLElement>("input, button, select")
|
||||
?.focus(),
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.audioState.kind,
|
||||
() => closePanels(),
|
||||
@@ -173,28 +189,33 @@ onBeforeUnmount(() => {
|
||||
ref="controlRoot"
|
||||
class="drive-controls"
|
||||
:data-drive-state="audioState.kind"
|
||||
:data-muted="audioMuted"
|
||||
>
|
||||
<!-- Two monochrome bay buttons: the soundtrack (an equaliser that moves
|
||||
while it plays) and the settings. Each is a 44 px target drawn as a
|
||||
36 px tile. -->
|
||||
<button
|
||||
v-if="audioState.kind === 'idle'"
|
||||
class="drive-start"
|
||||
class="drive-start drive-control-button"
|
||||
type="button"
|
||||
aria-label="Play soundtrack"
|
||||
data-tooltip="Play soundtrack"
|
||||
@click="emit('audioStart')"
|
||||
>
|
||||
<IconGlyph name="play" />
|
||||
<span>PLAY SOUNDTRACK</span>
|
||||
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="visually-hidden">PLAY SOUNDTRACK</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="audioState.kind === 'starting'"
|
||||
class="drive-start"
|
||||
class="drive-start drive-control-button"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="Starting drive"
|
||||
aria-live="polite"
|
||||
>
|
||||
<IconGlyph name="play" />
|
||||
<span>STARTING…</span>
|
||||
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="visually-hidden">STARTING…</span>
|
||||
</button>
|
||||
|
||||
<p
|
||||
@@ -225,45 +246,20 @@ onBeforeUnmount(() => {
|
||||
<IconGlyph name="play" />
|
||||
</button>
|
||||
<button
|
||||
class="drive-control-button"
|
||||
v-else
|
||||
class="drive-control-button drive-sound"
|
||||
type="button"
|
||||
:aria-label="muteLabel"
|
||||
:title="muteLabel"
|
||||
:data-tooltip="muteLabel"
|
||||
@click="emit('audioMuteToggle')"
|
||||
>
|
||||
<IconGlyph :name="muteIcon" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref="volumeButton"
|
||||
class="drive-control-button"
|
||||
type="button"
|
||||
aria-label="Sound volume"
|
||||
title="Sound volume"
|
||||
data-tooltip="Sound volume"
|
||||
aria-controls="drive-volume-panel"
|
||||
:aria-expanded="volumeOpen"
|
||||
@click="toggleVolume"
|
||||
>
|
||||
<IconGlyph name="volume" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="action"
|
||||
class="drive-control-button"
|
||||
type="button"
|
||||
:aria-label="action.label"
|
||||
:title="action.label"
|
||||
:data-tooltip="action.label"
|
||||
@click="runAction"
|
||||
>
|
||||
<IconGlyph :name="action.icon" />
|
||||
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="action && !isAudioActive && action.event !== 'pause'"
|
||||
v-if="action && action.event !== 'pause'"
|
||||
class="drive-control-button"
|
||||
type="button"
|
||||
:aria-label="action.label"
|
||||
@@ -297,35 +293,41 @@ onBeforeUnmount(() => {
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="volumeOpen && isAudioActive"
|
||||
id="drive-volume-panel"
|
||||
class="drive-popover drive-popover--volume"
|
||||
role="dialog"
|
||||
aria-label="Sound volume"
|
||||
>
|
||||
<label for="drive-volume">SOUND</label>
|
||||
<input
|
||||
id="drive-volume"
|
||||
aria-label="Sound volume level"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
:value="audioVolume"
|
||||
@input="changeVolume"
|
||||
/>
|
||||
<output for="drive-volume">{{ Math.round(audioVolume * 100) }}%</output>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settingsOpen"
|
||||
id="drive-settings-panel"
|
||||
ref="settingsPanel"
|
||||
class="drive-popover drive-popover--settings"
|
||||
role="dialog"
|
||||
aria-label="Drive settings"
|
||||
>
|
||||
<strong>DRIVE SETTINGS</strong>
|
||||
<template v-if="isAudioActive">
|
||||
<label for="drive-volume">SOUND</label>
|
||||
<div class="drive-popover__volume">
|
||||
<input
|
||||
id="drive-volume"
|
||||
aria-label="Sound volume level"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
:value="audioVolume"
|
||||
@input="changeVolume"
|
||||
/>
|
||||
<output for="drive-volume">{{ Math.round(audioVolume * 100) }}%</output>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-if="action && action.event === 'pause'"
|
||||
class="drive-popover__action"
|
||||
type="button"
|
||||
:aria-label="action.label"
|
||||
@click="runAction"
|
||||
>
|
||||
<IconGlyph :name="action.icon" />
|
||||
<span aria-hidden="true">PAUSE MOTION</span>
|
||||
</button>
|
||||
<label for="drive-quality">QUALITY</label>
|
||||
<select
|
||||
id="drive-quality"
|
||||
|
||||
Reference in New Issue
Block a user