diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9a35f0fb..e30a553f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -63,9 +63,10 @@
card's controls `LocalSdrControls`, through the radio store's new
`connect`/`act`; the card labels its gain options itself. `act` returns
what the answering action returned. The Display, Radio, RTL-SDR, Cockpit,
- mission replay and style parameter sliders are the kit's `UiSlider`, a
- native range input that emits numbers, and the pressed toggles are the
- kit's `UiButton`, which gains `pressedClass`; so are the rail card
+ mission replay, style parameter, rail card, observation timeline, imagery
+ opacity, Street Level date and Bhote Koshi sliders are the kit's
+ `UiSlider`, a native range input that emits numbers, and the pressed
+ toggles are the kit's `UiButton`, which gains `pressedClass`; so are the rail card
chips, map source chips and location pills (which now report
`aria-pressed`), and the voice card's chips
are the kit's new `UiChip`. The selects are the kit's new `UiSelect`, the
diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index fddfdaa0d..950691aff 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -37,9 +37,9 @@ one-panel rule too. Share links continue to record which panels are open.
## Component kit
`src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`,
-`UiButton`, `UiPanel`, `UiSegmented` and `UiSlider` so far, with more
-(`UiSelect`, `UiChip` and so on) joining as the controls they serve are
-converted. One component
+`UiButton`, `UiPanel`, `UiSegmented`, `UiSlider`, `UiChip`, `UiSelect`,
+`UiBadge` and `UiEmpty` so far, with more joining as the controls they serve
+are converted. One component
serves every use of a control, with props for the differences (`UiPanel`'s
`sideways`, `glow`, `scroll`), rather than a copy per panel. Names, props,
events and class names follow Halfpixel's `ui-vue` kit so the two can merge
@@ -84,13 +84,15 @@ empty rows stay a paragraph and table cells, and no Core block matches
`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
number, and adds `hold` (never rewrite a focused handle, and take the model
-back on blur). It is a functional render-function component, so the
-portable `./ui/input` rows use it without a component compiler. The
-Display, Radio, RTL-SDR, Cockpit, mission replay and style parameter
-sliders are `UiSlider`s; Street Level's date slider, Bhote Koshi's split
-and progress ranges and the rail card settings' ranges keep their own
-inputs for now. Merging the kits means settling native input against the
-drawn track.
+back on blur) and `unstyled` (the browser's own range, as Bhote Koshi's
+are, without Core's slider surface). A caller's own input, change and blur
+handlers still reach the input, and a null model leaves it as it is. It is
+a functional render-function component, so the portable `./ui/input` rows
+use it without a component compiler. Every range in the views is a
+`UiSlider` except the radio tuner's: its owner moves the handle back to the
+selected station on a drag's first input without changing the slot, which
+a functional component would not render again. Merging the kits means
+settling native input against the drawn track.
Controls share one skin, so every panel reads the same. Every scrolling
surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 00e7cbf03..e33d96f57 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -673,7 +673,9 @@
"src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue",
"src/ui/kit/UiButton.vue",
- "src/ui/kit/UiBadge.vue"
+ "src/ui/kit/UiBadge.vue",
+ "src/ui/rail/fieldValue.js",
+ "src/ui/kit/UiSlider.js"
],
"external": ["vue"]
},
diff --git a/src/ui/kit/UiSlider.js b/src/ui/kit/UiSlider.js
index dbed86315..e2865d16a 100644
--- a/src/ui/kit/UiSlider.js
+++ b/src/ui/kit/UiSlider.js
@@ -1,10 +1,14 @@
-import { h, withDirectives } from 'vue';
+import { h, mergeProps, normalizeClass, 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 };
+// A null model leaves the input as it is.
+const live = (el, binding) => {
+ if (binding.value != null) vRangeValue.updated(el, binding);
+};
+const LIVE = { mounted: live, updated: live, getSSRProps: ssrValue };
const HOLD = { ...vFieldValue, getSSRProps: ssrValue };
/** Put the model back on a held input that lost focus. */
@@ -18,11 +22,14 @@ function takeModel(input, props) {
* 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 caller's (`unstyled` leaves it off, for a range drawn as the browser's
+ * own), 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, and a caller's own input,
+ * change or blur handler still reaches the input.
*
- * The input takes the model's value only when it differs (`v-range-value`);
+ * The input takes the model's value only when it differs (`v-range-value`),
+ * and a null model leaves it as it is;
* 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
@@ -33,20 +40,27 @@ function takeModel(input, props) {
*/
export default function UiSlider(props, { attrs, emit }) {
const { class: className, ...rest } = attrs;
+ const classes = normalizeClass([
+ props.unstyled ? null : 'gev-quantitative-slider',
+ className,
+ ]);
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,
- }),
+ h(
+ 'input',
+ mergeProps(rest, {
+ class: classes || undefined,
+ 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]],
);
}
@@ -58,6 +72,7 @@ UiSlider.props = {
step: { type: [Number, String], default: undefined },
disabled: { type: Boolean, default: false },
hold: { type: Boolean, default: false },
+ unstyled: { type: Boolean, default: false },
};
UiSlider.emits = ['update:modelValue'];
UiSlider.inheritAttrs = false;
diff --git a/src/ui/kit/UiSlider.test.mjs b/src/ui/kit/UiSlider.test.mjs
index 329a015a1..380ae96db 100644
--- a/src/ui/kit/UiSlider.test.mjs
+++ b/src/ui/kit/UiSlider.test.mjs
@@ -105,3 +105,35 @@ test('an input stays until the owner answers, however late', async () => {
wrapper.unmount();
}
});
+
+test('a caller’s own input, change and blur handlers still run', async () => {
+ const seen = [];
+ const wrapper = mountSlider(
+ {},
+ {
+ onInput: (event) => seen.push(['input', event.target.value]),
+ onChange: () => seen.push(['change']),
+ onBlur: () => seen.push(['blur']),
+ },
+ );
+ const input = wrapper.get('input');
+ input.element.value = '55';
+ await input.trigger('input');
+ await input.trigger('change');
+ await input.trigger('blur');
+ assert.deepEqual(seen, [['input', '55'], ['change'], ['blur']]);
+ assert.deepEqual(wrapper.emitted('update:modelValue'), [[55]]);
+ wrapper.unmount();
+});
+
+test('a null model leaves the input, and unstyled leaves Core’s class off', async () => {
+ const wrapper = mountSlider({ modelValue: null, unstyled: true });
+ const input = wrapper.get('input');
+ assert.equal(input.attributes('class'), undefined);
+ input.element.value = '12';
+ await wrapper.setProps({ disabled: true });
+ assert.equal(input.element.value, '12');
+ await wrapper.setProps({ unstyled: false });
+ assert.equal(input.attributes('class'), 'gev-quantitative-slider');
+ wrapper.unmount();
+});
diff --git a/src/ui/rail/RailCardSetting.vue b/src/ui/rail/RailCardSetting.vue
index d26836537..49422268f 100644
--- a/src/ui/rail/RailCardSetting.vue
+++ b/src/ui/rail/RailCardSetting.vue
@@ -1,7 +1,7 @@
@@ -83,14 +95,13 @@ function onChange(event) {
>{{
range.label || ''
}}
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
-import { vRangeValue } from './rangeValue.js';
import { formatUtcDayMonth, t } from '../../i18n/index.js';
import { utcClock } from './utcClock.js';
+import UiSlider from '../kit/UiSlider.js';
// Stable native transport with local preview and a coalesced 150 ms drag
// commit. While the handle is dragged the slider keeps the ticks it was
@@ -88,6 +88,13 @@ function preview(event) {
if (typeof readout === 'string') shown.value = { ...shown.value, readout };
if (timer === null) timer = setTimeout(commit, 150);
}
+// The handle shows the shown index again. A drag that ends where it began
+// (an input and a cancel, or a refused commit, before the next render)
+// leaves the slider's model unchanged, so it would not render.
+const place = (input) => {
+ const index = String(shown.value.index);
+ if (input && input.value !== index) input.value = index;
+};
function change(event) {
if (props.state.disabled) return;
const index = Number(event.target.value);
@@ -95,12 +102,14 @@ function change(event) {
dragging = false;
commit();
sync();
+ place(event.target);
}
-function reset() {
+function reset(event) {
cancel();
pending = null;
dragging = false;
sync();
+ place(event?.target);
}
function blur(event) {
if (dragging) change(event);
@@ -122,13 +131,12 @@ const act = (fn) => {
:disabled="disabled || state.index <= 0"
@click="act(() => onStep(-1))"
>‹ {
+ const f = railFixture();
+ const model = (value) => ({
+ ...card,
+ blocks: [
+ {
+ id: 'settings',
+ type: 'settings',
+ settings: [
+ {
+ id: 'image',
+ label: 'IMAGE',
+ range: {
+ id: 'gain',
+ param: 'gain',
+ min: 0,
+ max: 100,
+ step: 1,
+ value,
+ ariaLabel: 'Gain',
+ },
+ },
+ ],
+ },
+ ],
+ });
+ // The owner takes at most 80 and answers at once.
+ const view = createRailCards({
+ ...f,
+ onParams: (_id, params) => view.update([model(Math.min(80, params.gain))]),
+ });
+ view.update([model(80)]);
+ await nextTick();
+ const slider = f.find((node) => node.dataset.rangeId === 'gain');
+ slider.value = '95';
+ slider.dispatchEvent(new Event('input'));
+ await nextTick();
+ assert.equal(slider.value, '80');
+ view.destroy();
+});
test('setting ranges stay below presets and commit expensive filters on release', async () => {
const f = railFixture();
const calls = [];
diff --git a/src/ui/railTimeline.test.mjs b/src/ui/railTimeline.test.mjs
index edbdbc7fe..9e408bdf3 100644
--- a/src/ui/railTimeline.test.mjs
+++ b/src/ui/railTimeline.test.mjs
@@ -150,3 +150,29 @@ test('an update from inside the preview neither moves the handle back nor surviv
assert.equal(slider.getAttribute('aria-valuetext'), '09-22 01:10 UTC');
view.destroy();
});
+test('a drag that ends where it began puts the handle back on the shown index', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const f = railFixture();
+ const commits = [];
+ const view = createRailTimeline({
+ ...f,
+ onCommit: (tick) => commits.push(tick),
+ });
+ view.update(props);
+ await nextTick();
+ const slider = f.find((n) => n.tagName === 'INPUT');
+ slider.value = '0';
+ slider.dispatchEvent(new Event('input'));
+ slider.dispatchEvent(new Event('pointercancel'));
+ await nextTick();
+ assert.equal(slider.value, '2', 'a cancelled drag');
+ assert.deepEqual(commits, []);
+ // A refused commit: the owner keeps its index.
+ slider.value = '1';
+ slider.dispatchEvent(new Event('input'));
+ slider.dispatchEvent(new Event('change'));
+ await nextTick();
+ assert.deepEqual(commits, [ticks[1]]);
+ assert.equal(slider.value, '2', 'a refused commit');
+ view.destroy();
+});
diff --git a/src/ui/recentImagery/RecentImageryReadout.vue b/src/ui/recentImagery/RecentImageryReadout.vue
index bd69fc01a..fff130555 100644
--- a/src/ui/recentImagery/RecentImageryReadout.vue
+++ b/src/ui/recentImagery/RecentImageryReadout.vue
@@ -4,9 +4,9 @@ import { t } from '../../i18n/index.js';
import UiSegmented from '../kit/UiSegmented.vue';
import RailCardBlocks from '../rail/RailCardBlocks.vue';
import RailCards from '../rail/RailCards.vue';
-import { vRangeValue } from '../rail/rangeValue.js';
import RecentImageryCard from './RecentImageryCard.vue';
import UiEmpty from '../kit/UiEmpty.vue';
+import UiSlider from '../kit/UiSlider.js';
// Every block exists from the start, in its final order. The owner computes
// the view and listens on the root; a mode choice calls `onSetMode`.
@@ -127,11 +127,10 @@ const modeOptions = computed(() =>
> Number(event.target.value) / scale;
{{ t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT') }}{{ panel.refs.splitReadout.textContent }}
-
+
diff --git a/src/ui/views/panels/StreetLevelPanel.vue b/src/ui/views/panels/StreetLevelPanel.vue
index 2289cb5c3..7bdde34a4 100644
--- a/src/ui/views/panels/StreetLevelPanel.vue
+++ b/src/ui/views/panels/StreetLevelPanel.vue
@@ -7,6 +7,7 @@ import UiSegmented from '../../kit/UiSegmented.vue';
import RailPanel from '../../rail/RailPanel.vue';
import RailChips from '../../rail/RailChips.vue';
import { useStreetLevelStore } from '../../stores/streetLevel.js';
+import UiSlider from '../../kit/UiSlider.js';
// The Street Level panel, rendered from what StreetLevelControls presents.
// Until it renders, the panel shows its initial markup. The viewer element
@@ -116,7 +117,15 @@ const placeThumb = (el, { value: since, oldValue }) => {
el.value = index;
if (el.value === index) sinceLabel.value = since.label;
};
-const vThumb = { mounted: placeThumb, updated: placeThumb };
+// The thumb starts at the first stop: a value attribute on the slider would
+// be written again on each of its renders.
+const vThumb = {
+ mounted(el, binding) {
+ el.value = '0';
+ placeThumb(el, binding);
+ },
+ updated: placeThumb,
+};
@@ -159,7 +168,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };