feat(ui): render the Display panel's imported file list from the store

The file import publishes its rows and hint to the gev-display store and DisplayPanel renders them, keyed by file, so a toggled row keeps its controls. Keyboard focus is given back once the list has rendered: a removed row's next row, else the previous one, else the Import button; a second change before the render keeps the first target, and focus moved elsewhere meanwhile stays there.
This commit is contained in:
Sameh Khamis committed 2026-10-31 00:05:25 -07:00
1 parent 9eb969c682
commit a068a45a89
7 files changed
+327 -131

No files matched your search

+6 -5
View File
@@ -96,11 +96,12 @@ They contain no application state.
descriptor before acting), Display (in progress: `DisplayPanel` renders descriptor before acting), Display (in progress: `DisplayPanel` renders
the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the
`gev-display` store, which VisualSettings publishes to, and the 3D `gev-display` store, which VisualSettings publishes to, and the 3D
aircraft group and the Draw controls, which AircraftDisplay and the draw aircraft group, the Draw controls and the imported file list, which
tool publish beside it; the style parameter rows render through Vue AircraftDisplay, the draw tool and the file import publish beside it; the
behind `createStyleParameters()`, as a render function so the portable file import restores keyboard focus once the list has rendered; the
`./ui/input` entry needs no component compiler; the other groups follow style parameter rows render through Vue behind `createStyleParameters()`,
one owner at a time), 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, Context and Radio, the command dock,
the phone shell, then Cockpit. Layers that write into the right rail the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead. directly gain a slot instead.
+5
View File
@@ -1,3 +1,4 @@
import { nextTick } from 'vue';
import { SceneDirector } from '../scenes/director.js'; import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js'; import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js'; import { initDrawTool } from '../annotations/drawTool.js';
@@ -73,6 +74,10 @@ export function createApplicationTools({
navigation: styleManager, navigation: styleManager,
ground: operations.surface?.groundFloor, ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener), subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
publish: (view) => {
displayStore.publishGeoImport(view);
return nextTick();
},
}); });
defer(() => geoFileImport?.destroy()); defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
+86
View File
@@ -9,6 +9,7 @@ import { useDisplayStore } from './stores/display.js';
import DisplayPanel from './views/panels/DisplayPanel.vue'; import DisplayPanel from './views/panels/DisplayPanel.vue';
import { AircraftDisplay } from './aircraftDisplay.js'; import { AircraftDisplay } from './aircraftDisplay.js';
import { VisualSettings } from './visualSettings.js'; import { VisualSettings } from './visualSettings.js';
import { initGeoFileImport } from './geoFileImport.js';
function fixture(t) { function fixture(t) {
document.body.replaceChildren(); 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-mode-row').classList.contains('visible'), false);
assert.equal($('draw-label-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: '<b>a</b>.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, '<b>a</b>.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 <b>a</b>.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 <b>a</b>.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 <b>a</b>.geojson');
assert.equal(button(first, 'remove').getAttribute('aria-label'), 'Remove <b>a</b>.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);
});
+72 -56
View File
@@ -20,8 +20,11 @@ const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
/** /**
* Wire the Import control. * 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. * `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, * @returns {{destroy: Function, importFiles: Function, list: Function,
* setVisible: Function, flyTo: Function, remove: Function, * setVisible: Function, flyTo: Function, remove: Function,
* diagnostics: Function}|null} * diagnostics: Function}|null}
@@ -34,11 +37,11 @@ export function initGeoFileImport({
navigation = null, navigation = null,
ground = null, ground = null,
subscribeMapSource = null, subscribeMapSource = null,
publish = () => {},
}) { }) {
const button = doc?.getElementById('geo-import-button'); const button = doc?.getElementById('geo-import-button');
const input = doc?.getElementById('geo-import-input'); const input = doc?.getElementById('geo-import-input');
const listEl = doc?.getElementById('geo-import-list'); const listEl = doc?.getElementById('geo-import-list');
const hint = doc?.getElementById('geo-import-hint');
if (!viewer || !button || !input || !listEl) return null; if (!viewer || !button || !input || !listEl) return null;
const imports = []; const imports = [];
@@ -49,6 +52,7 @@ export function initGeoFileImport({
let destroyed = false; let destroyed = false;
let dragDepth = 0; let dragDepth = 0;
let cameraIntent = 0; let cameraIntent = 0;
let hint = '';
const lifetime = new AbortController(); const lifetime = new AbortController();
let removeMapSource; let removeMapSource;
// Imports live for the page session: switching language (a reload) asks // Imports live for the page session: switching language (a reload) asks
@@ -63,7 +67,8 @@ export function initGeoFileImport({
listeners.push([target, type, listener, options]); listeners.push([target, type, listener, options]);
}; };
const setHint = (text) => { const setHint = (text) => {
if (hint) hint.textContent = text || ''; hint = text || '';
present();
}; };
const render = () => viewer.scene?.requestRender?.(); const render = () => viewer.scene?.requestRender?.();
let groundTimer, let groundTimer,
@@ -121,77 +126,86 @@ export function initGeoFileImport({
if (!active || !listEl.contains?.(active)) return null; if (!active || !listEl.contains?.(active)) return null;
const row = active.closest?.('[data-import-id]'); const row = active.closest?.('[data-import-id]');
return row return row
? { id: row.dataset.importId, action: active.dataset?.action } ? {
id: row.dataset.importId,
action: active.dataset?.action,
element: active,
next: [],
}
: null; : null;
} }
function restoreFocus(was) { 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]')]; 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 // The row is gone (removed): the next row's same control, else the
// previous row's, else the Import button. // previous row's, else the Import button.
if (!row && was.index != null) const row = [was.id, ...was.next].map(rowOf).find(Boolean);
row = rows[Math.min(was.index, rows.length - 1)];
const target = const target =
row?.querySelector?.(`button[data-action="${was.action}"]`) || row?.querySelector?.(`button[data-action="${was.action}"]`) ||
(row ? row.querySelector?.('button') : button); (row ? row.querySelector?.('button') : button);
target?.focus?.(); target?.focus?.();
} }
function renderList(keep = focusedAction()) { /** Publish the file list and hint; the view renders them. */
listEl.replaceChildren( function present() {
...imports.map((item) => { return publish({
const row = doc.createElement('li'); rows: imports.map((item) => ({
row.className = 'geo-import-row'; id: item.id,
row.dataset.importId = item.id; name: item.name,
const swatch = doc.createElement('span');
// KML and KMZ draw with their own styles; their swatch says so // KML and KMZ draw with their own styles; their swatch says so
// instead of claiming a color the globe does not show. // instead of claiming a color the globe does not show.
swatch.className = `geo-import-swatch${item.ownStyles ? ' own-styles' : ''}`; ownStyles: item.ownStyles,
if (!item.ownStyles) swatch.style.background = item.color; color: item.color,
const name = doc.createElement('span'); title: `${item.name} — ${item.format.toUpperCase()}, ${tn(
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(
item.entityCount, item.entityCount,
'{count} map object', '{count} map object',
'{count} map objects', '{count} map objects',
{ count: formatNumber(item.entityCount) }, { count: formatNumber(item.entityCount) },
)}${item.ownStyles ? t(', drawn with its own styles') : ''}`; )}${item.ownStyles ? t(', drawn with its own styles') : ''}`,
const actions = [ actions: [
[ {
item.visible ? 'visibility' : 'visibility_off', icon: item.visible ? 'visibility' : 'visibility_off',
item.visible label: item.visible
? t('Hide {name}', { name: item.name }) ? t('Hide {name}', { name: item.name })
: t('Show {name}', { name: item.name }), : t('Show {name}', { name: item.name }),
'toggle', action: 'toggle',
], pressed: String(item.visible),
['my_location', t('Fly to {name}', { name: item.name }), 'fly'], },
['close', t('Remove {name}', { name: item.name }), 'remove'], {
].map(([icon, label, action]) => { icon: 'my_location',
const btn = doc.createElement('button'); label: t('Fly to {name}', { name: item.name }),
btn.type = 'button'; action: 'fly',
btn.className = 'geo-import-action'; },
btn.dataset.action = action; {
btn.title = label; icon: 'close',
btn.setAttribute('aria-label', label); label: t('Remove {name}', { name: item.name }),
if (action === 'toggle') action: 'remove',
btn.setAttribute('aria-pressed', String(item.visible)); },
const glyph = doc.createElement('span'); ],
glyph.className = 'material-symbols-outlined'; })),
glyph.setAttribute('aria-hidden', 'true'); hint,
glyph.textContent = icon; });
btn.append(glyph); }
return btn;
}); // The focus to give back once the view renders; a second change before
row.append(swatch, name, ...actions); // then keeps the first, whose control the page still shows.
return row; let pendingFocus = null;
}),
); function renderList(keep = focusedAction()) {
listEl.classList.toggle('visible', imports.length > 0); pendingFocus ||= keep;
restoreFocus(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; 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); const at = imports.findIndex((item) => item.id === id);
if (at < 0) return false; if (at < 0) return false;
const keep = focusedAction(); 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); const [item] = imports.splice(at, 1);
viewer.dataSources?.remove?.(item.dataSource, true); viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.(); item.revoke?.();
@@ -452,8 +470,6 @@ export function initGeoFileImport({
for (const [target, type, listener, options] of listeners.splice(0)) for (const [target, type, listener, options] of listeners.splice(0))
target.removeEventListener(type, listener, options); target.removeEventListener(type, listener, options);
setDragging(false); setDragging(false);
listEl.replaceChildren();
listEl.classList.remove('visible');
setHint(''); setHint('');
if (globalThis.window?.__gevGeoImport === handle) if (globalThis.window?.__gevGeoImport === handle)
delete globalThis.window.__gevGeoImport; delete globalThis.window.__gevGeoImport;
+129 -59
View File
@@ -1,8 +1,9 @@
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against // Behavioral tests for DISPLAY ▸ Import: the controller is driven against
// fake DOM and viewer doubles with an injected loader, and its observable // fake DOM and viewer doubles with an injected loader, and its observable
// effects are asserted — data sources added and removed, list rows, the hint, // effects are asserted — data sources added and removed, the rows and hint it
// drop handling, the file limit, and what destroy() gives back. Real parsing // publishes, drop handling, the file limit, and what destroy() gives back.
// and rendering run in `scripts/qa-geo-import.mjs`. // 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 test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; 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; focus.active = null;
const els = { const els = {
'geo-import-button': new FakeElement('button', 'geo-import-button'), 'geo-import-button': new FakeElement('button', 'geo-import-button'),
@@ -156,6 +163,7 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
const removed = []; const removed = [];
const flights = []; const flights = [];
let renders = 0; let renders = 0;
let shown = null;
const viewer = { const viewer = {
container: new FakeElement('div', 'cesiumContainer'), container: new FakeElement('div', 'cesiumContainer'),
scene: { requestRender: () => (renders += 1) }, scene: { requestRender: () => (renders += 1) },
@@ -193,6 +201,10 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
...(navigation ? { navigation } : {}), ...(navigation ? { navigation } : {}),
...(subscribeMapSource ? { subscribeMapSource } : {}), ...(subscribeMapSource ? { subscribeMapSource } : {}),
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)), flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
publish: (view) => {
shown = view;
return rendered?.();
},
}); });
return { return {
tool, tool,
@@ -205,12 +217,25 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
revoked, revoked,
loads, loads,
renders: () => renders, renders: () => renders,
shown: () => shown,
}; };
} }
const file = (name, extra = {}) => ({ name, size: 10, ...extra }); const file = (name, extra = {}) => ({ name, size: 10, ...extra });
const rows = (els) => els['geo-import-list'].children; const rows = (t) => t.shown().rows;
const action = (row, name) => const action = (row, name) => row.actions.find((a) => a.action === name);
row.children.find((c) => c.dataset.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. // Runs first: tools left by later tests would answer the same registry.
test('imported files make the language switch ask first', async () => { 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, t.loads[1].opts.color,
'each file gets its own color', 'each file gets its own color',
); );
assert.equal(rows(t.els).length, 2); assert.equal(rows(t).length, 2);
assert.ok(t.els['geo-import-list'].classList.contains('visible'));
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to'); 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( assert.deepEqual(
t.tool.list().map((f) => f.name), t.tool.list().map((f) => f.name),
['a.geojson', 'b.kml'], ['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(); const t = setup();
await t.tool.importFiles([file('<img src=x onerror=alert(1)>.kml')]); await t.tool.importFiles([file('<img src=x onerror=alert(1)>.kml')]);
const name = rows(t.els)[0].children.find((c) => assert.equal(rows(t)[0].name, '<img src=x onerror=alert(1)>.kml');
c.classList.contains('geo-import-name'),
);
assert.equal(name.textContent, '<img src=x onerror=alert(1)>.kml');
assert.equal(name.innerHTML, undefined);
}); });
test('row buttons hide, show, fly to and remove a file', async () => { 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')]); await t.tool.importFiles([file('a.gpx')]);
const [{ id }] = t.tool.list(); const [{ id }] = t.tool.list();
const ds = t.added[0]; const ds = t.added[0];
// the click lands on the glyph inside the button, as in a browser const glyphClick = (name) => press(t, rows(t)[0], name);
const glyphClick = (name) =>
t.els['geo-import-list'].emit('click', {
target: action(rows(t.els)[0], name).children[0],
});
assert.equal(ds.show, true); assert.equal(ds.show, true);
glyphClick('toggle'); glyphClick('toggle');
assert.equal(ds.show, false); assert.equal(ds.show, false);
assert.equal(t.tool.list()[0].visible, false); assert.equal(t.tool.list()[0].visible, false);
assert.equal( assert.equal(action(rows(t)[0], 'toggle').pressed, 'false');
action(rows(t.els)[0], 'toggle').getAttribute('aria-pressed'), assert.equal(action(rows(t)[0], 'toggle').icon, 'visibility_off');
'false', assert.equal(action(rows(t)[0], 'toggle').label, 'Show a.gpx');
);
glyphClick('fly'); glyphClick('fly');
assert.equal(ds.show, true, 'flying to a hidden file shows it'); assert.equal(ds.show, true, 'flying to a hidden file shows it');
assert.equal(t.flights.at(-1), ds); 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.removed, [{ ds, destroy: true }]);
assert.deepEqual(t.revoked, ['a.gpx']); assert.deepEqual(t.revoked, ['a.gpx']);
assert.equal(t.tool.list().length, 0); 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); 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.equal(out[0].error, 'That KML file is not valid XML.');
assert.match(out[1].error, /could not be read\./); assert.match(out[1].error, /could not be read\./);
assert.match( 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\.\)/, /^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); 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'], t.els['geo-import-list'],
]) ])
assert.equal(el.listeners.length, 0); 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(globalThis.window.__gevGeoImport, undefined);
assert.equal(t.tool.diagnostics().destroyed, true); assert.equal(t.tool.diagnostics().destroyed, true);
} finally { } finally {
@@ -436,6 +451,11 @@ test('the application composition wires the import and owns its teardown', () =>
/navigation: styleManager/, /navigation: styleManager/,
); );
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt); 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( const markup = fs.readFileSync(
new URL('./views/panels/DisplayPanel.vue', import.meta.url), new URL('./views/panels/DisplayPanel.vue', import.meta.url),
'utf8', 'utf8',
@@ -665,32 +685,83 @@ test('a refused deferred stamp (Cockpit) imports without flying', async () => {
assert.equal(flown.length, 0); assert.equal(flown.length, 0);
}); });
test('keyboard focus stays on the control after hide, show and remove', async () => { // The rendered view as the owner reads it back: a row per published file,
const t = setup(); // each with its buttons. `settle()` finishes the held render.
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]); async function heldRender(names) {
// Clicks reach the list's delegated listener, as they bubble in a page. let settle = () => {};
const press = (btn) => const t = setup({ rendered: () => new Promise((r) => (settle = r)) });
t.els['geo-import-list'].emit('click', { target: btn }); await t.tool.importFiles(names.map((name) => file(name)));
const [first] = rows(t.els); const list = t.els['geo-import-list'];
action(first, 'toggle').focus(); const renderRow = ({ id }) => {
press(action(first, 'toggle')); const row = new FakeElement('li');
// The list was rebuilt; focus is on the NEW toggle of the same row. row.dataset.importId = id;
const again = rows(t.els)[0]; for (const name of ['toggle', 'fly', 'remove']) {
assert.notEqual(again, first); const btn = new FakeElement('button');
assert.equal(t.document.activeElement, action(again, 'toggle')); btn.dataset.action = name;
assert.equal(action(again, 'toggle').getAttribute('aria-pressed'), 'false'); row.append(btn);
press(action(again, 'toggle')); }
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'toggle')); return row;
// Remove the first row: focus moves to the same control on the next row. };
action(rows(t.els)[0], 'remove').focus(); const render = async () => {
press(action(rows(t.els)[0], 'remove')); list.replaceChildren(...rows(t).map(renderRow));
assert.equal(rows(t.els).length, 1); settle();
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'remove')); await new Promise((r) => setImmediate(r));
// Remove the last row: focus goes back to the Import button. };
press(action(rows(t.els)[0], 'remove')); 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']); 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 () => { test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => {
const t = setup({ const t = setup({
load: async (f) => ({ 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')]); await t.tool.importFiles([file('a.kml'), file('b.gpx')]);
const [kml, gpx] = rows(t.els); const [kml, gpx] = rows(t);
assert.ok(kml.children[0].classList.contains('own-styles')); assert.equal(kml.ownStyles, true);
assert.equal(kml.children[0].style.background, undefined); assert.match(kml.title, /drawn with its own styles/);
assert.match(kml.children[1].title, /drawn with its own styles/); assert.equal(gpx.ownStyles, false);
assert.ok(!gpx.children[0].classList.contains('own-styles')); assert.ok(gpx.color);
assert.ok(gpx.children[0].style.background);
}); });
test('the map-object count in a row title keeps digit grouping', async () => { 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')]); await t.tool.importFiles([file('big.geojson')]);
const [row] = rows(t.els); const [row] = rows(t);
assert.match(row.children[1].title, /12,345 map objects/); assert.match(row.title, /12,345 map objects/);
t.tool.destroy(); t.tool.destroy();
}); });
+21 -4
View File
@@ -3,9 +3,9 @@ import { markRaw, shallowRef } from 'vue';
/** /**
* The Display panel as its view renders it. VisualSettings presents its * The Display panel as its view renders it. VisualSettings presents its
* groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group and * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group, the
* the draw tool its controls, and each publishes here; each keeps its * draw tool its controls and the file import its list, and each publishes
* listeners on the controls. * here; each keeps its listeners on the controls.
*/ */
export const useDisplayStore = defineStore('gev-display', () => { export const useDisplayStore = defineStore('gev-display', () => {
/** The presented panel; null until VisualSettings first renders. */ /** The presented panel; null until VisualSettings first renders. */
@@ -32,5 +32,22 @@ export const useDisplayStore = defineStore('gev-display', () => {
draw.value = markRaw(next); 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,
};
}); });
+8 -7
View File
@@ -6,11 +6,10 @@ import { vRangeValue } from '../../rail/rangeValue.js';
import { useDisplayStore } from '../../stores/display.js'; import { useDisplayStore } from '../../stores/display.js';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope, // The Display panel. VisualSettings presents the HUD, Sonar, Scope,
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and // Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group, the
// the draw tool its controls, and each publishes them; until they do, the // draw tool its controls and the file import its list, and each publishes
// panel shows its initial markup. The other groups keep their owners' // them; until they do, the panel shows its initial markup.
// element bindings. const { view, models3d, draw, geoImport } = storeToRefs(useDisplayStore());
const { view, models3d, draw } = storeToRefs(useDisplayStore());
const pct = (value) => `${value}%`; const pct = (value) => `${value}%`;
const hud = computed(() => view.value?.hud ?? null); const hud = computed(() => view.value?.hud ?? null);
const sonar = computed(() => view.value?.sonar ?? null); const sonar = computed(() => view.value?.sonar ?? null);
@@ -37,6 +36,8 @@ const modelsSegment = segment(modelsMode);
const drawShape = computed(() => draw.value?.shape ?? 'area'); const drawShape = computed(() => draw.value?.shape ?? 'area');
const drawSegment = segment(drawShape); const drawSegment = segment(drawShape);
const allocationSegment = segment(allocation); const allocationSegment = segment(allocation);
const geoRows = computed(() => geoImport.value?.rows ?? []);
const geoListed = computed(() => geoRows.value.length > 0);
</script> </script>
<template> <template>
@@ -190,8 +191,8 @@ const allocationSegment = segment(allocation);
<span class="pp-label" data-i18n>Import</span> <span class="pp-label" data-i18n>Import</span>
</button> </button>
<input type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden aria-label="Choose GeoJSON, KML, KMZ or GPX files" data-i18n-aria-label> <input type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden aria-label="Choose GeoJSON, KML, KMZ or GPX files" data-i18n-aria-label>
<ul class="geo-import-list" id="geo-import-list" aria-label="Imported files" data-i18n-aria-label></ul> <ul class="geo-import-list" :class="{ visible: geoListed }" id="geo-import-list" aria-label="Imported files" data-i18n-aria-label><li v-for="row in geoRows" :key="row.id" class="geo-import-row" :data-import-id="row.id"><span :class="['geo-import-swatch', { 'own-styles': row.ownStyles }]" :style="row.ownStyles ? null : { background: row.color }"></span><span class="geo-import-name" :title="row.title">{{ row.name }}</span><button v-for="item in row.actions" :key="item.action" type="button" class="geo-import-action" :data-action="item.action" :title="item.label" :aria-label="item.label" :aria-pressed="item.pressed"><span class="material-symbols-outlined" aria-hidden="true">{{ item.icon }}</span></button></li></ul>
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite"></span> <span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite">{{ geoImport?.hint ?? '' }}</span>
</div> </div>
<div class="pp-toggle-group"> <div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: celestial?.active }" id="celestial-toggle" :title="celestialTitle" :aria-pressed="String(celestial?.active ?? false)" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined"> <button class="pp-toggle-btn" :class="{ active: celestial?.active }" id="celestial-toggle" :title="celestialTitle" :aria-pressed="String(celestial?.active ?? false)" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">