mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-12 20:55:41 +08:00
Group readback label and field so they wrap together
Detlef reported that the prompt for a blank (e.g. "runway") often ended up on the line above the input itself, forcing him to look back up to remember what he was typing. Each preceding text segment is now paired with its following input field into a `.cloze-group`. The group uses `inline-flex; flex-wrap: nowrap` so its label and input stay on one line, while the outer `.cloze` container still wraps groups normally. On narrow viewports (<640px) the group falls back to wrapping internally so it never overflows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1058,38 +1058,40 @@
|
|||||||
<div class="label">Your readback</div>
|
<div class="label">Your readback</div>
|
||||||
<div class="panel readback-panel">
|
<div class="panel readback-panel">
|
||||||
<div class="cloze">
|
<div class="cloze">
|
||||||
<template v-for="(segment, idx) in activeLesson.readback"
|
<div v-for="group in clozeGroups" :key="group.id" class="cloze-group">
|
||||||
:key="segment.type === 'field' ? `f-${segment.key}` : `t-${idx}`">
|
<template v-for="(segment, idx) in group.segments"
|
||||||
<span v-if="segment.type === 'text'" class="cloze-chunk cloze-text">
|
:key="segment.type === 'field' ? `f-${segment.key}` : `${group.id}-t-${idx}`">
|
||||||
{{
|
<span v-if="segment.type === 'text'" class="cloze-chunk cloze-text">
|
||||||
displayCallsign(typeof segment.text === 'function' ? (scenario ? segment.text(scenario) : '') : segment.text)
|
{{
|
||||||
}}
|
displayCallsign(typeof segment.text === 'function' ? (scenario ? segment.text(scenario) : '') : segment.text)
|
||||||
</span>
|
}}
|
||||||
<label
|
</span>
|
||||||
v-else
|
<label
|
||||||
class="blank cloze-chunk"
|
v-else
|
||||||
:class="[blankSizeClass(segment.key, segment.width), blankStateClass(segment.key)]"
|
class="blank cloze-chunk"
|
||||||
>
|
:class="[blankSizeClass(segment.key, segment.width), blankStateClass(segment.key)]"
|
||||||
<span class="sr-only">{{ fieldLabel(segment.key) }}</span>
|
>
|
||||||
<input
|
<span class="sr-only">{{ fieldLabel(segment.key) }}</span>
|
||||||
v-model="userAnswers[segment.key]"
|
<input
|
||||||
:aria-label="fieldLabel(segment.key)"
|
v-model="userAnswers[segment.key]"
|
||||||
:placeholder="fieldPlaceholder(segment.key)"
|
:aria-label="fieldLabel(segment.key)"
|
||||||
:inputmode="fieldInputmode(segment.key)"
|
:placeholder="fieldPlaceholder(segment.key)"
|
||||||
:ref="el => assignReadbackFieldRef(segment.key, el as HTMLInputElement | null)"
|
:inputmode="fieldInputmode(segment.key)"
|
||||||
autocomplete="off"
|
:ref="el => assignReadbackFieldRef(segment.key, el as HTMLInputElement | null)"
|
||||||
autocorrect="off"
|
autocomplete="off"
|
||||||
autocapitalize="none"
|
autocorrect="off"
|
||||||
spellcheck="false"
|
autocapitalize="none"
|
||||||
/>
|
spellcheck="false"
|
||||||
<v-icon v-if="fieldPass(segment.key)" size="16" class="blank-status ok">mdi-check</v-icon>
|
/>
|
||||||
<v-icon v-else-if="fieldHasAnswer(segment.key)" size="16" class="blank-status warn">mdi-alert
|
<v-icon v-if="fieldPass(segment.key)" size="16" class="blank-status ok">mdi-check</v-icon>
|
||||||
</v-icon>
|
<v-icon v-else-if="fieldHasAnswer(segment.key)" size="16" class="blank-status warn">mdi-alert
|
||||||
<small v-if="result" class="blank-feedback">
|
</v-icon>
|
||||||
Expected: {{ fieldExpectedValue(segment.key) }}
|
<small v-if="result" class="blank-feedback">
|
||||||
</small>
|
Expected: {{ fieldExpectedValue(segment.key) }}
|
||||||
</label>
|
</small>
|
||||||
</template>
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row wrap controls">
|
<div class="row wrap controls">
|
||||||
@@ -1423,7 +1425,7 @@ import {
|
|||||||
altitudeToWords,
|
altitudeToWords,
|
||||||
minutesToWords
|
minutesToWords
|
||||||
} from '~~/shared/learn/scenario'
|
} from '~~/shared/learn/scenario'
|
||||||
import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, Scenario} from '~~/shared/learn/types'
|
import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, ReadbackSegment, Scenario} from '~~/shared/learn/types'
|
||||||
import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite'
|
import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite'
|
||||||
import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite'
|
import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite'
|
||||||
import {createNoiseGenerators, getReadabilityProfile} from '~~/shared/utils/radioEffects'
|
import {createNoiseGenerators, getReadabilityProfile} from '~~/shared/utils/radioEffects'
|
||||||
@@ -3376,6 +3378,33 @@ const correctReadbackText = computed(() => {
|
|||||||
}).join('').trim()
|
}).join('').trim()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pair each input field with its preceding text label so they wrap together
|
||||||
|
// as a single visual unit — otherwise the label (e.g. "runway") can land on
|
||||||
|
// one line while the input lands on the next, forcing the user to look up to
|
||||||
|
// remember what they're typing. Reported by Detlef (FSC e.V.).
|
||||||
|
type ClozeGroup = { id: string; segments: ReadbackSegment[] }
|
||||||
|
const clozeGroups = computed<ClozeGroup[]>(() => {
|
||||||
|
if (!activeLesson.value) return []
|
||||||
|
const segments = activeLesson.value.readback
|
||||||
|
const groups: ClozeGroup[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < segments.length) {
|
||||||
|
const seg = segments[i]!
|
||||||
|
const next = segments[i + 1]
|
||||||
|
if (seg.type === 'text' && next && next.type === 'field') {
|
||||||
|
groups.push({ id: `g-${next.key}`, segments: [seg, next] })
|
||||||
|
i += 2
|
||||||
|
} else if (seg.type === 'field') {
|
||||||
|
groups.push({ id: `g-${seg.key}`, segments: [seg] })
|
||||||
|
i += 1
|
||||||
|
} else {
|
||||||
|
groups.push({ id: `g-t-${i}`, segments: [seg] })
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
async function speakCorrectReadback() {
|
async function speakCorrectReadback() {
|
||||||
const text = correctReadbackText.value
|
const text = correctReadbackText.value
|
||||||
if (!text || ttsLoading.value) return
|
if (!text || ttsLoading.value) return
|
||||||
@@ -6605,12 +6634,28 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: stretch;
|
align-items: flex-start;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .08em;
|
letter-spacing: .08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Each label/text + its input field travel together as one wrap-unit so the
|
||||||
|
user always sees the prompt next to the blank they're filling. */
|
||||||
|
.cloze-group {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cloze-group {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.cloze-chunk {
|
.cloze-chunk {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user