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.
This commit is contained in:
Sameh Khamis committed 2026-11-01 13:06:04 -08:00
1 parent b9ecb9b9f5
commit 2e43bd9985
12 files changed
+200 -54

No files matched your search

+4 -3
View File
@@ -63,9 +63,10 @@
card's controls `LocalSdrControls`, through the radio store's new card's controls `LocalSdrControls`, through the radio store's new
`connect`/`act`; the card labels its gain options itself. `act` returns `connect`/`act`; the card labels its gain options itself. `act` returns
what the answering action returned. The Display, Radio, RTL-SDR, Cockpit, what the answering action returned. The Display, Radio, RTL-SDR, Cockpit,
mission replay and style parameter sliders are the kit's `UiSlider`, a mission replay, style parameter, rail card, observation timeline, imagery
native range input that emits numbers, and the pressed toggles are the opacity, Street Level date and Bhote Koshi sliders are the kit's
kit's `UiButton`, which gains `pressedClass`; so are the rail card `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 chips, map source chips and location pills (which now report
`aria-pressed`), and the voice card's chips `aria-pressed`), and the voice card's chips
are the kit's new `UiChip`. The selects are the kit's new `UiSelect`, the are the kit's new `UiChip`. The selects are the kit's new `UiSelect`, the
+12 -10
View File
@@ -37,9 +37,9 @@ one-panel rule too. Share links continue to record which panels are open.
## Component kit ## Component kit
`src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`, `src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`,
`UiButton`, `UiPanel`, `UiSegmented` and `UiSlider` so far, with more `UiButton`, `UiPanel`, `UiSegmented`, `UiSlider`, `UiChip`, `UiSelect`,
(`UiSelect`, `UiChip` and so on) joining as the controls they serve are `UiBadge` and `UiEmpty` so far, with more joining as the controls they serve
converted. One component are converted. One component
serves every use of a control, with props for the differences (`UiPanel`'s serves every use of a control, with props for the differences (`UiPanel`'s
`sideways`, `glow`, `scroll`), rather than a copy per panel. Names, props, `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 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; `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 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 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 back on blur) and `unstyled` (the browser's own range, as Bhote Koshi's
portable `./ui/input` rows use it without a component compiler. The are, without Core's slider surface). A caller's own input, change and blur
Display, Radio, RTL-SDR, Cockpit, mission replay and style parameter handlers still reach the input, and a null model leaves it as it is. It is
sliders are `UiSlider`s; Street Level's date slider, Bhote Koshi's split a functional render-function component, so the portable `./ui/input` rows
and progress ranges and the rail card settings' ranges keep their own use it without a component compiler. Every range in the views is a
inputs for now. Merging the kits means settling native input against the `UiSlider` except the radio tuner's: its owner moves the handle back to the
drawn track. 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 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 surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin
+3 -1
View File
@@ -673,7 +673,9 @@
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/kit/UiButton.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"] "external": ["vue"]
}, },
+34 -19
View File
@@ -1,10 +1,14 @@
import { h, withDirectives } from 'vue'; import { h, mergeProps, normalizeClass, withDirectives } from 'vue';
import { vFieldValue } from '../rail/fieldValue.js'; import { vFieldValue } from '../rail/fieldValue.js';
import { vRangeValue } from '../rail/rangeValue.js'; import { vRangeValue } from '../rail/rangeValue.js';
// Server-rendered markup carries the model as the input's value. // Server-rendered markup carries the model as the input's value.
const ssrValue = ({ value }) => (value == null ? {} : { 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 }; const HOLD = { ...vFieldValue, getSSRProps: ssrValue };
/** Put the model back on a held input that lost focus. */ /** 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 * 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"` * (its keyboard, form and styling) where ui-vue draws a `role="slider"`
* track. The input carries Core's `gev-quantitative-slider` class ahead of * 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 * the caller's (`unstyled` leaves it off, for a range drawn as the browser's
* replaced, and the accessible name comes as an `aria-label` attribute so * own), the caller's attributes come first, as in the markup this replaced,
* the attribute pass translates it. The value emits as a number. * 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 * 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 * 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 * 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 }) { export default function UiSlider(props, { attrs, emit }) {
const { class: className, ...rest } = attrs; const { class: className, ...rest } = attrs;
const classes = normalizeClass([
props.unstyled ? null : 'gev-quantitative-slider',
className,
]);
return withDirectives( return withDirectives(
h('input', { h(
...rest, 'input',
class: ['gev-quantitative-slider', className], mergeProps(rest, {
type: 'range', class: classes || undefined,
min: props.min, type: 'range',
max: props.max, min: props.min,
step: props.step, max: props.max,
disabled: props.disabled, step: props.step,
onInput: (event) => emit('update:modelValue', Number(event.target.value)), disabled: props.disabled,
onBlur: props.hold onInput: (event) =>
? (event) => takeModel(event.target, props) emit('update:modelValue', Number(event.target.value)),
: undefined, onBlur: props.hold
}), ? (event) => takeModel(event.target, props)
: undefined,
}),
),
[[props.hold ? HOLD : LIVE, props.modelValue]], [[props.hold ? HOLD : LIVE, props.modelValue]],
); );
} }
@@ -58,6 +72,7 @@ UiSlider.props = {
step: { type: [Number, String], default: undefined }, step: { type: [Number, String], default: undefined },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
hold: { type: Boolean, default: false }, hold: { type: Boolean, default: false },
unstyled: { type: Boolean, default: false },
}; };
UiSlider.emits = ['update:modelValue']; UiSlider.emits = ['update:modelValue'];
UiSlider.inheritAttrs = false; UiSlider.inheritAttrs = false;
+32
View File
@@ -105,3 +105,35 @@ test('an input stays until the owner answers, however late', async () => {
wrapper.unmount(); 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();
});
+17 -6
View File
@@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { vRangeValue } from './rangeValue.js';
import RailChips from './RailChips.vue'; import RailChips from './RailChips.vue';
import UiSlider from '../kit/UiSlider.js';
// One setting: a chip group and an optional range. The handle's value is held // One setting: a chip group and an optional range. The handle's value is held
// until the owner's next update; a range that commits on change keeps it // until the owner's next update; a range that commits on change keeps it
@@ -57,12 +57,23 @@ function rangeParams(input) {
return { params: { [current.param]: next } }; return { params: { [current.param]: next } };
} }
// The handle shows the value again once the owner has answered: an owner
// that clamps or refuses may leave the slider's model where it last
// rendered, and then it would not render.
function place(input) {
if (!range.value) return;
const next = String(value.value);
if (input.value !== next) input.value = next;
}
function onInput(event) { function onInput(event) {
const params = rangeParams(event.target); const params = rangeParams(event.target);
if (!params) return; if (!params) return;
// The readout follows the handle at once, whether or not the owner redraws. // The readout follows the handle at once, whether or not the owner redraws.
draft.value = { range: range.value, value: Number(event.target.value) }; draft.value = { range: range.value, value: Number(event.target.value) };
if (!range.value.commitOnChange) props.dispatch(params); if (range.value.commitOnChange) return;
props.dispatch(params);
place(event.target);
} }
function onChange(event) { function onChange(event) {
@@ -71,6 +82,7 @@ function onChange(event) {
// Committed: the next update shows the owner's value, as before. // Committed: the next update shows the owner's value, as before.
if (draft.value) draft.value = { ...draft.value, committed: true }; if (draft.value) draft.value = { ...draft.value, committed: true };
if (params) props.dispatch(params); if (params) props.dispatch(params);
place(event.target);
} }
</script> </script>
@@ -83,14 +95,13 @@ function onChange(event) {
><span class="rail-card-setting-range-label" :hidden="!range.label">{{ ><span class="rail-card-setting-range-label" :hidden="!range.label">{{
range.label || '' range.label || ''
}}</span }}</span
><input ><UiSlider
type="range" class="rail-card-setting-range"
class="gev-quantitative-slider rail-card-setting-range"
:data-range-id="range.id || setting.id" :data-range-id="range.id || setting.id"
:min="String(Number(range.min))" :min="String(Number(range.min))"
:max="String(Number(range.max))" :max="String(Number(range.max))"
:step="String(Number(range.step))" :step="String(Number(range.step))"
v-range-value="String(value)" :model-value="String(value)"
:disabled="Boolean(range.disabled)" :disabled="Boolean(range.disabled)"
:aria-label="range.ariaLabel || setting.label" :aria-label="range.ariaLabel || setting.label"
:aria-valuetext="valueText" :aria-valuetext="valueText"
+14 -6
View File
@@ -1,8 +1,8 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'; import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
import { vRangeValue } from './rangeValue.js';
import { formatUtcDayMonth, t } from '../../i18n/index.js'; import { formatUtcDayMonth, t } from '../../i18n/index.js';
import { utcClock } from './utcClock.js'; import { utcClock } from './utcClock.js';
import UiSlider from '../kit/UiSlider.js';
// Stable native transport with local preview and a coalesced 150 ms drag // 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 // 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 (typeof readout === 'string') shown.value = { ...shown.value, readout };
if (timer === null) timer = setTimeout(commit, 150); 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) { function change(event) {
if (props.state.disabled) return; if (props.state.disabled) return;
const index = Number(event.target.value); const index = Number(event.target.value);
@@ -95,12 +102,14 @@ function change(event) {
dragging = false; dragging = false;
commit(); commit();
sync(); sync();
place(event.target);
} }
function reset() { function reset(event) {
cancel(); cancel();
pending = null; pending = null;
dragging = false; dragging = false;
sync(); sync();
place(event?.target);
} }
function blur(event) { function blur(event) {
if (dragging) change(event); if (dragging) change(event);
@@ -122,13 +131,12 @@ const act = (fn) => {
:disabled="disabled || state.index <= 0" :disabled="disabled || state.index <= 0"
@click="act(() => onStep(-1))" @click="act(() => onStep(-1))"
>‹</button >‹</button
><input ><UiSlider
type="range" :class="sliderClassName || undefined"
:class="`gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`"
min="0" min="0"
:max="String(Math.max(0, shown.ticks.length - 1))" :max="String(Math.max(0, shown.ticks.length - 1))"
step="1" step="1"
v-range-value="String(shown.index)" :model-value="String(shown.index)"
:disabled="disabled" :disabled="disabled"
:aria-label="t('Observed history')" :aria-label="t('Observed history')"
:aria-valuetext="utc(shown.ticks[shown.index])" :aria-valuetext="utc(shown.ticks[shown.index])"
+40
View File
@@ -123,6 +123,46 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs
button.click(); button.click();
assert.deepEqual(calls, [2]); assert.deepEqual(calls, [2]);
}); });
test('a live setting range whose owner clamps shows the clamped value', async () => {
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 () => { test('setting ranges stay below presets and commit expensive filters on release', async () => {
const f = railFixture(); const f = railFixture();
const calls = []; const calls = [];
+26
View File
@@ -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'); assert.equal(slider.getAttribute('aria-valuetext'), '09-22 01:10 UTC');
view.destroy(); 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();
});
@@ -4,9 +4,9 @@ import { t } from '../../i18n/index.js';
import UiSegmented from '../kit/UiSegmented.vue'; import UiSegmented from '../kit/UiSegmented.vue';
import RailCardBlocks from '../rail/RailCardBlocks.vue'; import RailCardBlocks from '../rail/RailCardBlocks.vue';
import RailCards from '../rail/RailCards.vue'; import RailCards from '../rail/RailCards.vue';
import { vRangeValue } from '../rail/rangeValue.js';
import RecentImageryCard from './RecentImageryCard.vue'; import RecentImageryCard from './RecentImageryCard.vue';
import UiEmpty from '../kit/UiEmpty.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 // 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`. // the view and listens on the root; a mode choice calls `onSetMode`.
@@ -127,11 +127,10 @@ const modeOptions = computed(() =>
><label class="ri-opacity-label" for="ri-opacity" ><label class="ri-opacity-label" for="ri-opacity"
><span>{{ t('OPACITY') }}</span ><span>{{ t('OPACITY') }}</span
><span class="ri-opacity-value">{{ view.alpha }}%</span></label ><span class="ri-opacity-value">{{ view.alpha }}%</span></label
><input ><UiSlider
id="ri-opacity" id="ri-opacity"
v-range-value="view.alpha" :model-value="view.alpha"
type="range" class="ri-opacity"
class="gev-quantitative-slider ri-opacity"
min="0" min="0"
max="100" max="100"
step="1" step="1"
+3 -2
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { useBhoteKoshiPanelStore } from '../../stores/bhoteKoshiPanel.js'; import { useBhoteKoshiPanelStore } from '../../stores/bhoteKoshiPanel.js';
import UiSlider from '../../kit/UiSlider.js';
// The Bhote Koshi reconstruction controls, at the end of the context rail, // The Bhote Koshi reconstruction controls, at the end of the context rail,
// as the event layer (src/data/bhoteKoshiEvent.js) presents them. Clicks and // as the event layer (src/data/bhoteKoshiEvent.js) presents them. Clicks and
@@ -29,7 +30,7 @@ const sliderValue = (event, scale) => Number(event.target.value) / scale;
<span>{{ t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT') }}</span> <span>{{ t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT') }}</span>
<span data-role="split-readout">{{ panel.refs.splitReadout.textContent }}</span> <span data-role="split-readout">{{ panel.refs.splitReadout.textContent }}</span>
</div> </div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" :value="panel.refs.splitInput.value" :aria-label="t('Historical reference and post-event image split')" @input="call('setSplit', sliderValue($event, 100))" /> <UiSlider class="bhote-event-range" unstyled data-role="split" min="0" max="100" :model-value="panel.refs.splitInput.value" :aria-label="t('Historical reference and post-event image split')" @update:model-value="call('setSplit', $event / 100)" />
<div class="bhote-event-dates"> <div class="bhote-event-dates">
<span data-role="before-date">{{ panel.beforeDate.date ? `${panel.beforeDate.name} · ` : panel.beforeDate.name }}<span v-if="panel.beforeDate.date" class="bhote-event-date">{{ panel.beforeDate.date }}</span></span> <span data-role="before-date">{{ panel.beforeDate.date ? `${panel.beforeDate.name} · ` : panel.beforeDate.name }}<span v-if="panel.beforeDate.date" class="bhote-event-date">{{ panel.beforeDate.date }}</span></span>
<span data-role="after-date">{{ panel.afterDate.date ? `${panel.afterDate.name} · ` : panel.afterDate.name }}<span v-if="panel.afterDate.date" class="bhote-event-date">{{ panel.afterDate.date }}</span></span> <span data-role="after-date">{{ panel.afterDate.date ? `${panel.afterDate.name} · ` : panel.afterDate.name }}<span v-if="panel.afterDate.date" class="bhote-event-date">{{ panel.afterDate.date }}</span></span>
@@ -40,7 +41,7 @@ const sliderValue = (event, scale) => Number(event.target.value) / scale;
<span data-role="timeline-label">{{ panel.refs.timelineLabel.textContent }}</span> <span data-role="timeline-label">{{ panel.refs.timelineLabel.textContent }}</span>
<span data-role="time">{{ panel.refs.time.textContent }}</span> <span data-role="time">{{ panel.refs.time.textContent }}</span>
</div> </div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" :value="panel.refs.progressInput.value" :hidden="panel.refs.progressInput.hidden" :disabled="panel.refs.progressInput.disabled" :aria-label="panel.refs.progressInput.attributes['aria-label']" @input="call('previewProgress', sliderValue($event, 1000))" @change="call('commitProgress', sliderValue($event, 1000), { final: true })" /> <UiSlider class="bhote-event-range flood" unstyled data-role="progress" min="0" max="1000" :model-value="panel.refs.progressInput.value" :hidden="panel.refs.progressInput.hidden" :disabled="panel.refs.progressInput.disabled" :aria-label="panel.refs.progressInput.attributes['aria-label']" @update:model-value="call('previewProgress', $event / 1000)" @change="call('commitProgress', sliderValue($event, 1000), { final: true })" />
<div class="bhote-event-story-nav" :aria-label="t('Story beat navigation')"> <div class="bhote-event-story-nav" :aria-label="t('Story beat navigation')">
<button type="button" data-action="previous-beat" :title="t('Previous story beat')" :aria-label="panel.refs.previousButton.attributes['aria-label']" :disabled="panel.refs.previousButton.disabled" @click="call('previousBeat')">‹</button> <button type="button" data-action="previous-beat" :title="t('Previous story beat')" :aria-label="panel.refs.previousButton.attributes['aria-label']" :disabled="panel.refs.previousButton.disabled" @click="call('previousBeat')">‹</button>
<div class="bhote-event-story-current"> <div class="bhote-event-story-current">
+11 -2
View File
@@ -7,6 +7,7 @@ import UiSegmented from '../../kit/UiSegmented.vue';
import RailPanel from '../../rail/RailPanel.vue'; import RailPanel from '../../rail/RailPanel.vue';
import RailChips from '../../rail/RailChips.vue'; import RailChips from '../../rail/RailChips.vue';
import { useStreetLevelStore } from '../../stores/streetLevel.js'; import { useStreetLevelStore } from '../../stores/streetLevel.js';
import UiSlider from '../../kit/UiSlider.js';
// The Street Level panel, rendered from what StreetLevelControls presents. // The Street Level panel, rendered from what StreetLevelControls presents.
// Until it renders, the panel shows its initial markup. The viewer element // 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; el.value = index;
if (el.value === index) sinceLabel.value = since.label; 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,
};
</script> </script>
<template> <template>
@@ -159,7 +168,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
</div> </div>
<div id="sl-since-row" class="sl-since" :hidden="!shows.since"> <div id="sl-since-row" class="sl-since" :hidden="!shows.since">
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label> <label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
<input id="sl-since" v-thumb="view?.since" class="sl-range gev-quantitative-slider" type="range" min="0" max="8" step="1" value="0" :aria-valuetext="sinceLabel" aria-describedby="sl-since-label sl-since-note" @input="onSinceInput" @change="onSinceChange" /> <UiSlider id="sl-since" v-thumb="view?.since" class="sl-range" min="0" max="8" step="1" :aria-valuetext="sinceLabel" aria-describedby="sl-since-label sl-since-note" @input="onSinceInput" @change="onSinceChange" />
<span class="sl-since-value"> <span class="sl-since-value">
<output id="sl-since-label" class="sl-since-label" for="sl-since">{{ sinceLabel }}</output> <output id="sl-since-label" class="sl-since-label" for="sl-since">{{ sinceLabel }}</output>
<small id="sl-since-note" class="sl-since-note" :hidden="!view?.sinceNote">{{ view?.sinceNote || '' }}</small> <small id="sl-since-note" class="sl-since-note" :hidden="!view?.sinceNote">{{ view?.sinceNote || '' }}</small>