From 2e43bd9985579a25f1cb9a638987b846df55a236 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 13:06:04 -0800 Subject: [PATCH] feat(kit): render the remaining ranges with UiSlider UiSlider merges a caller's own input, change and blur handlers with its own, leaves the input alone on a null model, and gains unstyled for a range drawn as the browser's own. The rail card setting, observation timeline, imagery opacity, Street Level date and Bhote Koshi split and progress ranges now render through it. The timeline and the setting ranges put the handle back once their owner has answered, since a drag that ends where the slider last rendered leaves its model unchanged. The radio tuner keeps its own input: its owner moves the handle back to the selected station without changing the slot. --- CHANGELOG.md | 7 +-- docs/VUE-MIGRATION.md | 22 ++++---- scripts/package-boundaries.json | 4 +- src/ui/kit/UiSlider.js | 53 ++++++++++++------- src/ui/kit/UiSlider.test.mjs | 32 +++++++++++ src/ui/rail/RailCardSetting.vue | 23 +++++--- src/ui/rail/RailTimeline.vue | 20 ++++--- src/ui/railCards.test.mjs | 40 ++++++++++++++ src/ui/railTimeline.test.mjs | 26 +++++++++ src/ui/recentImagery/RecentImageryReadout.vue | 9 ++-- src/ui/views/panels/BhoteKoshiPanel.vue | 5 +- src/ui/views/panels/StreetLevelPanel.vue | 13 ++++- 12 files changed, 200 insertions(+), 54 deletions(-) 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 }} - +
{{ panel.beforeDate.date ? `${panel.beforeDate.name} · ` : panel.beforeDate.name }}{{ panel.beforeDate.date }} {{ panel.afterDate.date ? `${panel.afterDate.name} · ` : panel.afterDate.name }}{{ panel.afterDate.date }} @@ -40,7 +41,7 @@ const sliderValue = (event, scale) => Number(event.target.value) / scale; {{ panel.refs.timelineLabel.textContent }} {{ panel.refs.time.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, +};