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

+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();
}
});