diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index 621c1fe06..8f2f7ac4f 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -96,11 +96,12 @@ They contain no application state.
descriptor before acting), Display (in progress: `DisplayPanel` renders
the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the
`gev-display` store, which VisualSettings publishes to, and the 3D
- aircraft group and the Draw controls, which AircraftDisplay and the draw
- tool publish beside it; the style parameter rows render through Vue
- behind `createStyleParameters()`, as a render function so the portable
- `./ui/input` entry needs no component compiler; the other groups follow
- one owner at a time),
+ aircraft group, the Draw controls and the imported file list, which
+ AircraftDisplay, the draw tool and the file import publish beside it; the
+ file import restores keyboard focus once the list has rendered; the
+ style parameter rows render through Vue behind `createStyleParameters()`,
+ as a render function so the portable `./ui/input` entry needs no
+ component compiler; the other groups follow one owner at a time),
Context and Radio, the command dock,
the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead.
diff --git a/src/app/tools.js b/src/app/tools.js
index c200abbba..943815a68 100644
--- a/src/app/tools.js
+++ b/src/app/tools.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
@@ -73,6 +74,10 @@ export function createApplicationTools({
navigation: styleManager,
ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
+ publish: (view) => {
+ displayStore.publishGeoImport(view);
+ return nextTick();
+ },
});
defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs
index b666774f5..602cc902f 100644
--- a/src/ui/displayPanel.test.mjs
+++ b/src/ui/displayPanel.test.mjs
@@ -9,6 +9,7 @@ import { useDisplayStore } from './stores/display.js';
import DisplayPanel from './views/panels/DisplayPanel.vue';
import { AircraftDisplay } from './aircraftDisplay.js';
import { VisualSettings } from './visualSettings.js';
+import { initGeoFileImport } from './geoFileImport.js';
function fixture(t) {
document.body.replaceChildren();
@@ -370,3 +371,88 @@ test('the Draw controls render what the draw tool publishes', async (t) => {
assert.equal($('draw-mode-row').classList.contains('visible'), false);
assert.equal($('draw-label-row').classList.contains('visible'), false);
});
+
+test('the imported files render what the file import publishes and keep focus', async (t) => {
+ const { store, $ } = fixture(t);
+ const list = $('geo-import-list');
+ assert.equal(list.className, 'geo-import-list');
+ assert.equal(list.children.length, 0);
+ assert.equal($('geo-import-hint').textContent, '');
+
+ const viewer = {
+ container: document.createElement('div'),
+ scene: { requestRender() {} },
+ dataSources: { add: async (ds) => ds, remove: () => true },
+ };
+ const tool = initGeoFileImport({
+ viewer,
+ load: async (file) => ({
+ dataSource: { show: true },
+ name: file.name,
+ format: file.name.endsWith('.kml') ? 'kml' : 'geojson',
+ entityCount: 2,
+ removed: 0,
+ revoke() {},
+ }),
+ flyToSource() {},
+ publish: (view) => {
+ store.publishGeoImport(view);
+ return nextTick();
+ },
+ });
+ t.after(() => tool.destroy());
+ await tool.importFiles([{ name: 'a.geojson' }, { name: 'b.kml' }]);
+ await nextTick();
+ assert.equal(list.className, 'geo-import-list visible');
+ assert.equal($('geo-import-hint').textContent, 'Added 2 files.');
+ const rowsNow = () => [...list.querySelectorAll('.geo-import-row')];
+ const [first, second] = rowsNow();
+ assert.equal(first.children.length, 5);
+ assert.equal(
+ [...first.childNodes].some((node) => node.nodeType === 3 && node.data),
+ false,
+ 'no text between the parts',
+ );
+ const [swatch, name, toggle] = first.children;
+ assert.equal(swatch.className, 'geo-import-swatch');
+ assert.ok(swatch.style.background);
+ assert.equal(name.textContent, 'a.geojson');
+ assert.equal(name.children.length, 0, 'the name is text');
+ assert.match(name.title, /GEOJSON, 2 map objects$/);
+ assert.equal(toggle.className, 'geo-import-action');
+ assert.equal(toggle.getAttribute('type'), 'button');
+ assert.equal(toggle.getAttribute('aria-pressed'), 'true');
+ assert.equal(toggle.getAttribute('aria-label'), 'Hide a.geojson');
+ assert.equal(toggle.textContent, 'visibility');
+ assert.equal(first.children[3].hasAttribute('aria-pressed'), false);
+ assert.equal(second.children[0].className, 'geo-import-swatch own-styles');
+ assert.equal(second.children[0].getAttribute('style'), null);
+
+ const button = (row, action) =>
+ row.querySelector(`button[data-action="${action}"]`);
+ // The click lands on the icon inside the button, as in a browser.
+ const press = async (el) => {
+ el.focus();
+ el.firstElementChild.click();
+ await nextTick();
+ await nextTick();
+ };
+ await press(button(first, 'toggle'));
+ assert.equal(rowsNow()[0] === first, true, 'the row is kept');
+ assert.equal(document.activeElement === button(first, 'toggle'), true);
+ assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'false');
+ assert.equal(button(first, 'toggle').textContent, 'visibility_off');
+ assert.equal(button(first, 'toggle').title, 'Show a.geojson');
+ await press(button(first, 'toggle'));
+ assert.equal(document.activeElement === button(first, 'toggle'), true);
+ assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'true');
+ assert.equal(button(first, 'fly').title, 'Fly to a.geojson');
+ assert.equal(button(first, 'remove').getAttribute('aria-label'), 'Remove a.geojson');
+
+ await press(button(first, 'remove'));
+ assert.equal(rowsNow().length, 1);
+ assert.equal(document.activeElement === button(second, 'remove'), true);
+ await press(button(second, 'remove'));
+ assert.equal(list.className, 'geo-import-list');
+ assert.equal(document.activeElement === $('geo-import-button'), true);
+});
diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js
index 8785f24fd..9390cbaf9 100644
--- a/src/ui/geoFileImport.js
+++ b/src/ui/geoFileImport.js
@@ -20,8 +20,11 @@ const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
/**
* Wire the Import control.
- * @param {{viewer: object, load?: Function, document?: Document}} deps
+ * @param {{viewer: object, load?: Function, document?: Document,
+ * publish?: Function}} deps
* `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double.
+ * `publish({rows, hint})` hands the file list and hint to the view that
+ * renders them; it may return a promise that settles once they render.
* @returns {{destroy: Function, importFiles: Function, list: Function,
* setVisible: Function, flyTo: Function, remove: Function,
* diagnostics: Function}|null}
@@ -34,11 +37,11 @@ export function initGeoFileImport({
navigation = null,
ground = null,
subscribeMapSource = null,
+ publish = () => {},
}) {
const button = doc?.getElementById('geo-import-button');
const input = doc?.getElementById('geo-import-input');
const listEl = doc?.getElementById('geo-import-list');
- const hint = doc?.getElementById('geo-import-hint');
if (!viewer || !button || !input || !listEl) return null;
const imports = [];
@@ -49,6 +52,7 @@ export function initGeoFileImport({
let destroyed = false;
let dragDepth = 0;
let cameraIntent = 0;
+ let hint = '';
const lifetime = new AbortController();
let removeMapSource;
// Imports live for the page session: switching language (a reload) asks
@@ -63,7 +67,8 @@ export function initGeoFileImport({
listeners.push([target, type, listener, options]);
};
const setHint = (text) => {
- if (hint) hint.textContent = text || '';
+ hint = text || '';
+ present();
};
const render = () => viewer.scene?.requestRender?.();
let groundTimer,
@@ -121,77 +126,86 @@ export function initGeoFileImport({
if (!active || !listEl.contains?.(active)) return null;
const row = active.closest?.('[data-import-id]');
return row
- ? { id: row.dataset.importId, action: active.dataset?.action }
+ ? {
+ id: row.dataset.importId,
+ action: active.dataset?.action,
+ element: active,
+ next: [],
+ }
: null;
}
function restoreFocus(was) {
- if (!was) return;
+ // Focus moved on while the list rendered: leave it where it went.
+ const active = doc.activeElement;
+ if (active && active !== was.element && active !== doc.body) return;
const rows = [...listEl.querySelectorAll('[data-import-id]')];
- let row = rows.find((r) => r.dataset.importId === was.id);
+ const rowOf = (id) => rows.find((r) => r.dataset.importId === id);
// The row is gone (removed): the next row's same control, else the
// previous row's, else the Import button.
- if (!row && was.index != null)
- row = rows[Math.min(was.index, rows.length - 1)];
+ const row = [was.id, ...was.next].map(rowOf).find(Boolean);
const target =
row?.querySelector?.(`button[data-action="${was.action}"]`) ||
(row ? row.querySelector?.('button') : button);
target?.focus?.();
}
- function renderList(keep = focusedAction()) {
- listEl.replaceChildren(
- ...imports.map((item) => {
- const row = doc.createElement('li');
- row.className = 'geo-import-row';
- row.dataset.importId = item.id;
- const swatch = doc.createElement('span');
+ /** Publish the file list and hint; the view renders them. */
+ function present() {
+ return publish({
+ rows: imports.map((item) => ({
+ id: item.id,
+ name: item.name,
// KML and KMZ draw with their own styles; their swatch says so
// instead of claiming a color the globe does not show.
- swatch.className = `geo-import-swatch${item.ownStyles ? ' own-styles' : ''}`;
- if (!item.ownStyles) swatch.style.background = item.color;
- const name = doc.createElement('span');
- name.className = 'geo-import-name';
- // textContent, never markup: the name comes from the file system.
- name.textContent = item.name;
- name.title = `${item.name} — ${item.format.toUpperCase()}, ${tn(
+ ownStyles: item.ownStyles,
+ color: item.color,
+ title: `${item.name} — ${item.format.toUpperCase()}, ${tn(
item.entityCount,
'{count} map object',
'{count} map objects',
{ count: formatNumber(item.entityCount) },
- )}${item.ownStyles ? t(', drawn with its own styles') : ''}`;
- const actions = [
- [
- item.visible ? 'visibility' : 'visibility_off',
- item.visible
+ )}${item.ownStyles ? t(', drawn with its own styles') : ''}`,
+ actions: [
+ {
+ icon: item.visible ? 'visibility' : 'visibility_off',
+ label: item.visible
? t('Hide {name}', { name: item.name })
: t('Show {name}', { name: item.name }),
- 'toggle',
- ],
- ['my_location', t('Fly to {name}', { name: item.name }), 'fly'],
- ['close', t('Remove {name}', { name: item.name }), 'remove'],
- ].map(([icon, label, action]) => {
- const btn = doc.createElement('button');
- btn.type = 'button';
- btn.className = 'geo-import-action';
- btn.dataset.action = action;
- btn.title = label;
- btn.setAttribute('aria-label', label);
- if (action === 'toggle')
- btn.setAttribute('aria-pressed', String(item.visible));
- const glyph = doc.createElement('span');
- glyph.className = 'material-symbols-outlined';
- glyph.setAttribute('aria-hidden', 'true');
- glyph.textContent = icon;
- btn.append(glyph);
- return btn;
- });
- row.append(swatch, name, ...actions);
- return row;
- }),
- );
- listEl.classList.toggle('visible', imports.length > 0);
- restoreFocus(keep);
+ action: 'toggle',
+ pressed: String(item.visible),
+ },
+ {
+ icon: 'my_location',
+ label: t('Fly to {name}', { name: item.name }),
+ action: 'fly',
+ },
+ {
+ icon: 'close',
+ label: t('Remove {name}', { name: item.name }),
+ action: 'remove',
+ },
+ ],
+ })),
+ hint,
+ });
+ }
+
+ // The focus to give back once the view renders; a second change before
+ // then keeps the first, whose control the page still shows.
+ let pendingFocus = null;
+
+ function renderList(keep = focusedAction()) {
+ pendingFocus ||= keep;
+ const rendered = present();
+ const was = pendingFocus;
+ // A removed row leaves the page only once the view renders.
+ if (was)
+ Promise.resolve(rendered).then(() => {
+ if (destroyed || pendingFocus !== was) return;
+ pendingFocus = null;
+ restoreFocus(was);
+ });
}
const find = (id) => imports.find((item) => item.id === id) || null;
@@ -249,7 +263,11 @@ export function initGeoFileImport({
const at = imports.findIndex((item) => item.id === id);
if (at < 0) return false;
const keep = focusedAction();
- if (keep) keep.index = at;
+ if (keep)
+ keep.next = [
+ ...imports.slice(at + 1),
+ ...imports.slice(0, at).reverse(),
+ ].map((other) => other.id);
const [item] = imports.splice(at, 1);
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
@@ -452,8 +470,6 @@ export function initGeoFileImport({
for (const [target, type, listener, options] of listeners.splice(0))
target.removeEventListener(type, listener, options);
setDragging(false);
- listEl.replaceChildren();
- listEl.classList.remove('visible');
setHint('');
if (globalThis.window?.__gevGeoImport === handle)
delete globalThis.window.__gevGeoImport;
diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs
index ee66bf84f..570adbb9f 100644
--- a/src/ui/geoFileImport.test.mjs
+++ b/src/ui/geoFileImport.test.mjs
@@ -1,8 +1,9 @@
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against
// fake DOM and viewer doubles with an injected loader, and its observable
-// effects are asserted — data sources added and removed, list rows, the hint,
-// drop handling, the file limit, and what destroy() gives back. Real parsing
-// and rendering run in `scripts/qa-geo-import.mjs`.
+// effects are asserted — data sources added and removed, the rows and hint it
+// publishes, drop handling, the file limit, and what destroy() gives back.
+// The Display panel renders the published list (`displayPanel.test.mjs`);
+// real parsing and rendering run in `scripts/qa-geo-import.mjs`.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -135,7 +136,13 @@ class FakeElement {
}
}
-function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
+function setup({
+ load,
+ navigation,
+ flyToSource,
+ subscribeMapSource,
+ rendered,
+} = {}) {
focus.active = null;
const els = {
'geo-import-button': new FakeElement('button', 'geo-import-button'),
@@ -156,6 +163,7 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
const removed = [];
const flights = [];
let renders = 0;
+ let shown = null;
const viewer = {
container: new FakeElement('div', 'cesiumContainer'),
scene: { requestRender: () => (renders += 1) },
@@ -193,6 +201,10 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
...(navigation ? { navigation } : {}),
...(subscribeMapSource ? { subscribeMapSource } : {}),
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
+ publish: (view) => {
+ shown = view;
+ return rendered?.();
+ },
});
return {
tool,
@@ -205,12 +217,25 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
revoked,
loads,
renders: () => renders,
+ shown: () => shown,
};
}
const file = (name, extra = {}) => ({ name, size: 10, ...extra });
-const rows = (els) => els['geo-import-list'].children;
-const action = (row, name) =>
- row.children.find((c) => c.dataset.action === name);
+const rows = (t) => t.shown().rows;
+const action = (row, name) => row.actions.find((a) => a.action === name);
+// A click on a row button, as the list's delegated listener receives it.
+const press = (t, row, name) =>
+ t.els['geo-import-list'].emit('click', {
+ target: {
+ closest: (selector) =>
+ selector === 'button[data-action]'
+ ? {
+ dataset: { action: name },
+ closest: () => ({ dataset: { importId: row.id } }),
+ }
+ : null,
+ },
+ });
// Runs first: tools left by later tests would answer the same registry.
test('imported files make the language switch ask first', async () => {
@@ -256,24 +281,19 @@ test('the Import button opens the picker and a pick imports each file', async ()
t.loads[1].opts.color,
'each file gets its own color',
);
- assert.equal(rows(t.els).length, 2);
- assert.ok(t.els['geo-import-list'].classList.contains('visible'));
+ assert.equal(rows(t).length, 2);
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to');
- assert.equal(t.els['geo-import-hint'].textContent, 'Added 2 files.');
+ assert.equal(t.shown().hint, 'Added 2 files.');
assert.deepEqual(
t.tool.list().map((f) => f.name),
['a.geojson', 'b.kml'],
);
});
-test('file names are text, never markup', async () => {
+test('file names are published as given, for the view to show as text', async () => {
const t = setup();
await t.tool.importFiles([file('
.kml')]);
- const name = rows(t.els)[0].children.find((c) =>
- c.classList.contains('geo-import-name'),
- );
- assert.equal(name.textContent, '
.kml');
- assert.equal(name.innerHTML, undefined);
+ assert.equal(rows(t)[0].name, '
.kml');
});
test('row buttons hide, show, fly to and remove a file', async () => {
@@ -281,19 +301,14 @@ test('row buttons hide, show, fly to and remove a file', async () => {
await t.tool.importFiles([file('a.gpx')]);
const [{ id }] = t.tool.list();
const ds = t.added[0];
- // the click lands on the glyph inside the button, as in a browser
- const glyphClick = (name) =>
- t.els['geo-import-list'].emit('click', {
- target: action(rows(t.els)[0], name).children[0],
- });
+ const glyphClick = (name) => press(t, rows(t)[0], name);
assert.equal(ds.show, true);
glyphClick('toggle');
assert.equal(ds.show, false);
assert.equal(t.tool.list()[0].visible, false);
- assert.equal(
- action(rows(t.els)[0], 'toggle').getAttribute('aria-pressed'),
- 'false',
- );
+ assert.equal(action(rows(t)[0], 'toggle').pressed, 'false');
+ assert.equal(action(rows(t)[0], 'toggle').icon, 'visibility_off');
+ assert.equal(action(rows(t)[0], 'toggle').label, 'Show a.gpx');
glyphClick('fly');
assert.equal(ds.show, true, 'flying to a hidden file shows it');
assert.equal(t.flights.at(-1), ds);
@@ -301,7 +316,7 @@ test('row buttons hide, show, fly to and remove a file', async () => {
assert.deepEqual(t.removed, [{ ds, destroy: true }]);
assert.deepEqual(t.revoked, ['a.gpx']);
assert.equal(t.tool.list().length, 0);
- assert.ok(!t.els['geo-import-list'].classList.contains('visible'));
+ assert.deepEqual(rows(t), []);
assert.equal(t.tool.remove(id), false);
});
@@ -319,7 +334,7 @@ test('a refused file says why and does not stop the rest of the batch', async ()
assert.equal(out[0].error, 'That KML file is not valid XML.');
assert.match(out[1].error, /could not be read\./);
assert.match(
- t.els['geo-import-hint'].textContent,
+ t.shown().hint,
/^Added 1 file\. 2 files could not be added \(bad\.kml: That KML file is not valid XML\.\)/,
);
assert.equal(t.tool.list().length, 1);
@@ -387,7 +402,7 @@ test('destroy removes every file and listener and the window handle', async () =
t.els['geo-import-list'],
])
assert.equal(el.listeners.length, 0);
- assert.equal(rows(t.els).length, 0);
+ assert.deepEqual(t.shown(), { rows: [], hint: '' });
assert.equal(globalThis.window.__gevGeoImport, undefined);
assert.equal(t.tool.diagnostics().destroyed, true);
} finally {
@@ -436,6 +451,11 @@ test('the application composition wires the import and owns its teardown', () =>
/navigation: styleManager/,
);
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
+ // The list renders from the store once Vue has flushed.
+ assert.match(
+ tools.slice(importAt, importAt + 400),
+ /displayStore\.publishGeoImport\(view\);\s*return nextTick\(\);/,
+ );
const markup = fs.readFileSync(
new URL('./views/panels/DisplayPanel.vue', import.meta.url),
'utf8',
@@ -665,32 +685,83 @@ test('a refused deferred stamp (Cockpit) imports without flying', async () => {
assert.equal(flown.length, 0);
});
-test('keyboard focus stays on the control after hide, show and remove', async () => {
- const t = setup();
- await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
- // Clicks reach the list's delegated listener, as they bubble in a page.
- const press = (btn) =>
- t.els['geo-import-list'].emit('click', { target: btn });
- const [first] = rows(t.els);
- action(first, 'toggle').focus();
- press(action(first, 'toggle'));
- // The list was rebuilt; focus is on the NEW toggle of the same row.
- const again = rows(t.els)[0];
- assert.notEqual(again, first);
- assert.equal(t.document.activeElement, action(again, 'toggle'));
- assert.equal(action(again, 'toggle').getAttribute('aria-pressed'), 'false');
- press(action(again, 'toggle'));
- assert.equal(t.document.activeElement, action(rows(t.els)[0], 'toggle'));
- // Remove the first row: focus moves to the same control on the next row.
- action(rows(t.els)[0], 'remove').focus();
- press(action(rows(t.els)[0], 'remove'));
- assert.equal(rows(t.els).length, 1);
- assert.equal(t.document.activeElement, action(rows(t.els)[0], 'remove'));
- // Remove the last row: focus goes back to the Import button.
- press(action(rows(t.els)[0], 'remove'));
+// The rendered view as the owner reads it back: a row per published file,
+// each with its buttons. `settle()` finishes the held render.
+async function heldRender(names) {
+ let settle = () => {};
+ const t = setup({ rendered: () => new Promise((r) => (settle = r)) });
+ await t.tool.importFiles(names.map((name) => file(name)));
+ const list = t.els['geo-import-list'];
+ const renderRow = ({ id }) => {
+ const row = new FakeElement('li');
+ row.dataset.importId = id;
+ for (const name of ['toggle', 'fly', 'remove']) {
+ const btn = new FakeElement('button');
+ btn.dataset.action = name;
+ row.append(btn);
+ }
+ return row;
+ };
+ const render = async () => {
+ list.replaceChildren(...rows(t).map(renderRow));
+ settle();
+ await new Promise((r) => setImmediate(r));
+ };
+ await render();
+ const removeButton = (at) => list.children[at].children[2];
+ return { t, list, render, removeButton };
+}
+
+test('keyboard focus moves on only once the list without the removed row renders', async () => {
+ const { t, list, render, removeButton } = await heldRender([
+ 'a.geojson',
+ 'b.geojson',
+ ]);
+ const first = removeButton(0);
+ first.focus();
+ press(t, rows(t)[0], 'remove');
+ await new Promise((r) => setImmediate(r));
+ assert.equal(t.document.activeElement, first, 'not before the view renders');
+ await render();
+ assert.equal(list.children.length, 1);
+ assert.equal(
+ t.document.activeElement,
+ removeButton(0),
+ 'the same control on the next row',
+ );
+ press(t, rows(t)[0], 'remove');
+ await render();
assert.equal(t.document.activeElement, t.els['geo-import-button']);
});
+test('focus moved elsewhere while the list renders stays where it went', async () => {
+ const { t, render, removeButton } = await heldRender([
+ 'a.geojson',
+ 'b.geojson',
+ ]);
+ removeButton(0).focus();
+ press(t, rows(t)[0], 'remove');
+ t.els['geo-import-input'].focus();
+ await render();
+ assert.equal(t.document.activeElement, t.els['geo-import-input']);
+});
+
+test('two removes before one render give focus to the first one’s next row', async () => {
+ const names = ['a', 'b', 'c', 'd', 'e'].map((n) => `${n}.geojson`);
+ const { t, list, render, removeButton } = await heldRender(names);
+ removeButton(1).focus();
+ press(t, rows(t)[1], 'remove');
+ // The view has not rendered: b's button still holds focus.
+ press(t, rows(t)[2], 'remove');
+ await render();
+ assert.deepEqual(
+ t.tool.list().map((f) => f.name),
+ ['a.geojson', 'c.geojson', 'e.geojson'],
+ );
+ assert.equal(t.document.activeElement, removeButton(1));
+ assert.equal(list.children[1].dataset.importId, rows(t)[1].id);
+});
+
test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => {
const t = setup({
load: async (f) => ({
@@ -703,12 +774,11 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color
}),
});
await t.tool.importFiles([file('a.kml'), file('b.gpx')]);
- const [kml, gpx] = rows(t.els);
- assert.ok(kml.children[0].classList.contains('own-styles'));
- assert.equal(kml.children[0].style.background, undefined);
- assert.match(kml.children[1].title, /drawn with its own styles/);
- assert.ok(!gpx.children[0].classList.contains('own-styles'));
- assert.ok(gpx.children[0].style.background);
+ const [kml, gpx] = rows(t);
+ assert.equal(kml.ownStyles, true);
+ assert.match(kml.title, /drawn with its own styles/);
+ assert.equal(gpx.ownStyles, false);
+ assert.ok(gpx.color);
});
test('the map-object count in a row title keeps digit grouping', async () => {
@@ -723,7 +793,7 @@ test('the map-object count in a row title keeps digit grouping', async () => {
}),
});
await t.tool.importFiles([file('big.geojson')]);
- const [row] = rows(t.els);
- assert.match(row.children[1].title, /12,345 map objects/);
+ const [row] = rows(t);
+ assert.match(row.title, /12,345 map objects/);
t.tool.destroy();
});
diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js
index 53c904c7d..cad6c5dd2 100644
--- a/src/ui/stores/display.js
+++ b/src/ui/stores/display.js
@@ -3,9 +3,9 @@ import { markRaw, shallowRef } from 'vue';
/**
* The Display panel as its view renders it. VisualSettings presents its
- * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group and
- * the draw tool its controls, and each publishes here; each keeps its
- * listeners on the controls.
+ * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group, the
+ * draw tool its controls and the file import its list, and each publishes
+ * here; each keeps its listeners on the controls.
*/
export const useDisplayStore = defineStore('gev-display', () => {
/** The presented panel; null until VisualSettings first renders. */
@@ -32,5 +32,22 @@ export const useDisplayStore = defineStore('gev-display', () => {
draw.value = markRaw(next);
}
- return { view, publish, models3d, publishModels3d, draw, publishDraw };
+ /** The imported files and hint; null until the file import renders. */
+ const geoImport = shallowRef(null);
+
+ /** @param {object} next A fresh presentation of the imported files. */
+ function publishGeoImport(next) {
+ geoImport.value = markRaw(next);
+ }
+
+ return {
+ view,
+ publish,
+ models3d,
+ publishModels3d,
+ draw,
+ publishDraw,
+ geoImport,
+ publishGeoImport,
+ };
});
diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue
index 6492fd2f8..6509f9b94 100644
--- a/src/ui/views/panels/DisplayPanel.vue
+++ b/src/ui/views/panels/DisplayPanel.vue
@@ -6,11 +6,10 @@ import { vRangeValue } from '../../rail/rangeValue.js';
import { useDisplayStore } from '../../stores/display.js';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
-// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
-// the draw tool its controls, and each publishes them; until they do, the
-// panel shows its initial markup. The other groups keep their owners'
-// element bindings.
-const { view, models3d, draw } = storeToRefs(useDisplayStore());
+// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group, the
+// draw tool its controls and the file import its list, and each publishes
+// them; until they do, the panel shows its initial markup.
+const { view, models3d, draw, geoImport } = storeToRefs(useDisplayStore());
const pct = (value) => `${value}%`;
const hud = computed(() => view.value?.hud ?? null);
const sonar = computed(() => view.value?.sonar ?? null);
@@ -37,6 +36,8 @@ const modelsSegment = segment(modelsMode);
const drawShape = computed(() => draw.value?.shape ?? 'area');
const drawSegment = segment(drawShape);
const allocationSegment = segment(allocation);
+const geoRows = computed(() => geoImport.value?.rows ?? []);
+const geoListed = computed(() => geoRows.value.length > 0);
@@ -190,8 +191,8 @@ const allocationSegment = segment(allocation);
Import
-
-
+ - {{ row.name }}
+ {{ geoImport?.hint ?? '' }}