diff --git a/CHANGELOG.md b/CHANGELOG.md index d32717dbd..f414857b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,8 @@ 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`. `syncScopeControls` + are the kit's new `UiChip`. The selects are the kit's new `UiSelect`. + `syncScopeControls` (`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue (samehkhamis). diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index d5fbc63fa..9e43e4030 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -65,6 +65,13 @@ tilt-map button, whose owners still write their state into the DOM. Core's pressable chips and pills (rail card chips, map sources, cities and places) are `UiButton`s too; `UiChip` is ui-vue's non-interactive label chip, used by the voice card. +`UiSelect` renders the bare native select (Core's labels sit outside it or +come as an `aria-label`) with ui-vue's `v-model`, `options` and `disabled`; +options that carry their own attributes come through the slot, a caller's +`@change` still reaches the select, and `hold` works as in `UiSlider`. +Without `hold`, a select takes its model back after its own change renders, +so a pick its owner refuses goes back; an owner that answers later (the SDR +gain) takes `hold`. Every select in the views is a `UiSelect`. `UiSlider` keeps the native range input where ui-vue draws a `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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 57f479111..9ae37e6b8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1134,6 +1134,8 @@ "src/ui/kit/UiButton.test.mjs", "src/ui/kit/UiChip.vue", "src/ui/kit/UiChip.test.mjs", + "src/ui/kit/UiSelect.js", + "src/ui/kit/UiSelect.test.mjs", "src/ui/rail/RailChips.vue", "src/ui/displayPanelActions.test.mjs", "src/testSupport/vueDom.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fb68740ae..8c56e857b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -135,7 +135,8 @@ "src/ui/rail/panelState.js", "src/sdr/gain.js", "src/ui/kit/UiSlider.js", - "src/ui/kit/UiChip.vue" + "src/ui/kit/UiChip.vue", + "src/ui/kit/UiSelect.js" ], "external": ["vue", "pinia"] }, diff --git a/src/annotations/drawTool.test.mjs b/src/annotations/drawTool.test.mjs index a866565b6..d955d2d6f 100644 --- a/src/annotations/drawTool.test.mjs +++ b/src/annotations/drawTool.test.mjs @@ -94,7 +94,7 @@ test('the Draw control markup carries the ids the tool binds to', () => { "act('toggleDraw')", "act('setDrawShape', $event)", "act('setDrawLabel', $event.target.value)", - "act('setDrawColor', $event.target.value)", + "act('setDrawColor', $event)", "act('clearDraw')", ]) { assert.ok(html.includes(action), `DisplayPanel must call ${action}`); diff --git a/src/ui/cctv/CctvPicker.vue b/src/ui/cctv/CctvPicker.vue index 719209cb2..a4b1a077c 100644 --- a/src/ui/cctv/CctvPicker.vue +++ b/src/ui/cctv/CctvPicker.vue @@ -1,5 +1,6 @@ diff --git a/src/ui/kit/UiSelect.js b/src/ui/kit/UiSelect.js new file mode 100644 index 000000000..7042003d1 --- /dev/null +++ b/src/ui/kit/UiSelect.js @@ -0,0 +1,68 @@ +import { h, mergeProps, nextTick, withDirectives } from 'vue'; +import { vFieldValue } from '../rail/fieldValue.js'; +import { vRangeValue } from '../rail/rangeValue.js'; + +// A null model leaves the select as it is. +const live = (el, binding) => { + if (binding.value != null) vRangeValue.updated(el, binding); +}; +const LIVE = { mounted: live, updated: live }; + +/** Put the model back on the select. */ +function takeModel(select, props) { + const next = props.modelValue; + if (next != null && select.value !== String(next)) + select.value = String(next); +} + +/** + * Core's select, with @halfpixel/ui-vue UiSelect's v-model, options and + * disabled so the two kits can merge. Core renders the bare select: its + * labels sit outside it or come as an `aria-label` attribute, so the attribute + * pass translates them. Options come from `options` ({ value, label }) or, + * where they carry their own attributes, the default slot. A change emits the + * chosen value; a caller's own `@change` still reaches the select. + * + * The select takes the model's value only when it differs (`v-range-value`), + * after its options are in place; a null model leaves it as it is. With + * `hold`, it never takes it while focused (`v-field-value`), and takes it back + * when it loses focus. Without `hold`, it takes the model back once the + * change's render has run: an owner that refuses a pick republishes the same + * value, which renders nothing, so this puts the refused pick back. The + * owners of these selects answer synchronously. Like UiSlider it is a functional render-function + * component. Not mirrored yet: `label` and `hideLabel`. + */ +export default function UiSelect(props, { attrs, emit, slots }) { + const options = props.options + ? props.options.map((option) => + h('option', { key: option.value, value: option.value }, option.label), + ) + : slots.default?.(); + const select = h( + 'select', + mergeProps(attrs, { + disabled: props.disabled, + onChange: (event) => { + const select = event.target; + emit('update:modelValue', select.value); + if (!props.hold) void nextTick(() => takeModel(select, props)); + }, + onBlur: props.hold + ? (event) => takeModel(event.target, props) + : undefined, + }), + options, + ); + return withDirectives(select, [ + [props.hold ? vFieldValue : LIVE, props.modelValue], + ]); +} + +UiSelect.props = { + modelValue: { type: [String, Number], default: undefined }, + options: { type: Array, default: undefined }, + disabled: { type: Boolean, default: false }, + hold: { type: Boolean, default: false }, +}; +UiSelect.emits = ['update:modelValue']; +UiSelect.inheritAttrs = false; diff --git a/src/ui/kit/UiSelect.test.mjs b/src/ui/kit/UiSelect.test.mjs new file mode 100644 index 000000000..3059e5138 --- /dev/null +++ b/src/ui/kit/UiSelect.test.mjs @@ -0,0 +1,132 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../../testSupport/vueDom.mjs'; + +let mount; +let vue; +let UiSelect; +let uninstall; +before(async () => { + uninstall = installVueDom(); + ({ mount } = await import('@vue/test-utils')); + vue = await import('vue'); + UiSelect = (await import('./UiSelect.js')).default; +}); +after(() => uninstall()); + +const OPTIONS = [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Bravo' }, + { value: 'c', label: 'Charlie' }, +]; + +function mountSelect(props = {}, attrs = {}) { + return mount(UiSelect, { + attachTo: document.body, + props: { options: OPTIONS, modelValue: 'b', ...props }, + attrs, + }); +} + +test('a bare select with the caller’s attributes and the model chosen', () => { + const wrapper = mountSelect( + {}, + { id: 'pick', class: 'gev-field', 'aria-label': 'Pick' }, + ); + const select = wrapper.get('select'); + assert.equal(select.attributes('class'), 'gev-field'); + assert.equal(select.attributes('id'), 'pick'); + assert.equal(select.attributes('aria-label'), 'Pick'); + assert.equal(select.attributes('value'), undefined); + assert.deepEqual( + [...select.element.options].map((o) => [o.value, o.textContent]), + [ + ['a', 'Alpha'], + ['b', 'Bravo'], + ['c', 'Charlie'], + ], + ); + assert.equal(select.element.value, 'b'); + assert.equal(select.element.disabled, false); + wrapper.unmount(); +}); + +test('a change emits the value, and a caller’s own change still runs', async () => { + const seen = []; + const wrapper = mountSelect( + {}, + { onChange: (event) => seen.push(event.target.value) }, + ); + const select = wrapper.get('select'); + select.element.value = 'c'; + await select.trigger('change'); + assert.deepEqual(wrapper.emitted('update:modelValue'), [['c']]); + assert.deepEqual(seen, ['c']); + wrapper.unmount(); +}); + +test('a refused pick goes back to the model', async () => { + const { nextTick } = vue; + const wrapper = mountSelect(); + const select = wrapper.get('select'); + select.element.value = 'c'; + await select.trigger('change'); + await nextTick(); + assert.equal(select.element.value, 'b'); + wrapper.unmount(); +}); + +test('the model picks the option, and a null model leaves the select', async () => { + const wrapper = mountSelect(); + const select = wrapper.get('select').element; + await wrapper.setProps({ modelValue: 'c' }); + assert.equal(select.value, 'c'); + select.value = 'a'; + await wrapper.setProps({ modelValue: null, disabled: true }); + assert.equal(select.value, 'a'); + assert.equal(select.disabled, true); + wrapper.unmount(); +}); + +test('a held select keeps a focused pick and takes the model back on blur', async () => { + const wrapper = mountSelect({ hold: true }); + const select = wrapper.get('select').element; + select.focus(); + select.value = 'a'; + await wrapper.setProps({ modelValue: 'c' }); + assert.equal(select.value, 'a'); + select.blur(); + await wrapper.get('select').trigger('blur'); + assert.equal(select.value, 'c'); + wrapper.unmount(); +}); + +test('slotted options render, and the model applies once they arrive', async () => { + const { defineComponent, h, ref, nextTick } = vue; + const options = ref(['x']); + const model = ref('x'); + const wrapper = mount( + defineComponent({ + setup: () => () => + h( + UiSelect, + { modelValue: model.value }, + { + default: () => + options.value.map((value) => + h('option', { key: value, value, 'data-i18n': '' }, value), + ), + }, + ), + }), + { attachTo: document.body }, + ); + const select = wrapper.get('select').element; + assert.equal(select.options[0].hasAttribute('data-i18n'), true); + options.value = ['x', 'y']; + model.value = 'y'; + await nextTick(); + assert.equal(select.options.length, 2); + assert.equal(select.value, 'y'); + wrapper.unmount(); +}); diff --git a/src/ui/views/LayerPanelsView.vue b/src/ui/views/LayerPanelsView.vue index 4026a6cd2..5bcecaec2 100644 --- a/src/ui/views/LayerPanelsView.vue +++ b/src/ui/views/LayerPanelsView.vue @@ -11,6 +11,7 @@ import SceneOptions from './SceneOptions.vue'; import SceneShotList from './SceneShotList.vue'; import CctvPanel from './panels/CctvPanel.vue'; import StreetLevelPanel from './panels/StreetLevelPanel.vue'; +import UiSelect from '../kit/UiSelect.js'; // Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents // its dialog through this store; without this view, neither is drawn. @@ -52,7 +53,7 @@ async function importChosen(event) {

SCENE

- + NEW SCENE DELETE
diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue index 3865d4090..5c55ae2fc 100644 --- a/src/ui/views/panels/DisplayPanel.vue +++ b/src/ui/views/panels/DisplayPanel.vue @@ -10,6 +10,7 @@ import UiSlider from '../../kit/UiSlider.js'; import GeoImport from './GeoImport.vue'; import { useDisplayStore } from '../../stores/display.js'; import UiButton from '../../kit/UiButton.vue'; +import UiSelect from '../../kit/UiSelect.js'; // The Display panel. VisualSettings presents the HUD, Sonar, Scope, // Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and @@ -68,11 +69,11 @@ const allocationOptions = [
Layout - +
Sonar @@ -176,13 +177,13 @@ const allocationOptions = [
- + {{ draw?.hint ?? '' }}
diff --git a/src/ui/views/panels/RadioPanel.vue b/src/ui/views/panels/RadioPanel.vue index b3947ba1c..1f3d6d992 100644 --- a/src/ui/views/panels/RadioPanel.vue +++ b/src/ui/views/panels/RadioPanel.vue @@ -5,11 +5,11 @@ import { t } from '../../../i18n/index.js'; import RailPanel from '../../rail/RailPanel.vue'; import { vClassToggle } from '../../rail/classToggle.js'; import UiSlider from '../../kit/UiSlider.js'; -import { vRangeValue } from '../../rail/rangeValue.js'; import { useRadioStore } from '../../stores/radio.js'; import RadioTuner from './RadioTuner.vue'; import SdrCard from './SdrCard.vue'; import UiButton from '../../kit/UiButton.vue'; +import UiSelect from '../../kit/UiSelect.js'; // The Radio panel inside Context. RadioControls presents the directory row, // station card, transport, volume and playback line and publishes them; @@ -41,9 +41,9 @@ const radioWords = {
{{ radio ? radio.enable.text : t('ENABLE') }} - +
{{ radio ? radio.station.name : t('NO STATION SELECTED') }} diff --git a/src/ui/views/panels/SdrCard.vue b/src/ui/views/panels/SdrCard.vue index 6b921731c..8bfb25e11 100644 --- a/src/ui/views/panels/SdrCard.vue +++ b/src/ui/views/panels/SdrCard.vue @@ -9,6 +9,7 @@ import { vFieldValue } from '../../rail/fieldValue.js'; import UiSlider from '../../kit/UiSlider.js'; import { useRadioStore } from '../../stores/radio.js'; import UiButton from '../../kit/UiButton.vue'; +import UiSelect from '../../kit/UiSelect.js'; // The local RTL-SDR card in the Radio panel. LocalSdrControls presents it and // publishes it; until it does, the card shows its initial markup. Its @@ -40,10 +41,10 @@ const tune = () => act('tuneSdr', frequency.value.value);
- +
{{ sdr ? sdr.stats.rate : '--' }} MSG/S diff --git a/src/ui/views/panels/VoiceControl.vue b/src/ui/views/panels/VoiceControl.vue index a1bef3b52..31230c436 100644 --- a/src/ui/views/panels/VoiceControl.vue +++ b/src/ui/views/panels/VoiceControl.vue @@ -6,6 +6,7 @@ import { useVoiceStore } from '../../stores/voice.js'; import VoiceCard from './VoiceCard.vue'; import VoiceVisualizer from './VoiceVisualizer.vue'; import UiButton from '../../kit/UiButton.vue'; +import UiSelect from '../../kit/UiSelect.js'; // The voice control in the command dock, as its presenter // (src/voice/control.js) holds it. The session owners write the presenter; @@ -52,7 +53,7 @@ const rootData = (root) => ({
@@ -96,10 +97,10 @@ const rootData = (root) => ({
- +