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, +};