feat(import): render the file import in a GeoImport view

The file import no longer looks up or listens on the Import button, the
picker or the list, and no longer restores focus itself. It connects
importGeoFiles, toggleGeoImport, flyToGeoImport and removeGeoImport to
the display store. A new GeoImport view owns the button, the picker, the
rows and the hint: it opens the picker once the import has presented
itself, sends the pick and the row buttons' actions, and hands keyboard
focus on with v-focus-safe-children before a focused row goes, to the
next row's same button, else the previous row's, else Import.
This commit is contained in:
Sameh Khamis committed 2026-11-01 10:23:35 -08:00
1 parent a6e85db8be
commit 42c3cc12b1
11 files changed
+303 -322

No files matched your search

+5 -1
View File
@@ -51,7 +51,11 @@
store's `connect`/`act`, and its allocation and 3D model choices are the store's `connect`/`act`, and its allocation and 3D model choices are the
kit's `UiSegmented`. `initDrawTool` takes `connect(actions)`, which returns kit's `UiSegmented`. `initDrawTool` takes `connect(actions)`, which returns
their disconnect, and no longer listens on the Draw controls; its view their disconnect, and no longer listens on the Draw controls; its view
publishes `color` and `label` too, and the shape choice is a `UiSegmented`. `syncScopeControls` (`./ui/display`) and publishes `color` and `label` too, and the shape choice is a `UiSegmented`.
`initGeoFileImport` takes `connect(actions)` the same way and no longer
listens on the Import button, picker or list, nor restores focus itself:
the `GeoImport` view does both, and its `publish` need not settle once the
list renders. `syncScopeControls` (`./ui/display`) and
`shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display `shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display
view and the rail now own what they did. `./ui/input` imports Vue view and the rail now own what they did. `./ui/input` imports Vue
(samehkhamis). (samehkhamis).
+4 -3
View File
@@ -133,9 +133,10 @@ a feature supplying only its accent.
`gev-display` store, which VisualSettings publishes to, and the 3D `gev-display` store, which VisualSettings publishes to, and the 3D
aircraft group, the Draw controls and the imported file list, which aircraft group, the Draw controls and the imported file list, which
AircraftDisplay, the draw tool and the file import publish beside it; AircraftDisplay, the draw tool and the file import publish beside it;
the controls call DisplayBindings and the draw tool through the store's the controls call DisplayBindings, the draw tool and the file import
`connect`/`act`, the newest owner of an action answering it; the through the store's `connect`/`act`, the newest owner of an action
file import restores keyboard focus once the list has rendered; the answering it; `GeoImport` hands keyboard focus on before a focused row
goes, with `v-focus-safe-children`; the
style parameter rows render through Vue behind `createStyleParameters()`, style parameter rows render through Vue behind `createStyleParameters()`,
as a render function so the portable `./ui/input` entry needs no as a render function so the portable `./ui/input` entry needs no
component compiler; the other groups follow one owner at a time), component compiler; the other groups follow one owner at a time),
+1
View File
@@ -86,6 +86,7 @@
"src/ui/stores/streetLevel.js", "src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue", "src/ui/rail/RailChips.vue",
"src/ui/views/panels/DisplayPanel.vue", "src/ui/views/panels/DisplayPanel.vue",
"src/ui/views/panels/GeoImport.vue",
"src/ui/stores/display.js", "src/ui/stores/display.js",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/rail/classToggle.js", "src/ui/rail/classToggle.js",
+10 -10
View File
@@ -1,4 +1,3 @@
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';
@@ -56,18 +55,21 @@ export function createApplicationTools({
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy(); annotations.destroy();
}); });
const displayStore = useDisplayStore(applicationPinia());
// The Display panel's controls answer through the store; returns the
// disconnect.
const connectDisplay = (actions) => {
const token = displayStore.connect(actions);
return () => displayStore.detach(token);
};
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer // DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application // while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button. // lifetime rather than to whoever last pressed the button.
const displayStore = useDisplayStore(applicationPinia());
const drawTool = initDrawTool({ const drawTool = initDrawTool({
viewer, viewer,
annotations, annotations,
publish: (view) => displayStore.publishDraw(view), publish: (view) => displayStore.publishDraw(view),
connect: (actions) => { connect: connectDisplay,
const token = displayStore.connect(actions);
return () => displayStore.detach(token);
},
}); });
defer(() => drawTool?.destroy()); defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its // DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
@@ -78,10 +80,8 @@ 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) => { publish: (view) => displayStore.publishGeoImport(view),
displayStore.publishGeoImport(view); connect: connectDisplay,
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
+4 -3
View File
@@ -395,9 +395,10 @@ test('the imported files render what the file import publishes and keep focus',
revoke() {}, revoke() {},
}), }),
flyToSource() {}, flyToSource() {},
publish: (view) => { publish: (view) => store.publishGeoImport(view),
store.publishGeoImport(view); connect: (actions) => {
return nextTick(); const token = store.connect(actions);
return () => store.detach(token);
}, },
}); });
t.after(() => tool.destroy()); t.after(() => tool.destroy());
+19 -81
View File
@@ -1,6 +1,5 @@
/** Session-local file controls, serial loading and application-owned navigation. */ /** Session-local file controls, serial loading and application-owned navigation. */
import { import {
GEO_FILE_ACCEPT,
GeoImportError, GeoImportError,
MAX_IMPORTED_FILES, MAX_IMPORTED_FILES,
loadGeoFile, loadGeoFile,
@@ -19,12 +18,13 @@ import { flyToDataSource } from './flyToDataSource.js';
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff']; const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
/** /**
* Wire the Import control. * Run the Import control.
* @param {{viewer: object, load?: Function, document?: Document, * @param {{viewer: object, load?: Function, document?: Document,
* publish?: Function}} deps * publish?: Function, connect?: 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 * `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. * renders them. `connect(actions)` answers the view's pick and row buttons
* and returns their disconnect.
* @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}
@@ -38,11 +38,11 @@ export function initGeoFileImport({
ground = null, ground = null,
subscribeMapSource = null, subscribeMapSource = null,
publish = () => {}, publish = () => {},
connect = () => () => {},
}) { }) {
const button = doc?.getElementById('geo-import-button'); // A page without the Import control (the Display controls view omitted)
const input = doc?.getElementById('geo-import-input'); // has no file import.
const listEl = doc?.getElementById('geo-import-list'); if (!viewer || !doc?.getElementById('geo-import-button')) return null;
if (!viewer || !button || !input || !listEl) return null;
const imports = []; const imports = [];
const listeners = []; const listeners = [];
@@ -120,36 +120,6 @@ export function initGeoFileImport({
if (!pending) scheduleGround(); if (!pending) scheduleGround();
}); });
/** Which row button has focus, so a re-render can give it back. */
function focusedAction() {
const active = doc.activeElement;
if (!active || !listEl.contains?.(active)) return null;
const row = active.closest?.('[data-import-id]');
return row
? {
id: row.dataset.importId,
action: active.dataset?.action,
element: active,
next: [],
}
: null;
}
function restoreFocus(was) {
// 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 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.
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?.();
}
/** Publish the file list and hint; the view renders them. */ /** Publish the file list and hint; the view renders them. */
function present() { function present() {
return publish({ return publish({
@@ -191,23 +161,6 @@ export function initGeoFileImport({
}); });
} }
// 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; const find = (id) => imports.find((item) => item.id === id) || null;
/** Show or hide one imported file. */ /** Show or hide one imported file. */
@@ -217,7 +170,7 @@ export function initGeoFileImport({
item.visible = Boolean(visible); item.visible = Boolean(visible);
item.dataSource.show = item.visible; item.dataSource.show = item.visible;
if (item.visible) scheduleGround(); if (item.visible) scheduleGround();
renderList(); present();
render(); render();
return true; return true;
} }
@@ -262,16 +215,10 @@ export function initGeoFileImport({
function remove(id) { function remove(id) {
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();
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?.();
renderList(keep); present();
render(); render();
return true; return true;
} }
@@ -353,7 +300,7 @@ export function initGeoFileImport({
} }
loaded.mount?.(); loaded.mount?.();
imports.push(item); imports.push(item);
renderList(); present();
render(); render();
outcomes.push({ outcomes.push({
name: fileName, name: fileName,
@@ -375,22 +322,12 @@ export function initGeoFileImport({
} }
// ---- controls ---------------------------------------------------------- // ---- controls ----------------------------------------------------------
input.accept = GEO_FILE_ACCEPT; // The view opens the picker; its pick and the row buttons call these.
listen(button, 'click', () => input.click()); const disconnect = connect({
listen(input, 'change', () => { importGeoFiles: (files) => void importFiles(files),
const files = [...(input.files || [])]; toggleGeoImport: (id) => setVisible(id, !find(id)?.visible),
// Clear the selection so choosing the same file again fires `change`. flyToGeoImport: flyTo,
input.value = ''; removeGeoImport: remove,
if (files.length) void importFiles(files);
});
listen(listEl, 'click', (event) => {
const btn = event.target?.closest?.('button[data-action]');
const id = btn?.closest?.('[data-import-id]')?.dataset.importId;
if (!btn || !id) return;
const action = btn.dataset.action;
if (action === 'toggle') setVisible(id, !find(id)?.visible);
else if (action === 'fly') flyTo(id);
else if (action === 'remove') remove(id);
}); });
// ---- drop onto the globe ---------------------------------------------- // ---- drop onto the globe ----------------------------------------------
@@ -433,7 +370,7 @@ export function initGeoFileImport({
if (files.length) void importFiles(files); if (files.length) void importFiles(files);
}); });
renderList(); present();
const handle = { const handle = {
importFiles, importFiles,
@@ -459,6 +396,7 @@ export function initGeoFileImport({
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
unregisterUnsavedWork(); unregisterUnsavedWork();
disconnect();
lifetime.abort(); lifetime.abort();
removeMapSource?.(); removeMapSource?.();
clearTimeout(groundTimer); clearTimeout(groundTimer);
+28 -205
View File
@@ -32,46 +32,12 @@ class FakeClassList {
} }
} }
// The one focused element across every fake document (tests run one at a time).
const focus = { active: null };
class FakeElement { class FakeElement {
constructor(tagName = 'div', id = '') { constructor(tagName = 'div', id = '') {
this.tagName = tagName.toUpperCase(); this.tagName = tagName.toUpperCase();
this.id = id; this.id = id;
this.classList = new FakeClassList(); this.classList = new FakeClassList();
this.dataset = {};
this.style = {};
this.attributes = new Map();
this.children = [];
this.parent = null;
this.listeners = []; this.listeners = [];
this.textContent = '';
this.value = '';
this.clicks = 0;
}
set className(v) {
this.classList = new FakeClassList();
String(v)
.split(/\s+/)
.filter(Boolean)
.forEach((n) => this.classList.add(n));
}
setAttribute(n, v) {
this.attributes.set(n, String(v));
}
getAttribute(n) {
return this.attributes.get(n) ?? null;
}
append(...kids) {
for (const k of kids) {
k.parent = this;
this.children.push(k);
}
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
} }
addEventListener(type, listener, options) { addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options }); this.listeners.push({ type, listener, options });
@@ -92,71 +58,15 @@ class FakeElement {
...event, ...event,
}); });
} }
click() {
this.clicks += 1;
this.emit('click');
}
focus() {
focus.active = this;
}
contains(el) {
for (let at = el; at; at = at.parent) if (at === this) return true;
return false;
}
*walk() {
for (const kid of this.children) {
yield kid;
yield* kid.walk();
}
}
querySelectorAll(selector) {
if (selector !== '[data-import-id]') throw new Error(`fake: ${selector}`);
return [...this.walk()].filter((el) => el.dataset.importId);
}
querySelector(selector) {
const exact = /^button\[data-action="([a-z]+)"\]$/.exec(selector);
for (const el of this.walk()) {
if (el.tagName !== 'BUTTON') continue;
if (selector === 'button' || (exact && el.dataset.action === exact[1]))
return el;
}
return null;
}
closest(selector) {
for (let el = this; el; el = el.parent) {
if (
selector === 'button[data-action]' &&
el.tagName === 'BUTTON' &&
el.dataset.action
)
return el;
if (selector === '[data-import-id]' && el.dataset.importId) return el;
}
return null;
}
} }
function setup({ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
load,
navigation,
flyToSource,
subscribeMapSource,
rendered,
} = {}) {
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'),
'geo-import-input': new FakeElement('input', 'geo-import-input'),
'geo-import-list': new FakeElement('ul', 'geo-import-list'),
'geo-import-hint': new FakeElement('span', 'geo-import-hint'),
}; };
const document = { const document = {
body: new FakeElement('body'), body: new FakeElement('body'),
getElementById: (id) => els[id] ?? null, getElementById: (id) => els[id] ?? null,
createElement: (tag) => new FakeElement(tag),
get activeElement() {
return focus.active;
},
}; };
Object.setPrototypeOf(document, new FakeElement('document')); Object.setPrototypeOf(document, new FakeElement('document'));
const added = []; const added = [];
@@ -164,6 +74,8 @@ function setup({
const flights = []; const flights = [];
let renders = 0; let renders = 0;
let shown = null; let shown = null;
// The view's pick and row buttons, as the display store holds them.
let actions = null;
const viewer = { const viewer = {
container: new FakeElement('div', 'cesiumContainer'), container: new FakeElement('div', 'cesiumContainer'),
scene: { requestRender: () => (renders += 1) }, scene: { requestRender: () => (renders += 1) },
@@ -203,7 +115,10 @@ function setup({
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)), flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
publish: (view) => { publish: (view) => {
shown = view; shown = view;
return rendered?.(); },
connect: (next) => {
actions = next;
return () => (actions = null);
}, },
}); });
return { return {
@@ -218,24 +133,21 @@ function setup({
loads, loads,
renders: () => renders, renders: () => renders,
shown: () => shown, shown: () => shown,
actions: () => actions,
/** A control's change, as the view sends it. */
act: (name, ...args) => actions?.[name]?.(...args),
}; };
} }
const file = (name, extra = {}) => ({ name, size: 10, ...extra }); const file = (name, extra = {}) => ({ name, size: 10, ...extra });
const rows = (t) => t.shown().rows; const rows = (t) => t.shown().rows;
const action = (row, name) => row.actions.find((a) => a.action === name); 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. // A row button's click, as the view sends it.
const press = (t, row, name) => const ROW_ACTIONS = {
t.els['geo-import-list'].emit('click', { toggle: 'toggleGeoImport',
target: { fly: 'flyToGeoImport',
closest: (selector) => remove: 'removeGeoImport',
selector === 'button[data-action]' };
? { const press = (t, row, name) => t.act(ROW_ACTIONS[name], row.id);
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 () => {
@@ -262,19 +174,11 @@ test('returns null without its markup or viewer', () => {
); );
}); });
test('the Import button opens the picker and a pick imports each file', async () => { test('a pick imports each file', async () => {
const t = setup(); const t = setup();
t.els['geo-import-button'].click(); t.act('importGeoFiles', [file('a.geojson'), file('b.kml')]);
assert.equal(t.els['geo-import-input'].clicks, 1); for (let turn = 0; turn < 20 && t.added.length < 2; turn += 1)
assert.equal( await new Promise((r) => setImmediate(r));
t.els['geo-import-input'].accept,
'.geojson,.json,.kml,.kmz,.gpx',
);
const out = await t.tool.importFiles([file('a.geojson'), file('b.kml')]);
assert.deepEqual(
out.map((o) => o.ok),
[true, true],
);
assert.equal(t.added.length, 2); assert.equal(t.added.length, 2);
assert.notEqual( assert.notEqual(
t.loads[0].opts.color, t.loads[0].opts.color,
@@ -395,13 +299,9 @@ test('destroy removes every file and listener and the window handle', async () =
t.tool.destroy(); t.tool.destroy();
assert.equal(t.removed.length, 2); assert.equal(t.removed.length, 2);
assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']); assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']);
for (const el of [ assert.equal(t.viewer.container.listeners.length, 0);
t.viewer.container, assert.equal(t.document.listeners.length, 0);
t.els['geo-import-button'], assert.equal(t.actions(), null, 'the view is disconnected');
t.els['geo-import-input'],
t.els['geo-import-list'],
])
assert.equal(el.listeners.length, 0);
assert.deepEqual(t.shown(), { rows: [], hint: '' }); 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);
@@ -451,13 +351,13 @@ 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. // The list renders from the store and its controls answer through it.
assert.match( assert.match(
tools.slice(importAt, importAt + 400), tools.slice(importAt, importAt + 400),
/displayStore\.publishGeoImport\(view\);\s*return nextTick\(\);/, /publish: \(view\) => displayStore\.publishGeoImport\(view\),\s*connect: connectDisplay,/,
); );
const markup = fs.readFileSync( const markup = fs.readFileSync(
new URL('./views/panels/DisplayPanel.vue', import.meta.url), new URL('./views/panels/GeoImport.vue', import.meta.url),
'utf8', 'utf8',
); );
for (const id of [ for (const id of [
@@ -468,7 +368,7 @@ test('the application composition wires the import and owns its teardown', () =>
]) ])
assert.ok( assert.ok(
markup.includes(`id="${id}"`), markup.includes(`id="${id}"`),
`${id} missing from the DISPLAY template`, `${id} missing from the Import template`,
); );
}); });
@@ -685,83 +585,6 @@ test('a refused deferred stamp (Cockpit) imports without flying', async () => {
assert.equal(flown.length, 0); assert.equal(flown.length, 0);
}); });
// 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 () => { 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) => ({
+4 -4
View File
@@ -5,9 +5,8 @@ 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, the * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group, the
* draw tool its controls and the file import its list, and each publishes * draw tool its controls and the file import its list, and each publishes
* here. The controls' changes reach their owners (DisplayBindings and the * here. The controls' changes reach their owners (DisplayBindings, the draw
* draw tool) through act(); the file import keeps its listeners on its * tool and the file import) through act(). VisualSettings also
* controls. VisualSettings also
* publishes the active style, which the dock and the scene chrome show, and * publishes the active style, which the dock and the scene chrome show, and
* the map source controls publish their chip row beside it. * the map source controls publish their chip row beside it.
*/ */
@@ -70,7 +69,8 @@ export const useDisplayStore = defineStore('gev-display', () => {
/** /**
* The owners answering the panel's controls, oldest first: DisplayBindings * The owners answering the panel's controls, oldest first: DisplayBindings
* the Display controls and the dock's style buttons, the draw tool its own. * the Display controls and the dock's style buttons, the draw tool and the
* file import their own.
* @type {Array<{actions: object}>} * @type {Array<{actions: object}>}
*/ */
const owners = []; const owners = [];
+6 -15
View File
@@ -6,14 +6,15 @@ import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue'; import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vRangeValue } from '../../rail/rangeValue.js'; import { vRangeValue } from '../../rail/rangeValue.js';
import UiSegmented from '../../kit/UiSegmented.vue'; import UiSegmented from '../../kit/UiSegmented.vue';
import GeoImport from './GeoImport.vue';
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, the // Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
// draw tool its controls and the file import its list, and each publishes // the draw tool its controls, and each publishes them; until they do, the
// them; until they do, the panel shows its initial markup. // panel shows its initial markup. GeoImport renders the file import.
const display = useDisplayStore(); const display = useDisplayStore();
const { view, models3d, draw, geoImport } = storeToRefs(display); const { view, models3d, draw } = storeToRefs(display);
// A control's change goes to its owner (DisplayBindings or the draw tool). // A control's change goes to its owner (DisplayBindings or the draw tool).
const act = (name, ...args) => display.act(name, ...args); const act = (name, ...args) => display.act(name, ...args);
const integer = (event) => parseInt(event.target.value, 10); const integer = (event) => parseInt(event.target.value, 10);
@@ -48,8 +49,6 @@ const allocationOptions = [
{ value: 'ELASTIC', label: t('Elastic'), attrs: { id: 'detection-allocation-elastic', 'data-allocation': 'ELASTIC' } }, { value: 'ELASTIC', label: t('Elastic'), attrs: { id: 'detection-allocation-elastic', 'data-allocation': 'ELASTIC' } },
{ value: 'WEIGHTED', label: t('Weighted'), attrs: { id: 'detection-allocation-weighted', 'data-allocation': 'WEIGHTED' } }, { value: 'WEIGHTED', label: t('Weighted'), attrs: { id: 'detection-allocation-weighted', 'data-allocation': 'WEIGHTED' } },
]; ];
const geoRows = computed(() => geoImport.value?.rows ?? []);
const geoListed = computed(() => geoRows.value.length > 0);
</script> </script>
<template> <template>
@@ -187,15 +186,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span> <span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
</div> </div>
</div> </div>
<div class="pp-toggle-group" id="geo-import-group"> <GeoImport />
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice." data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">upload_file</span>
<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" :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"> <div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: celestial?.active }" id="celestial-toggle" @click="act('toggleCelestial')" :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" @click="act('toggleCelestial')" :title="celestialTitle" :aria-pressed="String(celestial?.active ?? false)" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span> <span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
+154
View File
@@ -0,0 +1,154 @@
// src/ui/views/panels/GeoImport.test.mjs — DISPLAY ▸ Import renders the file
// import's rows, sends a pick and the row buttons through the display store,
// and keeps keyboard focus on a row that stays when the focused one goes.
import '../../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { GEO_FILE_ACCEPT } from '../../../data/geoFileImport.js';
import { useDisplayStore } from '../../stores/display.js';
import GeoImport from './GeoImport.vue';
function mount(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useDisplayStore(pinia);
const calls = [];
const record =
(name) =>
(...args) =>
calls.push([name, ...args]);
store.connect({
importGeoFiles: record('importGeoFiles'),
toggleGeoImport: record('toggleGeoImport'),
flyToGeoImport: record('flyToGeoImport'),
removeGeoImport: record('removeGeoImport'),
});
const app = createApp(GeoImport).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
return { store, calls };
}
const $ = (id) => document.getElementById(id);
const row = (id) => ({
id,
name: `${id}.geojson`,
ownStyles: false,
color: '#39d0ff',
title: id,
actions: ['toggle', 'fly', 'remove'].map((action) => ({
icon: action,
label: `${action} ${id}`,
action,
pressed: action === 'toggle' ? 'true' : undefined,
})),
});
async function show(store, ids, hint = '') {
store.publishGeoImport({ rows: ids.map(row), hint });
await nextTick();
}
const button = (id, action) =>
document.querySelector(
`[data-import-id="${id}"] button[data-action="${action}"]`,
);
test('the Import button opens the picker, and a pick goes to the file import', async (t) => {
const { store, calls } = mount(t);
const picker = $('geo-import-input');
assert.equal(picker.getAttribute('accept'), GEO_FILE_ACCEPT);
let opened = 0;
picker.click = () => (opened += 1);
$('geo-import-button').click();
assert.equal(opened, 0, 'not before the file import presents itself');
await show(store, []);
$('geo-import-button').click();
assert.equal(opened, 1);
let cleared = false;
Object.defineProperty(picker, 'value', {
configurable: true,
get: () => '',
set: (value) => (cleared = value === ''),
});
const files = [new File(['{}'], 'a.geojson'), new File(['{}'], 'b.kml')];
Object.defineProperty(picker, 'files', { value: files, configurable: true });
picker.dispatchEvent(new Event('change'));
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'importGeoFiles');
assert.deepEqual(
calls[0][1].map((file) => file.name),
['a.geojson', 'b.kml'],
);
assert.ok(cleared, 'the selection clears, so the same file can be picked again');
Object.defineProperty(picker, 'files', { value: [], configurable: true });
picker.dispatchEvent(new Event('change'));
assert.equal(calls.length, 1, 'an empty pick sends nothing');
});
test('rows render what the file import publishes and their buttons call it', async (t) => {
const { store, calls } = mount(t);
assert.equal($('geo-import-list').classList.contains('visible'), false);
await show(store, ['a', 'b'], 'Added 2 files.');
assert.ok($('geo-import-list').classList.contains('visible'));
assert.equal($('geo-import-hint').textContent, 'Added 2 files.');
assert.equal(button('a', 'toggle').getAttribute('aria-pressed'), 'true');
assert.equal(button('a', 'toggle').getAttribute('aria-label'), 'toggle a');
button('a', 'toggle').click();
button('b', 'fly').click();
button('a', 'remove').click();
assert.deepEqual(calls, [
['toggleGeoImport', 'a'],
['flyToGeoImport', 'b'],
['removeGeoImport', 'a'],
]);
});
test('focus goes to the next row’s same button, then the previous, then Import', async (t) => {
const { store } = mount(t);
await show(store, ['a', 'b', 'c']);
button('b', 'fly').focus();
await show(store, ['a', 'c']);
assert.equal(document.activeElement, button('c', 'fly'), 'the next row');
await show(store, ['a']);
assert.equal(document.activeElement, button('a', 'fly'), 'the previous row');
await show(store, []);
assert.equal(document.activeElement, $('geo-import-button'));
});
test('two rows gone in one render leave focus on the first one’s next row', async (t) => {
const { store } = mount(t);
await show(store, ['a', 'b', 'c', 'd', 'e']);
button('b', 'remove').focus();
await show(store, ['a', 'c', 'e']);
assert.equal(document.activeElement, button('c', 'remove'));
});
test('focus outside the removed row stays where it is', async (t) => {
const { store } = mount(t);
await show(store, ['a', 'b']);
button('b', 'toggle').focus();
await show(store, ['b']);
assert.equal(document.activeElement, button('b', 'toggle'));
$('geo-import-button').focus();
await show(store, []);
assert.equal(document.activeElement, $('geo-import-button'));
});
test('focus passes through a neighbour that goes in the same render', async (t) => {
const { store } = mount(t);
await show(store, ['a', 'b', 'c', 'd']);
button('b', 'fly').focus();
await show(store, ['a', 'd']);
assert.equal(document.activeElement, button('d', 'fly'));
});
test('a row without the same button gives focus to its first one', async (t) => {
const { store } = mount(t);
const short = { ...row('b'), actions: row('b').actions.slice(1) };
store.publishGeoImport({ rows: [row('a'), short], hint: '' });
await nextTick();
button('a', 'toggle').focus();
store.publishGeoImport({ rows: [short], hint: '' });
await nextTick();
assert.equal(document.activeElement, button('b', 'fly'));
});
+68
View File
@@ -0,0 +1,68 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed, ref } from 'vue';
import { vFocusSafeChildren } from '../../rail/focusSafeChildren.js';
import { useDisplayStore } from '../../stores/display.js';
// DISPLAY ▸ Import: the button, the hidden picker, the imported files and
// the hint. The file import publishes the rows and hint; until it does, the
// list is empty. A pick and the row buttons call its actions. Before the
// row holding keyboard focus goes, focus moves to the next row's same
// button, else the previous row's, else the Import button. The list's
// template is one line: templates keep whitespace.
const display = useDisplayStore();
const { geoImport } = storeToRefs(display);
const rows = computed(() => geoImport.value?.rows ?? []);
// No `>` in the template: the catalog reads its tags with a regex.
const listed = computed(() => rows.value.length > 0);
const importButton = ref(null);
const picker = ref(null);
const ROW_ACTIONS = {
toggle: 'toggleGeoImport',
fly: 'flyToGeoImport',
remove: 'removeGeoImport',
};
// The picker opens once the file import has presented itself, so a pick
// always has somewhere to go.
function openPicker() {
if (geoImport.value) picker.value.click();
}
function pick() {
const files = [...(picker.value.files || [])];
// Clear the selection so choosing the same file again fires `change`.
picker.value.value = '';
if (files.length) display.act('importGeoFiles', files);
}
function leave() {
const active = document.activeElement;
const row = active?.closest('[data-import-id]');
const action = active?.dataset.action;
const others = [];
for (let next = row?.nextElementSibling; next; next = next.nextElementSibling)
others.push(next);
for (let prev = row?.previousElementSibling; prev; prev = prev.previousElementSibling)
others.push(prev);
for (const other of others) {
const target =
other.querySelector(`button[data-action="${action}"]`) ||
other.querySelector('button');
if (target) return target.focus();
}
importButton.value?.focus();
}
</script>
<template>
<div class="pp-toggle-group" id="geo-import-group">
<button ref="importButton" class="pp-toggle-btn" id="geo-import-button" type="button" @click="openPicker" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice." data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">upload_file</span>
<span class="pp-label" data-i18n>Import</span>
</button>
<input ref="picker" type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden @change="pick" aria-label="Choose GeoJSON, KML, KMZ or GPX files" data-i18n-aria-label>
<ul class="geo-import-list" :class="{ visible: listed }" id="geo-import-list" v-focus-safe-children="leave" aria-label="Imported files" data-i18n-aria-label><li v-for="row in rows" :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" @click="display.act(ROW_ACTIONS[item.action], row.id)"><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>
</template>