feat(kit): render the sliders with UiSlider

UiSlider takes v-model, min, max, step and disabled
over Core's native range input, emits numbers, and with `hold` never
rewrites a focused handle and takes the model back on blur. It is a
functional render function, so the portable style parameter rows use it
too. The Display, Radio, RTL-SDR, Cockpit, mission replay and style
parameter sliders render with it; server-rendered markup keeps their
default values.
This commit is contained in:
Sameh Khamis committed 2026-11-01 11:27:52 -08:00
1 parent fa2dc9fc87
commit 5019bd9e8a
18 files changed
+241 -50

No files matched your search

+1 -1
View File
@@ -658,7 +658,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(display, /id="cyber-sonar-toggle"/);
assert.match(
display,
/id="cyber-sonar-opacity"[^>]*value="84"[^>]*aria-label="Cyber contact and label opacity"/,
/id="cyber-sonar-opacity"[^>]*:model-value="sonar\?\.opacity \?\? 84" aria-label="Cyber contact and label opacity"/,
);
assert.match(display, /id="cyber-sonar-sector"/);
assert.deepEqual(setHud.parameters.properties.layout.enum, HUD_LAYOUTS);
+1 -1
View File
@@ -87,7 +87,7 @@ test('buttons, sliders, the layout select and the Scope checkbox call their acti
['setSonarOpacity', 60],
['setSonarSector', 30],
// Density passes the slider's own value, as it always has.
['setDensity', '75'],
['setDensity', 75],
['setFade', 12],
['setOutsideOpacity', 3],
['setScopeFeather', 150],
+63
View File
@@ -0,0 +1,63 @@
import { h, withDirectives } from 'vue';
import { vFieldValue } from '../rail/fieldValue.js';
import { vRangeValue } from '../rail/rangeValue.js';
// Server-rendered markup carries the model as the input's value.
const ssrValue = ({ value }) => (value == null ? {} : { value });
const LIVE = { ...vRangeValue, getSSRProps: ssrValue };
const HOLD = { ...vFieldValue, getSSRProps: ssrValue };
/** Put the model back on a held input that lost focus. */
function takeModel(input, props) {
const next = props.modelValue;
if (next != null && input.value !== String(next)) input.value = String(next);
}
/**
* Core's slider, with @halfpixel/ui-vue UiSlider's v-model, min, max, step
* and disabled so the two kits can merge. Core keeps the native range input
* (its keyboard, form and styling) where ui-vue draws a `role="slider"`
* track. The input carries Core's `gev-quantitative-slider` class ahead of
* the caller's, the caller's attributes come first, as in the markup this
* replaced, and the accessible name comes as an `aria-label` attribute so
* the attribute pass translates it. The value emits as a number.
*
* The input takes the model's value only when it differs (`v-range-value`);
* with `hold`, never while it is focused (`v-field-value`), so a render
* cannot move a handle a person is dragging, and it takes the model back
* when it loses focus: a model left unchanged renders nothing. Owners take
* every value their slider can reach, some only after a delay, so an input
* is never put back on its own. It is a functional component written as a
* render function, so portable entries need no component compiler. Not
* mirrored yet: `label`, `format`, `markers` and `endLabels`.
*/
export default function UiSlider(props, { attrs, emit }) {
const { class: className, ...rest } = attrs;
return withDirectives(
h('input', {
...rest,
class: ['gev-quantitative-slider', className],
type: 'range',
min: props.min,
max: props.max,
step: props.step,
disabled: props.disabled,
onInput: (event) => emit('update:modelValue', Number(event.target.value)),
onBlur: props.hold
? (event) => takeModel(event.target, props)
: undefined,
}),
[[props.hold ? HOLD : LIVE, props.modelValue]],
);
}
UiSlider.props = {
modelValue: { type: [Number, String], default: undefined },
min: { type: [Number, String], required: true },
max: { type: [Number, String], required: true },
step: { type: [Number, String], default: undefined },
disabled: { type: Boolean, default: false },
hold: { type: Boolean, default: false },
};
UiSlider.emits = ['update:modelValue'];
UiSlider.inheritAttrs = false;
+107
View File
@@ -0,0 +1,107 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../../testSupport/vueDom.mjs';
let mount;
let UiSlider;
let uninstall;
before(async () => {
uninstall = installVueDom();
({ mount } = await import('@vue/test-utils'));
UiSlider = (await import('./UiSlider.js')).default;
});
after(() => uninstall());
function mountSlider(props = {}, attrs = {}) {
return mount(UiSlider, {
attachTo: document.body,
props: { min: 0, max: 100, modelValue: 40, ...props },
attrs,
});
}
test('a native range input with Core’s class beside the caller’s', () => {
const wrapper = mountSlider(
{ step: 5 },
{ id: 'vol', class: 'pp-slider', 'aria-label': 'Volume' },
);
const input = wrapper.get('input');
assert.equal(input.attributes('type'), 'range');
assert.equal(input.attributes('class'), 'gev-quantitative-slider pp-slider');
assert.equal(input.attributes('id'), 'vol');
assert.equal(input.attributes('aria-label'), 'Volume');
assert.equal(input.attributes('step'), '5');
assert.equal(input.attributes('value'), undefined);
assert.equal(input.element.value, '40');
wrapper.unmount();
});
test('no step attribute unless one is given, and disabled reaches the input', () => {
const wrapper = mountSlider({ disabled: true });
const input = wrapper.get('input');
assert.equal(input.attributes('step'), undefined);
assert.equal(input.element.disabled, true);
wrapper.unmount();
});
test('a drag emits the value as a number', async () => {
const wrapper = mountSlider({ min: 0.25, max: 4, step: 0.25, modelValue: 1 });
const input = wrapper.get('input');
input.element.value = '2.5';
await input.trigger('input');
assert.deepEqual(wrapper.emitted('update:modelValue'), [[2.5]]);
wrapper.unmount();
});
test('the model moves the handle, even while focused, unless it holds', async () => {
const live = mountSlider();
live.element.focus();
await live.setProps({ modelValue: 70 });
assert.equal(live.element.value, '70');
live.unmount();
const held = mountSlider({ hold: true });
held.element.focus();
held.element.value = '55';
await held.setProps({ modelValue: 70 });
assert.equal(held.element.value, '55');
held.element.blur();
await held.setProps({ modelValue: 80 });
assert.equal(held.element.value, '80');
held.unmount();
});
test('a held slider with no model keeps its value', async () => {
const wrapper = mountSlider({ hold: true, modelValue: 30 });
await wrapper.setProps({ modelValue: null });
assert.equal(wrapper.element.value, '30');
wrapper.unmount();
});
test('a held handle takes the model back when it loses focus', async () => {
const wrapper = mountSlider({ hold: true, modelValue: 60 });
const input = wrapper.get('input');
input.element.focus();
input.element.value = '35';
await input.trigger('input');
await wrapper.setProps({ modelValue: 60 });
assert.equal(input.element.value, '35');
input.element.blur();
await input.trigger('blur');
assert.equal(input.element.value, '60');
wrapper.unmount();
});
test('an input stays until the owner answers, however late', async () => {
for (const hold of [false, true]) {
const wrapper = mountSlider({ hold, modelValue: 40 });
const input = wrapper.get('input');
input.element.value = '70';
await input.trigger('input');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(input.element.value, '70', `hold: ${hold}`);
await wrapper.setProps({ modelValue: 70 });
assert.equal(input.element.value, '70', `hold: ${hold}`);
wrapper.unmount();
}
});
+2 -2
View File
@@ -111,7 +111,7 @@ test('before the layer presents, the roster loads and no mission shows', (t) =>
);
assert.equal($('#space-mission-panel').hidden, true);
assert.equal($('[data-mission-replay-speed-output]').textContent, '1×');
assert.equal($('[data-mission-replay-speed]').getAttribute('value'), '1');
assert.equal($('[data-mission-replay-speed]').value, '1');
});
test('the roster lists missions and drives selection and preview', async (t) => {
@@ -316,7 +316,7 @@ test('the replay controls follow the layer and call its commands', async (t) =>
['replay'],
['toggle'],
['cancel'],
['speed', '3'],
['speed', 3],
['focus'],
['previous'],
['next'],
+1 -1
View File
@@ -64,7 +64,7 @@ test('the panel’s Enable, transport and volume call RadioControls', (t) => {
['toggleRadioPlayback'],
['cycleRadio', 1],
['stopRadio'],
['setRadioVolume', '35'],
['setRadioVolume', 35],
]);
});
+1 -1
View File
@@ -68,7 +68,7 @@ test('the card’s buttons, gain, tuning and volume call LocalSdrControls', (t)
['setSdrGain', '20.7'],
['tuneSdr', '101.1'],
['tuneSdr', '95.3'],
['setSdrVolume', '40'],
['setSdrVolume', 40],
]);
});
+14 -18
View File
@@ -1,6 +1,6 @@
import { h, render, withDirectives } from 'vue';
import { h, render } from 'vue';
import { formatNumber, t } from '../i18n/index.js';
import { vRangeValue } from './rail/rangeValue.js';
import UiSlider from './kit/UiSlider.js';
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
const formatParam = (value, digits) =>
@@ -11,8 +11,8 @@ const formatParam = (value, digits) =>
});
/**
* The generated rows: a label, the shared native slider and its value in the
* UI language. A render function, so the portable input package needs no
* The generated rows: a label, the kit's slider and its value in the UI
* language. A render function, so the portable input package needs no
* single-file-component compiler.
*/
const StyleParameterRows = {
@@ -24,18 +24,15 @@ const StyleParameterRows = {
return this.rows.map((row) =>
h('div', { key: row.name, class: 'param-slider-row' }, [
h('span', { class: 'param-label' }, row.label),
withDirectives(
h('input', {
type: 'range',
class: 'gev-quantitative-slider param-slider',
min: row.min,
max: row.max,
step: row.step,
'aria-label': row.label,
onInput: (event) => this.onInput(row.name, event.target.value),
}),
[[vRangeValue, row.value]],
),
h(UiSlider, {
class: 'param-slider',
min: row.min,
max: row.max,
step: row.step,
'aria-label': row.label,
modelValue: row.value,
'onUpdate:modelValue': (value) => this.onInput(row.name, value),
}),
h('span', { class: 'param-value' }, row.text),
]),
);
@@ -82,9 +79,8 @@ export function createStyleParameters({ container }) {
value: readValue(name),
text: '',
}));
onInput = (name, raw) => {
onInput = (name, value) => {
if (destroyed || current !== generation) return;
const value = parseFloat(raw);
writeValue(name, value);
rows = rows.map((row) =>
row.name === name
@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import UiSlider from '../../kit/UiSlider.js';
import { useRadioStore } from '../../stores/radio.js';
// The Cockpit's Display and Radio launchers and their popovers. RadioControls
@@ -60,7 +60,7 @@ const radioLabel = computed(() =>
</div>
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
<span data-i18n>VOLUME</span>
<input id="cockpit-radio-volume" @input="act('setRadioVolume', $event.target.value)" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" :disabled="cockpit ? cockpit.volume.disabled : true" v-field-value="cockpit ? cockpit.volume.value : 80" data-i18n-aria-label />
<UiSlider id="cockpit-radio-volume" hold @update:model-value="act('setRadioVolume', $event)" min="0" max="100" aria-label="Cockpit Radio volume" :disabled="cockpit ? cockpit.volume.disabled : true" :model-value="cockpit ? cockpit.volume.value : 80" data-i18n-aria-label />
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">{{ cockpit ? `${cockpit.volume.value}%` : '80%' }}</output>
</label>
</div>
+12 -12
View File
@@ -6,6 +6,7 @@ import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vRangeValue } from '../../rail/rangeValue.js';
import UiSegmented from '../../kit/UiSegmented.vue';
import UiSlider from '../../kit/UiSlider.js';
import GeoImport from './GeoImport.vue';
import { useDisplayStore } from '../../stores/display.js';
@@ -17,7 +18,6 @@ const display = useDisplayStore();
const { view, models3d, draw } = storeToRefs(display);
// A control's change goes to its owner (DisplayBindings or the draw tool).
const act = (name, ...args) => display.act(name, ...args);
const integer = (event) => parseInt(event.target.value, 10);
const pct = (value) => `${value}%`;
const hud = computed(() => view.value?.hud ?? null);
const sonar = computed(() => view.value?.sonar ?? null);
@@ -80,27 +80,27 @@ const allocationOptions = [
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
<label class="pp-slider-mini-label" for="cyber-sonar-rings" data-i18n>Rings</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-rings" @input="act('setSonarRings', integer($event))" min="3" max="12" step="1" value="8" v-range-value="sonar?.rings ?? 8" aria-label="Sonar ring count" data-i18n-aria-label />
<UiSlider class="pp-slider" id="cyber-sonar-rings" @update:model-value="act('setSonarRings', $event)" min="3" max="12" step="1" :model-value="sonar?.rings ?? 8" aria-label="Sonar ring count" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-rings-value">{{ sonar?.rings ?? 8 }}</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
<label class="pp-slider-mini-label" for="cyber-sonar-range" data-i18n>Range</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-range" @input="act('setSonarRange', integer($event))" min="60" max="120" step="1" value="100" v-range-value="sonar?.range ?? 100" aria-label="Sonar ring range" data-i18n-aria-label />
<UiSlider class="pp-slider" id="cyber-sonar-range" @update:model-value="act('setSonarRange', $event)" min="60" max="120" step="1" :model-value="sonar?.range ?? 100" aria-label="Sonar ring range" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-range-value">{{ pct(sonar?.range ?? 100) }}</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-intensity" data-i18n>Power</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-intensity" @input="act('setSonarIntensity', integer($event))" min="0" max="100" step="1" value="70" v-range-value="sonar?.intensity ?? 70" aria-label="Sonar intensity" data-i18n-aria-label />
<UiSlider class="pp-slider" id="cyber-sonar-intensity" @update:model-value="act('setSonarIntensity', $event)" min="0" max="100" step="1" :model-value="sonar?.intensity ?? 70" aria-label="Sonar intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">{{ pct(sonar?.intensity ?? 70) }}</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-opacity" data-i18n>Opacity</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-opacity" @input="act('setSonarOpacity', integer($event))" min="35" max="100" step="1" value="84" v-range-value="sonar?.opacity ?? 84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
<UiSlider class="pp-slider" id="cyber-sonar-opacity" @update:model-value="act('setSonarOpacity', $event)" min="35" max="100" step="1" :model-value="sonar?.opacity ?? 84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">{{ pct(sonar?.opacity ?? 84) }}</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
<label class="pp-slider-mini-label" for="cyber-sonar-sector" data-i18n>Sector</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-sector" @input="act('setSonarSector', integer($event))" min="8" max="60" step="1" value="24" v-range-value="sonar?.sector ?? 24" aria-label="Sonar sector width" data-i18n-aria-label />
<UiSlider class="pp-slider" id="cyber-sonar-sector" @update:model-value="act('setSonarSector', $event)" min="8" max="60" step="1" :model-value="sonar?.sector ?? 24" aria-label="Sonar sector width" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-sector-value">{{ `${sonar?.sector ?? 24}°` }}</span>
</div>
</div>
@@ -111,7 +111,7 @@ const allocationOptions = [
</button>
<div class="pp-slider-row detection-slider-row" :class="{ visible: detection?.rowsVisible }" id="detection-slider-row">
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-density-slider" @input="act('setDensity', $event.target.value)" min="0" max="100" step="25" value="50" v-range-value="detection?.densityPct ?? 50" aria-label="Detection label density" data-i18n-aria-label />
<UiSlider class="pp-slider" id="detection-density-slider" @update:model-value="act('setDensity', $event)" min="0" max="100" step="25" :model-value="detection?.densityPct ?? 50" aria-label="Detection label density" data-i18n-aria-label />
<span class="pp-slider-value" id="detection-density-value">{{ pct(detection?.densityPct ?? 50) }}</span>
</div>
<div class="pp-slider-row detection-allocation-row" :class="{ visible: detection?.rowsVisible }" id="detection-allocation-row">
@@ -120,12 +120,12 @@ const allocationOptions = [
</div>
<div class="pp-slider-row detection-fade-row" :class="{ visible: detection?.rowsVisible }" id="detection-fade-row">
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n>Fade</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-fade-slider" @input="act('setFade', integer($event))" min="0" max="40" step="1" value="7" v-range-value="detection?.fadePct ?? 7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
<UiSlider class="pp-slider" id="detection-fade-slider" @update:model-value="act('setFade', $event)" min="0" max="40" step="1" :model-value="detection?.fadePct ?? 7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="detection-fade-value">{{ pct(detection?.fadePct ?? 7) }}</span>
</div>
<div class="pp-slider-row detection-opacity-row" :class="{ visible: detection?.rowsVisible }" id="detection-opacity-row">
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n>Outside</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-opacity-slider" @input="act('setOutsideOpacity', integer($event))" min="0" max="100" step="1" value="1" v-range-value="detection?.outsideOpacityPct ?? 1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
<UiSlider class="pp-slider" id="detection-opacity-slider" @update:model-value="act('setOutsideOpacity', $event)" min="0" max="100" step="1" :model-value="detection?.outsideOpacityPct ?? 1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="detection-opacity-value">{{ pct(detection?.outsideOpacityPct ?? 1) }}</span>
</div>
</div>
@@ -160,7 +160,7 @@ const allocationOptions = [
</label>
<div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label" data-i18n>Feather</span>
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" @input="act('setScopeFeather', integer($event))" min="0" max="200" value="100" v-range-value="scopeFeather" :disabled="!scopeOn" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<UiSlider class="pp-slider" id="scope-feather-slider" @update:model-value="act('setScopeFeather', $event)" min="0" max="200" :model-value="scopeFeather" :disabled="!scopeOn" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">{{ pct(scopeFeather) }}</span>
</div>
</div>
@@ -203,7 +203,7 @@ const allocationOptions = [
<span class="pp-label" data-i18n>Bloom</span>
</button>
<div class="pp-slider-row" :class="{ visible: view?.bloom.active }" id="bloom-slider-row">
<input type="range" class="gev-quantitative-slider pp-slider" id="bloom-intensity-slider" @input="act('setBloomIntensity', integer($event))" min="0" max="200" value="0" v-range-value="view?.bloom.intensity ?? 0" aria-label="Bloom intensity" data-i18n-aria-label />
<UiSlider class="pp-slider" id="bloom-intensity-slider" @update:model-value="act('setBloomIntensity', $event)" min="0" max="200" :model-value="view?.bloom.intensity ?? 0" aria-label="Bloom intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="bloom-intensity-value">{{ pct(view?.bloom.intensity ?? 0) }}</span>
</div>
</div>
@@ -213,7 +213,7 @@ const allocationOptions = [
<span class="pp-label" data-i18n>Sharpen</span>
</button>
<div class="pp-slider-row" :class="{ visible: view?.sharpen.active }" id="sharpen-slider-row">
<input type="range" class="gev-quantitative-slider pp-slider" id="sharpen-intensity-slider" @input="act('setSharpenIntensity', integer($event))" min="0" max="100" value="49" v-range-value="view?.sharpen.intensity ?? 49" aria-label="Sharpen intensity" data-i18n-aria-label />
<UiSlider class="pp-slider" id="sharpen-intensity-slider" @update:model-value="act('setSharpenIntensity', $event)" min="0" max="100" :model-value="view?.sharpen.intensity ?? 49" aria-label="Sharpen intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="sharpen-intensity-value">{{ pct(view?.sharpen.intensity ?? 49) }}</span>
</div>
</div>
+2 -2
View File
@@ -2,7 +2,7 @@
import { storeToRefs } from 'pinia';
import { nextTick, ref, watch } from 'vue';
import { t } from '../../../i18n/index.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import UiSlider from '../../kit/UiSlider.js';
import { useContextStore } from '../../stores/context.js';
// The selected space mission inside Context. The launches layer presents it
@@ -62,7 +62,7 @@ watch(
</section>
<div class="mission-replay-speed-control" :hidden="m && !m.replay.available">
<div class="mission-replay-speed-header"><label for="space-mission-replay-speed">{{ t('REPLAY SPEED') }}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>{{ m ? m.replay.speedText : '1×' }}</output></div>
<input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed :aria-label="t('Replay speed multiplier')" v-field-value="m?.replay.speed" :style="m && { '--replay-speed-progress': m.replay.speedProgress }" @input="layer?.setReplaySpeed($event.currentTarget.value)">
<UiSlider id="space-mission-replay-speed" hold min="0.25" max="4" step="0.25" data-mission-replay-speed :aria-label="t('Replay speed multiplier')" :model-value="m ? m.replay.speed : 1" :style="m && { '--replay-speed-progress': m.replay.speedProgress }" @update:model-value="layer?.setReplaySpeed($event)" />
<div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div>
</div>
<div class="mission-action-row">
+2 -2
View File
@@ -4,7 +4,7 @@ import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import RailPanel from '../../rail/RailPanel.vue';
import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import UiSlider from '../../kit/UiSlider.js';
import { vRangeValue } from '../../rail/rangeValue.js';
import { useRadioStore } from '../../stores/radio.js';
import RadioTuner from './RadioTuner.vue';
@@ -58,7 +58,7 @@ const radioWords = {
</div>
<label class="radio-volume-row" for="radio-volume">
<span data-i18n>VOLUME</span>
<input id="radio-volume" @input="act('setRadioVolume', $event.target.value)" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" :disabled="radio?.volume.disabled" v-field-value="radio ? radio.volume.value : 80" data-i18n-aria-label />
<UiSlider id="radio-volume" hold @update:model-value="act('setRadioVolume', $event)" min="0" max="100" aria-label="Radio volume" :disabled="radio?.volume.disabled" :model-value="radio ? radio.volume.value : 80" data-i18n-aria-label />
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">{{ radio ? `${radio.volume.value}%` : '80%' }}</output>
</label>
<div id="radio-playback-state" class="radio-playback-state" :class="{ error: radio?.playback.error }" role="status" aria-live="polite">{{ radio ? radio.playback.text : t('Radio off') }}</div>
+2 -1
View File
@@ -6,6 +6,7 @@ import { R820T_GAIN_STEPS_DB } from '../../../sdr/gain.js';
import { localSdrGainOptionLabel } from '../../localSdrPresentation.js';
import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import UiSlider from '../../kit/UiSlider.js';
import { useRadioStore } from '../../stores/radio.js';
// The local RTL-SDR card in the Radio panel. LocalSdrControls presents it and
@@ -60,7 +61,7 @@ const tune = () => act('tuneSdr', frequency.value.value);
</div>
<label class="radio-volume-row" for="sdr-volume">
<span data-i18n>SDR VOL</span>
<input id="sdr-volume" @input="act('setSdrVolume', $event.target.value)" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" :disabled="sdr?.volumeDisabled" v-field-value="sdr ? sdr.volume : 80" data-i18n-aria-label />
<UiSlider id="sdr-volume" hold @update:model-value="act('setSdrVolume', $event)" min="0" max="100" aria-label="Local SDR volume" :disabled="sdr?.volumeDisabled" :model-value="sdr ? sdr.volume : 80" data-i18n-aria-label />
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">{{ sdr ? sdr.volumeText : '80%' }}</output>
</label>
<div id="sdr-status" class="radio-playback-state" :class="{ error: sdr?.statusError }" role="status" aria-live="polite">{{ sdr ? sdr.statusText : t('Connect an RTL-SDR to begin.') }}</div>