mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
9eb969c682
commit
a068a45a89
7 files changed
+327
-131
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '<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
@@ -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;
|
||||
|
||||
+129
-59
@@ -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('<img src=x onerror=alert(1)>.kml')]);
|
||||
const name = rows(t.els)[0].children.find((c) =>
|
||||
c.classList.contains('geo-import-name'),
|
||||
);
|
||||
assert.equal(name.textContent, '<img src=x onerror=alert(1)>.kml');
|
||||
assert.equal(name.innerHTML, undefined);
|
||||
assert.equal(rows(t)[0].name, '<img src=x onerror=alert(1)>.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();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -190,8 +191,8 @@ const allocationSegment = segment(allocation);
|
||||
<span class="pp-label" data-i18n>Import</span>
|
||||
</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>
|
||||
<ul class="geo-import-list" id="geo-import-list" aria-label="Imported files" data-i18n-aria-label></ul>
|
||||
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite"></span>
|
||||
<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">{{ geoImport?.hint ?? '' }}</span>
|
||||
</div>
|
||||
<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">
|
||||
|
||||
Reference in new issue
Block a user