feat(kit): render the selects with UiSelect

UiSelect renders the bare native select with v-model, options and
disabled. Options that carry their own attributes come through the slot, a
caller's own change handler still reaches the select, and hold never
rewrites a focused select and takes the model back on blur. Without hold, a
select takes its model back once its change has rendered, so a pick its
owner refuses goes back, as the raw value binding did.

The HUD layout, draw colour, RTL-SDR gain, radio station tag, voice
provider, voice timeout, scene and CCTV camera selects now render through
it.
This commit is contained in:
Sameh Khamis committed 2026-11-01 12:22:49 -08:00
1 parent 28ca784d49
commit ecaf723035
13 files changed
+234 -18

No files matched your search

+2 -1
View File
@@ -68,7 +68,8 @@
kit's `UiButton`, which gains `pressedClass`; so are the rail card kit's `UiButton`, which gains `pressedClass`; so are the rail card
chips, map source chips and location pills (which now report chips, map source chips and location pills (which now report
`aria-pressed`), and the voice card's chips `aria-pressed`), and the voice card's chips
are the kit's new `UiChip`. `syncScopeControls` are the kit's new `UiChip`. The selects are the kit's new `UiSelect`.
`syncScopeControls`
(`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are (`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are
deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue
(samehkhamis). (samehkhamis).
+7
View File
@@ -65,6 +65,13 @@ tilt-map button, whose owners still write their state into the DOM. Core's
pressable chips and pills (rail card chips, map sources, cities and places) pressable chips and pills (rail card chips, map sources, cities and places)
are `UiButton`s too; `UiChip` is ui-vue's non-interactive label chip, used by are `UiButton`s too; `UiChip` is ui-vue's non-interactive label chip, used by
the voice card. the voice card.
`UiSelect` renders the bare native select (Core's labels sit outside it or
come as an `aria-label`) with ui-vue's `v-model`, `options` and `disabled`;
options that carry their own attributes come through the slot, a caller's
`@change` still reaches the select, and `hold` works as in `UiSlider`.
Without `hold`, a select takes its model back after its own change renders,
so a pick its owner refuses goes back; an owner that answers later (the SDR
gain) takes `hold`. Every select in the views is a `UiSelect`.
`UiSlider` keeps the native range input where ui-vue draws a `UiSlider` keeps the native range input where ui-vue draws a
`role="slider"` track, so Core's keyboard, form behaviour and styling stay; `role="slider"` track, so Core's keyboard, form behaviour and styling stay;
it takes ui-vue's `v-model`, `min`, `max`, `step` and `disabled`, emits a it takes ui-vue's `v-model`, `min`, `max`, `step` and `disabled`, emits a
+2
View File
@@ -1134,6 +1134,8 @@
"src/ui/kit/UiButton.test.mjs", "src/ui/kit/UiButton.test.mjs",
"src/ui/kit/UiChip.vue", "src/ui/kit/UiChip.vue",
"src/ui/kit/UiChip.test.mjs", "src/ui/kit/UiChip.test.mjs",
"src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs",
"src/ui/rail/RailChips.vue", "src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs", "src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs", "src/testSupport/vueDom.mjs",
+2 -1
View File
@@ -135,7 +135,8 @@
"src/ui/rail/panelState.js", "src/ui/rail/panelState.js",
"src/sdr/gain.js", "src/sdr/gain.js",
"src/ui/kit/UiSlider.js", "src/ui/kit/UiSlider.js",
"src/ui/kit/UiChip.vue" "src/ui/kit/UiChip.vue",
"src/ui/kit/UiSelect.js"
], ],
"external": ["vue", "pinia"] "external": ["vue", "pinia"]
}, },
+1 -1
View File
@@ -94,7 +94,7 @@ test('the Draw control markup carries the ids the tool binds to', () => {
"act('toggleDraw')", "act('toggleDraw')",
"act('setDrawShape', $event)", "act('setDrawShape', $event)",
"act('setDrawLabel', $event.target.value)", "act('setDrawLabel', $event.target.value)",
"act('setDrawColor', $event.target.value)", "act('setDrawColor', $event)",
"act('clearDraw')", "act('clearDraw')",
]) { ]) {
assert.ok(html.includes(action), `DisplayPanel must call ${action}`); assert.ok(html.includes(action), `DisplayPanel must call ${action}`);
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import UiSelect from '../kit/UiSelect.js';
import { useCctvStore } from '../stores/cctv.js'; import { useCctvStore } from '../stores/cctv.js';
// The camera picker: the cameras in view and a closing note for those the // The camera picker: the cameras in view and a closing note for those the
@@ -19,7 +20,7 @@ const vPick = { mounted: pick, updated: pick };
</script> </script>
<template> <template>
<select v-pick="picker?.selection" :disabled="picker?.disabled" <UiSelect v-pick="picker?.selection" :disabled="picker?.disabled"
><option ><option
v-for="option in picker?.options" v-for="option in picker?.options"
:key="option.value" :key="option.value"
@@ -27,6 +28,6 @@ const vPick = { mounted: pick, updated: pick };
>{{ option.label }}</option >{{ option.label }}</option
><option v-if="picker?.note" value="" disabled data-cctv-outside>{{ ><option v-if="picker?.note" value="" disabled data-cctv-outside>{{
picker.note picker.note
}}</option></select }}</option></UiSelect
> >
</template> </template>
+68
View File
@@ -0,0 +1,68 @@
import { h, mergeProps, nextTick, withDirectives } from 'vue';
import { vFieldValue } from '../rail/fieldValue.js';
import { vRangeValue } from '../rail/rangeValue.js';
// A null model leaves the select as it is.
const live = (el, binding) => {
if (binding.value != null) vRangeValue.updated(el, binding);
};
const LIVE = { mounted: live, updated: live };
/** Put the model back on the select. */
function takeModel(select, props) {
const next = props.modelValue;
if (next != null && select.value !== String(next))
select.value = String(next);
}
/**
* Core's select, with @halfpixel/ui-vue UiSelect's v-model, options and
* disabled so the two kits can merge. Core renders the bare select: its
* labels sit outside it or come as an `aria-label` attribute, so the attribute
* pass translates them. Options come from `options` ({ value, label }) or,
* where they carry their own attributes, the default slot. A change emits the
* chosen value; a caller's own `@change` still reaches the select.
*
* The select takes the model's value only when it differs (`v-range-value`),
* after its options are in place; a null model leaves it as it is. With
* `hold`, it never takes it while focused (`v-field-value`), and takes it back
* when it loses focus. Without `hold`, it takes the model back once the
* change's render has run: an owner that refuses a pick republishes the same
* value, which renders nothing, so this puts the refused pick back. The
* owners of these selects answer synchronously. Like UiSlider it is a functional render-function
* component. Not mirrored yet: `label` and `hideLabel`.
*/
export default function UiSelect(props, { attrs, emit, slots }) {
const options = props.options
? props.options.map((option) =>
h('option', { key: option.value, value: option.value }, option.label),
)
: slots.default?.();
const select = h(
'select',
mergeProps(attrs, {
disabled: props.disabled,
onChange: (event) => {
const select = event.target;
emit('update:modelValue', select.value);
if (!props.hold) void nextTick(() => takeModel(select, props));
},
onBlur: props.hold
? (event) => takeModel(event.target, props)
: undefined,
}),
options,
);
return withDirectives(select, [
[props.hold ? vFieldValue : LIVE, props.modelValue],
]);
}
UiSelect.props = {
modelValue: { type: [String, Number], default: undefined },
options: { type: Array, default: undefined },
disabled: { type: Boolean, default: false },
hold: { type: Boolean, default: false },
};
UiSelect.emits = ['update:modelValue'];
UiSelect.inheritAttrs = false;
+132
View File
@@ -0,0 +1,132 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../../testSupport/vueDom.mjs';
let mount;
let vue;
let UiSelect;
let uninstall;
before(async () => {
uninstall = installVueDom();
({ mount } = await import('@vue/test-utils'));
vue = await import('vue');
UiSelect = (await import('./UiSelect.js')).default;
});
after(() => uninstall());
const OPTIONS = [
{ value: 'a', label: 'Alpha' },
{ value: 'b', label: 'Bravo' },
{ value: 'c', label: 'Charlie' },
];
function mountSelect(props = {}, attrs = {}) {
return mount(UiSelect, {
attachTo: document.body,
props: { options: OPTIONS, modelValue: 'b', ...props },
attrs,
});
}
test('a bare select with the caller’s attributes and the model chosen', () => {
const wrapper = mountSelect(
{},
{ id: 'pick', class: 'gev-field', 'aria-label': 'Pick' },
);
const select = wrapper.get('select');
assert.equal(select.attributes('class'), 'gev-field');
assert.equal(select.attributes('id'), 'pick');
assert.equal(select.attributes('aria-label'), 'Pick');
assert.equal(select.attributes('value'), undefined);
assert.deepEqual(
[...select.element.options].map((o) => [o.value, o.textContent]),
[
['a', 'Alpha'],
['b', 'Bravo'],
['c', 'Charlie'],
],
);
assert.equal(select.element.value, 'b');
assert.equal(select.element.disabled, false);
wrapper.unmount();
});
test('a change emits the value, and a caller’s own change still runs', async () => {
const seen = [];
const wrapper = mountSelect(
{},
{ onChange: (event) => seen.push(event.target.value) },
);
const select = wrapper.get('select');
select.element.value = 'c';
await select.trigger('change');
assert.deepEqual(wrapper.emitted('update:modelValue'), [['c']]);
assert.deepEqual(seen, ['c']);
wrapper.unmount();
});
test('a refused pick goes back to the model', async () => {
const { nextTick } = vue;
const wrapper = mountSelect();
const select = wrapper.get('select');
select.element.value = 'c';
await select.trigger('change');
await nextTick();
assert.equal(select.element.value, 'b');
wrapper.unmount();
});
test('the model picks the option, and a null model leaves the select', async () => {
const wrapper = mountSelect();
const select = wrapper.get('select').element;
await wrapper.setProps({ modelValue: 'c' });
assert.equal(select.value, 'c');
select.value = 'a';
await wrapper.setProps({ modelValue: null, disabled: true });
assert.equal(select.value, 'a');
assert.equal(select.disabled, true);
wrapper.unmount();
});
test('a held select keeps a focused pick and takes the model back on blur', async () => {
const wrapper = mountSelect({ hold: true });
const select = wrapper.get('select').element;
select.focus();
select.value = 'a';
await wrapper.setProps({ modelValue: 'c' });
assert.equal(select.value, 'a');
select.blur();
await wrapper.get('select').trigger('blur');
assert.equal(select.value, 'c');
wrapper.unmount();
});
test('slotted options render, and the model applies once they arrive', async () => {
const { defineComponent, h, ref, nextTick } = vue;
const options = ref(['x']);
const model = ref('x');
const wrapper = mount(
defineComponent({
setup: () => () =>
h(
UiSelect,
{ modelValue: model.value },
{
default: () =>
options.value.map((value) =>
h('option', { key: value, value, 'data-i18n': '' }, value),
),
},
),
}),
{ attachTo: document.body },
);
const select = wrapper.get('select').element;
assert.equal(select.options[0].hasAttribute('data-i18n'), true);
options.value = ['x', 'y'];
model.value = 'y';
await nextTick();
assert.equal(select.options.length, 2);
assert.equal(select.value, 'y');
wrapper.unmount();
});
+2 -1
View File
@@ -11,6 +11,7 @@ import SceneOptions from './SceneOptions.vue';
import SceneShotList from './SceneShotList.vue'; import SceneShotList from './SceneShotList.vue';
import CctvPanel from './panels/CctvPanel.vue'; import CctvPanel from './panels/CctvPanel.vue';
import StreetLevelPanel from './panels/StreetLevelPanel.vue'; import StreetLevelPanel from './panels/StreetLevelPanel.vue';
import UiSelect from '../kit/UiSelect.js';
// Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents // Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents
// its dialog through this store; without this view, neither is drawn. // its dialog through this store; without this view, neither is drawn.
@@ -52,7 +53,7 @@ async function importChosen(event) {
<RailPanel id="scene-panel" start-collapsed v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" :title="t('AUTHORING')" :hint="t('Create and edit scenes')" scroll> <RailPanel id="scene-panel" start-collapsed v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" :title="t('AUTHORING')" :hint="t('Create and edit scenes')" scroll>
<p class="scene-step-heading" data-i18n>SCENE</p> <p class="scene-step-heading" data-i18n>SCENE</p>
<div class="scene-controls"> <div class="scene-controls">
<select class="gev-field" id="scene-select" aria-label="Scene" data-i18n-aria-label :disabled="off('select')" @change="scenes.act('selectScene', $event.currentTarget.value)"><SceneOptions /></select> <UiSelect class="gev-field" id="scene-select" aria-label="Scene" data-i18n-aria-label :disabled="off('select')" @update:model-value="scenes.act('selectScene', $event)"><SceneOptions /></UiSelect>
<UiButton id="scene-new-btn" type="button" :disabled="off('new')" @click="scenes.act('create')" class="gev-action-button scene-btn" title="Start a new, empty scene" data-i18n data-i18n-title>NEW SCENE</UiButton> <UiButton id="scene-new-btn" type="button" :disabled="off('new')" @click="scenes.act('create')" class="gev-action-button scene-btn" title="Start a new, empty scene" data-i18n data-i18n-title>NEW SCENE</UiButton>
<UiButton tone="alert" id="scene-delete-btn" type="button" :disabled="off('delete')" @click="scenes.act('deleteScene')" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Delete this scene and all of its shots" data-i18n data-i18n-title>DELETE</UiButton> <UiButton tone="alert" id="scene-delete-btn" type="button" :disabled="off('delete')" @click="scenes.act('deleteScene')" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Delete this scene and all of its shots" data-i18n data-i18n-title>DELETE</UiButton>
</div> </div>
+5 -4
View File
@@ -10,6 +10,7 @@ import UiSlider from '../../kit/UiSlider.js';
import GeoImport from './GeoImport.vue'; import GeoImport from './GeoImport.vue';
import { useDisplayStore } from '../../stores/display.js'; import { useDisplayStore } from '../../stores/display.js';
import UiButton from '../../kit/UiButton.vue'; import UiButton from '../../kit/UiButton.vue';
import UiSelect from '../../kit/UiSelect.js';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope, // The Display panel. VisualSettings presents the HUD, Sonar, Scope,
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and // Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
@@ -68,11 +69,11 @@ const allocationOptions = [
</button> </button>
<div class="pp-slider-row" :class="{ visible: hud?.visible }" id="hud-layout-row"> <div class="pp-slider-row" :class="{ visible: hud?.visible }" id="hud-layout-row">
<span class="pp-slider-mini-label" data-i18n>Layout</span> <span class="pp-slider-mini-label" data-i18n>Layout</span>
<select id="hud-layout-select" @change="act('setHudLayout', $event.target.value)" class="pp-select gev-field gev-field--compact" v-range-value="hud?.variant ?? 'tactical'" aria-label="HUD layout" data-i18n-aria-label> <UiSelect id="hud-layout-select" class="pp-select gev-field gev-field--compact" :model-value="hud?.variant ?? 'tactical'" @update:model-value="act('setHudLayout', $event)" aria-label="HUD layout" data-i18n-aria-label>
<option value="tactical" data-i18n>Tactical</option> <option value="tactical" data-i18n>Tactical</option>
<option value="minimal" data-i18n>Minimal</option> <option value="minimal" data-i18n>Minimal</option>
<option value="cyber" data-i18n>Cyber</option> <option value="cyber" data-i18n>Cyber</option>
</select> </UiSelect>
</div> </div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row"> <div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
<span class="pp-slider-mini-label" data-i18n>Sonar</span> <span class="pp-slider-mini-label" data-i18n>Sonar</span>
@@ -176,13 +177,13 @@ const allocationOptions = [
</div> </div>
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row"> <div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" v-range-value="draw?.label ?? ''" @input="act('setDrawLabel', $event.target.value)" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false"> <input type="text" class="pp-text-input" id="draw-label-input" v-range-value="draw?.label ?? ''" @input="act('setDrawLabel', $event.target.value)" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" v-range-value="draw?.color ?? 'primary'" @change="act('setDrawColor', $event.target.value)" aria-label="Colour of the drawn shape" data-i18n-aria-label> <UiSelect class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" :model-value="draw?.color ?? 'primary'" @update:model-value="act('setDrawColor', $event)" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<option value="primary" data-i18n>Primary</option> <option value="primary" data-i18n>Primary</option>
<option value="amber" data-i18n>Amber</option> <option value="amber" data-i18n>Amber</option>
<option value="cyan" data-i18n>Cyan</option> <option value="cyan" data-i18n>Cyan</option>
<option value="green" data-i18n>Green</option> <option value="green" data-i18n>Green</option>
<option value="red" data-i18n>Red</option> <option value="red" data-i18n>Red</option>
</select> </UiSelect>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button> <button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span> <span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
</div> </div>
+3 -3
View File
@@ -5,11 +5,11 @@ import { t } from '../../../i18n/index.js';
import RailPanel from '../../rail/RailPanel.vue'; import RailPanel from '../../rail/RailPanel.vue';
import { vClassToggle } from '../../rail/classToggle.js'; import { vClassToggle } from '../../rail/classToggle.js';
import UiSlider from '../../kit/UiSlider.js'; import UiSlider from '../../kit/UiSlider.js';
import { vRangeValue } from '../../rail/rangeValue.js';
import { useRadioStore } from '../../stores/radio.js'; import { useRadioStore } from '../../stores/radio.js';
import RadioTuner from './RadioTuner.vue'; import RadioTuner from './RadioTuner.vue';
import SdrCard from './SdrCard.vue'; import SdrCard from './SdrCard.vue';
import UiButton from '../../kit/UiButton.vue'; import UiButton from '../../kit/UiButton.vue';
import UiSelect from '../../kit/UiSelect.js';
// The Radio panel inside Context. RadioControls presents the directory row, // The Radio panel inside Context. RadioControls presents the directory row,
// station card, transport, volume and playback line and publishes them; // station card, transport, volume and playback line and publishes them;
@@ -41,9 +41,9 @@ const radioWords = {
<div class="radio-directory-row"> <div class="radio-directory-row">
<UiButton id="radio-enable-btn" @click="act('toggleRadio', 'panel')" class="panel-layer-toggle" :pressed="radio?.enable.active ?? false" pressed-class="active" :aria-label="radio?.enable.label" :aria-disabled="radio ? String(radio.enable.busy) : undefined" :aria-busy="radio ? String(radio.enable.busy) : undefined">{{ radio ? radio.enable.text : t('ENABLE') }}</UiButton> <UiButton id="radio-enable-btn" @click="act('toggleRadio', 'panel')" class="panel-layer-toggle" :pressed="radio?.enable.active ?? false" pressed-class="active" :aria-label="radio?.enable.label" :aria-disabled="radio ? String(radio.enable.busy) : undefined" :aria-busy="radio ? String(radio.enable.busy) : undefined">{{ radio ? radio.enable.text : t('ENABLE') }}</UiButton>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label> <label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<select class="gev-field" id="radio-filter" @change="setFilter($event.target)" aria-label="Filter stations by station tag" :disabled="radio ? radio.filter.disabled : true" v-bind="radio?.filter.color ? { style: { color: radio.filter.color } } : {}" v-range-value="radio ? radio.filter.value : 'all'" data-i18n-aria-label> <UiSelect class="gev-field" id="radio-filter" @change="setFilter($event.target)" aria-label="Filter stations by station tag" :disabled="radio ? radio.filter.disabled : true" v-bind="radio?.filter.color ? { style: { color: radio.filter.color } } : {}" :model-value="radio ? radio.filter.value : 'all'" data-i18n-aria-label>
<option v-if="!radio?.filter.options" value="all">{{ t('All', { context: 'radio tag' }) }}</option><template v-else><option v-for="option in radio.filter.options" :key="option.id" :value="option.id" :data-radio-color="option.color" :style="{ color: option.color }" :aria-label="option.label">● {{ option.label }}</option></template> <option v-if="!radio?.filter.options" value="all">{{ t('All', { context: 'radio tag' }) }}</option><template v-else><option v-for="option in radio.filter.options" :key="option.id" :value="option.id" :data-radio-color="option.color" :style="{ color: option.color }" :aria-label="option.label">● {{ option.label }}</option></template>
</select> </UiSelect>
</div> </div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite"> <div id="radio-station-card" class="radio-station-card" aria-live="polite">
<strong id="radio-station-name">{{ radio ? radio.station.name : t('NO STATION SELECTED') }}</strong> <strong id="radio-station-name">{{ radio ? radio.station.name : t('NO STATION SELECTED') }}</strong>
+3 -2
View File
@@ -9,6 +9,7 @@ import { vFieldValue } from '../../rail/fieldValue.js';
import UiSlider from '../../kit/UiSlider.js'; import UiSlider from '../../kit/UiSlider.js';
import { useRadioStore } from '../../stores/radio.js'; import { useRadioStore } from '../../stores/radio.js';
import UiButton from '../../kit/UiButton.vue'; import UiButton from '../../kit/UiButton.vue';
import UiSelect from '../../kit/UiSelect.js';
// The local RTL-SDR card in the Radio panel. LocalSdrControls presents it and // The local RTL-SDR card in the Radio panel. LocalSdrControls presents it and
// publishes it; until it does, the card shows its initial markup. Its // publishes it; until it does, the card shows its initial markup. Its
@@ -40,10 +41,10 @@ const tune = () => act('tuneSdr', frequency.value.value);
</div> </div>
<div class="sdr-gain-row"> <div class="sdr-gain-row">
<label for="sdr-gain-select" data-i18n>GAIN</label> <label for="sdr-gain-select" data-i18n>GAIN</label>
<select class="gev-field" id="sdr-gain-select" @change="act('setSdrGain', $event.target.value)" aria-label="RTL-SDR tuner gain for the current mode" :disabled="sdr?.gainDisabled" v-field-value="sdr ? sdr.gain : 'auto'" data-i18n-aria-label> <UiSelect class="gev-field" id="sdr-gain-select" hold :model-value="sdr ? sdr.gain : 'auto'" @update:model-value="act('setSdrGain', $event)" aria-label="RTL-SDR tuner gain for the current mode" :disabled="sdr?.gainDisabled" data-i18n-aria-label>
<option value="auto" data-i18n>AUTO</option> <option value="auto" data-i18n>AUTO</option>
<option v-for="gain in GAINS" :key="gain" :value="gain" translate="no">{{ localSdrGainOptionLabel(gain) }}</option> <option v-for="gain in GAINS" :key="gain" :value="gain" translate="no">{{ localSdrGainOptionLabel(gain) }}</option>
</select> </UiSelect>
</div> </div>
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" :hidden="sdr ? sdr.statsHidden : true"> <div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" :hidden="sdr ? sdr.statsHidden : true">
<span><output id="sdr-stat-rate">{{ sdr ? sdr.stats.rate : '--' }}</output> <span data-i18n>MSG/S</span></span> <span><output id="sdr-stat-rate">{{ sdr ? sdr.stats.rate : '--' }}</output> <span data-i18n>MSG/S</span></span>
+4 -3
View File
@@ -6,6 +6,7 @@ import { useVoiceStore } from '../../stores/voice.js';
import VoiceCard from './VoiceCard.vue'; import VoiceCard from './VoiceCard.vue';
import VoiceVisualizer from './VoiceVisualizer.vue'; import VoiceVisualizer from './VoiceVisualizer.vue';
import UiButton from '../../kit/UiButton.vue'; import UiButton from '../../kit/UiButton.vue';
import UiSelect from '../../kit/UiSelect.js';
// The voice control in the command dock, as its presenter // The voice control in the command dock, as its presenter
// (src/voice/control.js) holds it. The session owners write the presenter; // (src/voice/control.js) holds it. The session owners write the presenter;
@@ -52,7 +53,7 @@ const rootData = (root) => ({
<div id="gev-voice-control" v-bind="rootData(view.root)" v-class-toggle="{ 'error-dismissed': view.root.errorDismissed }" @pointerleave="emit('pointerleave', $event)" @pointerenter="emit('pointerenter', $event)"> <div id="gev-voice-control" v-bind="rootData(view.root)" v-class-toggle="{ 'error-dismissed': view.root.errorDismissed }" @pointerleave="emit('pointerleave', $event)" @pointerenter="emit('pointerenter', $event)">
<label id="gev-voice-provider-field" class="gev-voice-provider-field" :hidden="view.provider.hidden"> <label id="gev-voice-provider-field" class="gev-voice-provider-field" :hidden="view.provider.hidden">
<span>{{ t('Voice provider') }}</span> <span>{{ t('Voice provider') }}</span>
<select id="gev-voice-provider" class="gev-field" :aria-label="t('Voice provider')" :title="view.provider.title" :value="view.provider.value" @change="emit('provider-change', $event.target.value)"><option v-for="option in view.provider.options" :key="option.value" :value="option.value">{{ option.label }}</option></select> <UiSelect id="gev-voice-provider" class="gev-field" :aria-label="t('Voice provider')" :title="view.provider.title" :model-value="view.provider.value" @update:model-value="emit('provider-change', $event)"><option v-for="option in view.provider.options" :key="option.value" :value="option.value">{{ option.label }}</option></UiSelect>
</label> </label>
<button v-if="view.panel" id="gev-voice-settings-button" class="gev-voice-settings-button" type="button" :aria-label="t('Voice Settings')" aria-controls="gev-voice-panel" :aria-expanded="String(view.voicePanel.settingsExpanded)" @click="emit('settings-button')"><span class="material-symbols-outlined gev-voice-settings-icon" aria-hidden="true">settings</span></button> <button v-if="view.panel" id="gev-voice-settings-button" class="gev-voice-settings-button" type="button" :aria-label="t('Voice Settings')" aria-controls="gev-voice-panel" :aria-expanded="String(view.voicePanel.settingsExpanded)" @click="emit('settings-button')"><span class="material-symbols-outlined gev-voice-settings-icon" aria-hidden="true">settings</span></button>
<div class="gev-voice-heading"> <div class="gev-voice-heading">
@@ -96,10 +97,10 @@ const rootData = (root) => ({
<div class="gev-voice-timeout-group"> <div class="gev-voice-timeout-group">
<label class="gev-voice-preference gev-voice-setting-title" for="gev-voice-timeout-select">{{ t('Voice inactivity timeout') }}</label> <label class="gev-voice-preference gev-voice-setting-title" for="gev-voice-timeout-select">{{ t('Voice inactivity timeout') }}</label>
<div class="gev-voice-timeout-controls"> <div class="gev-voice-timeout-controls">
<select id="gev-voice-timeout-select" class="gev-field" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status" :value="view.preferences.timeoutChoice" @change="emit('timeout-select', $event.target.value)"> <UiSelect id="gev-voice-timeout-select" class="gev-field" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status" :model-value="view.preferences.timeoutChoice" @update:model-value="emit('timeout-select', $event)">
<option value="3">3 {{ t('Minutes') }}</option><option value="5">5 {{ t('Minutes') }}</option><option value="10">10 {{ t('Minutes') }}</option><option value="15">15 {{ t('Minutes') }}</option> <option value="3">3 {{ t('Minutes') }}</option><option value="5">5 {{ t('Minutes') }}</option><option value="10">10 {{ t('Minutes') }}</option><option value="15">15 {{ t('Minutes') }}</option>
<option value="custom">{{ t('Custom') }}</option><option value="0">{{ t('None') }}</option> <option value="custom">{{ t('Custom') }}</option><option value="0">{{ t('None') }}</option>
</select> </UiSelect>
<label class="gev-voice-timeout-unit" for="gev-voice-timeout-input">{{ t('Minutes') }}</label> <label class="gev-voice-timeout-unit" for="gev-voice-timeout-input">{{ t('Minutes') }}</label>
<input id="gev-voice-timeout-input" type="number" min="1" max="60" step="1" aria-describedby="gev-voice-timeout-note" v-owned-value="{ text: view.preferences.timeoutMinutes, revision: view.preferences.revision }" :hidden="view.preferences.timeoutInputHidden" @change="emit('timeout-input', $event.target.value)" /> <input id="gev-voice-timeout-input" type="number" min="1" max="60" step="1" aria-describedby="gev-voice-timeout-note" v-owned-value="{ text: view.preferences.timeoutMinutes, revision: view.preferences.revision }" :hidden="view.preferences.timeoutInputHidden" @change="emit('timeout-input', $event.target.value)" />
</div> </div>