style: format Location component boundary

This commit is contained in:
Sameh Khamis committed 2026-09-12 22:55:18 -07:00
1 parent ea77b49482
commit 82d922ae87
7 files changed
+557 -275

No files matched your search

+58 -14
View File
@@ -3,8 +3,30 @@ const POI_KEYS = ['Q', 'W', 'E', 'R', 'T'];
/** Location DOM, keyboard handling and pending row animation over supplied actions. */
export class LocationControls {
constructor({ elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc = document, requestFrame = requestAnimationFrame, cancelFrame = cancelAnimationFrame }) {
Object.assign(this, { elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc, requestFrame, cancelFrame });
constructor({
elements,
cities,
getExpandedCity,
onCity,
onPoi,
onSearch,
onReset,
doc = document,
requestFrame = requestAnimationFrame,
cancelFrame = cancelAnimationFrame,
}) {
Object.assign(this, {
elements,
cities,
getExpandedCity,
onCity,
onPoi,
onSearch,
onReset,
doc,
requestFrame,
cancelFrame,
});
this.removers = [];
this.poiRemovers = [];
this.frame = null;
@@ -20,24 +42,34 @@ export class LocationControls {
this.bind(pill, 'click', () => onCity(id));
elements.pills.appendChild(pill);
}
this.bind(doc, 'keydown', event => {
this.bind(doc, 'keydown', (event) => {
const cityId = getExpandedCity();
if (!cityId || event.target?.matches?.('select, input, textarea') || event.target === elements.search) return;
if (
!cityId ||
event.target?.matches?.('select, input, textarea') ||
event.target === elements.search
)
return;
const index = POI_KEYS.indexOf(event.key.toUpperCase());
if (index !== -1 && index < (cities[cityId]?.pois.length || 0)) onPoi(cityId, index);
if (index !== -1 && index < (cities[cityId]?.pois.length || 0))
onPoi(cityId, index);
});
this.bind(elements.searchToggle, 'click', () => {
elements.search.classList.toggle('expanded');
if (elements.search.classList.contains('expanded')) elements.search.focus();
if (elements.search.classList.contains('expanded'))
elements.search.focus();
});
this.bind(elements.search, 'keydown', event => {
this.bind(elements.search, 'keydown', (event) => {
if (event.key === 'Enter') void onSearch(elements.search.value);
});
for (const button of elements.resetButtons) this.bind(button, 'click', onReset);
for (const button of elements.resetButtons)
this.bind(button, 'click', onReset);
}
bind(element, event, handler, removers = this.removers) {
if (!element) return;
const listener = (...args) => { if (!this.destroyed) return handler(...args); };
const listener = (...args) => {
if (!this.destroyed) return handler(...args);
};
element.addEventListener(event, listener);
removers.push(() => element.removeEventListener(event, listener));
}
@@ -66,7 +98,12 @@ export class LocationControls {
label.className = 'poi-pill-name';
label.textContent = poi.name;
pill.append(key, label);
this.bind(pill, 'click', () => this.onPoi(cityId, index), this.poiRemovers);
this.bind(
pill,
'click',
() => this.onPoi(cityId, index),
this.poiRemovers,
);
this.elements.poiRow.appendChild(pill);
});
this.frame = this.requestFrame(() => {
@@ -84,14 +121,17 @@ export class LocationControls {
}
highlightPoi(index) {
if (this.destroyed) return;
for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill')) pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index);
for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill'))
pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index);
}
highlightCity(id) {
if (this.destroyed) return;
for (const pill of this.elements.pills.querySelectorAll('.location-pill')) pill.classList.toggle('active', pill.dataset.locationId === id);
for (const pill of this.elements.pills.querySelectorAll('.location-pill'))
pill.classList.toggle('active', pill.dataset.locationId === id);
}
renderStatus(state) {
if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi) return;
if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi)
return;
const lines = locationMiniStatus(state);
this.elements.statusCity.textContent = lines.city;
this.elements.statusPoi.textContent = lines.poi;
@@ -113,7 +153,11 @@ export class LocationControls {
if (this.destroyed) return;
this.destroyed = true;
this.cancelExpansion();
for (const remove of [...this.poiRemovers.splice(0), ...this.removers.splice(0)]) remove();
for (const remove of [
...this.poiRemovers.splice(0),
...this.removers.splice(0),
])
remove();
this.orbitIndicator?.remove();
}
}
+130 -25
View File
@@ -5,40 +5,145 @@ import { LocationControls } from './locationControls.js';
function node() {
const classes = new Set();
return {
children: [], dataset: {}, listeners: new Map(), className: '', textContent: '',
classList: { add: (...names) => names.forEach(name => classes.add(name)), remove: (...names) => names.forEach(name => classes.delete(name)), contains: name => classes.has(name), toggle(name, enabled = !classes.has(name)) { if(enabled) classes.add(name); else classes.delete(name); } },
addEventListener(name, callback) { if(!this.listeners.has(name)) this.listeners.set(name,new Set()); this.listeners.get(name).add(callback); },
removeEventListener(name, callback) { this.listeners.get(name)?.delete(callback); },
fire(name,event={}) { for(const callback of this.listeners.get(name)||[]) callback(event); },
appendChild(child) { this.children.push(child); child.parent=this; },
append(...children) { for(const child of children) if(typeof child!=='string') this.appendChild(child); },
replaceChildren() { this.children=[]; },
remove() { if(this.parent) this.parent.children=this.parent.children.filter(child=>child!==this); },
querySelectorAll(selector) { return this.children.flatMap(child=>[...(child.className===selector.slice(1)?[child]:[]),...child.querySelectorAll(selector)]); },
focus() { this.focused=true; },
children: [],
dataset: {},
listeners: new Map(),
className: '',
textContent: '',
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle(name, enabled = !classes.has(name)) {
if (enabled) classes.add(name);
else classes.delete(name);
},
},
addEventListener(name, callback) {
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
this.listeners.get(name).add(callback);
},
removeEventListener(name, callback) {
this.listeners.get(name)?.delete(callback);
},
fire(name, event = {}) {
for (const callback of this.listeners.get(name) || []) callback(event);
},
appendChild(child) {
this.children.push(child);
child.parent = this;
},
append(...children) {
for (const child of children)
if (typeof child !== 'string') this.appendChild(child);
},
replaceChildren() {
this.children = [];
},
remove() {
if (this.parent)
this.parent.children = this.parent.children.filter(
(child) => child !== this,
);
},
querySelectorAll(selector) {
return this.children.flatMap((child) => [
...(child.className === selector.slice(1) ? [child] : []),
...child.querySelectorAll(selector),
]);
},
focus() {
this.focused = true;
},
};
}
function fixture() {
const elements={pills:node(),poiRow:node(),divider:node(),search:node(),searchToggle:node(),resetButtons:[node(),node()],statusCity:node(),statusPoi:node()};
const doc=node();doc.createElement=node;doc.body=node();
const cities={a:{name:'City A',pois:[{name:'First'},{name:'Second'}]},b:{name:'City B',pois:[{name:'Elsewhere'}]}};
const calls=[];const frames=new Map();const cancelled=[];let next=0;
const controls=new LocationControls({elements,cities,getExpandedCity:()=> 'a',onCity:id=>calls.push(['city',id]),onPoi:(id,index)=>calls.push(['poi',id,index]),onSearch:query=>calls.push(['search',query]),onReset:()=>calls.push(['reset']),doc,requestFrame:fn=>{const id=next++;frames.set(id,fn);return id},cancelFrame:id=>cancelled.push(id)});
return {elements,doc,controls,calls,frames,cancelled};
const elements = {
pills: node(),
poiRow: node(),
divider: node(),
search: node(),
searchToggle: node(),
resetButtons: [node(), node()],
statusCity: node(),
statusPoi: node(),
};
const doc = node();
doc.createElement = node;
doc.body = node();
const cities = {
a: { name: 'City A', pois: [{ name: 'First' }, { name: 'Second' }] },
b: { name: 'City B', pois: [{ name: 'Elsewhere' }] },
};
const calls = [];
const frames = new Map();
const cancelled = [];
let next = 0;
const controls = new LocationControls({
elements,
cities,
getExpandedCity: () => 'a',
onCity: (id) => calls.push(['city', id]),
onPoi: (id, index) => calls.push(['poi', id, index]),
onSearch: (query) => calls.push(['search', query]),
onReset: () => calls.push(['reset']),
doc,
requestFrame: (fn) => {
const id = next++;
frames.set(id, fn);
return id;
},
cancelFrame: (id) => cancelled.push(id),
});
return { elements, doc, controls, calls, frames, cancelled };
}
test('hiding a POI row cancels frame zero and rejects an already queued expansion', () => {
const f=fixture();f.controls.showPois('a');const callback=f.frames.get(0);f.controls.hidePois();callback();
assert.deepEqual(f.cancelled,[0]);assert.equal(f.elements.poiRow.classList.contains('expanded'),false);assert.equal(f.elements.divider.classList.contains('visible'),false);
const f = fixture();
f.controls.showPois('a');
const callback = f.frames.get(0);
f.controls.hidePois();
callback();
assert.deepEqual(f.cancelled, [0]);
assert.equal(f.elements.poiRow.classList.contains('expanded'), false);
assert.equal(f.elements.divider.classList.contains('visible'), false);
});
test('replacing POIs removes old click actions and presents the final row', () => {
const f=fixture();f.controls.showPois('a');const old=f.elements.poiRow.children[0];f.controls.showPois('b');old.fire('click');
assert.deepEqual(f.calls,[]);f.elements.poiRow.children[0].fire('click');assert.deepEqual(f.calls,[['poi','b',0]]);
f.frames.get(0)();assert.equal(f.elements.poiRow.classList.contains('expanded'),false);f.frames.get(1)();assert.equal(f.elements.poiRow.classList.contains('expanded'),true);
const f = fixture();
f.controls.showPois('a');
const old = f.elements.poiRow.children[0];
f.controls.showPois('b');
old.fire('click');
assert.deepEqual(f.calls, []);
f.elements.poiRow.children[0].fire('click');
assert.deepEqual(f.calls, [['poi', 'b', 0]]);
f.frames.get(0)();
assert.equal(f.elements.poiRow.classList.contains('expanded'), false);
f.frames.get(1)();
assert.equal(f.elements.poiRow.classList.contains('expanded'), true);
});
test('destruction revokes document, search, reset, city and POI actions and removes orbit UI', () => {
const f=fixture();f.controls.showPois('a');f.controls.createOrbitIndicator();assert.equal(f.doc.body.children.length,1);const city=f.elements.pills.children[0];const poi=f.elements.poiRow.children[0];f.controls.destroy();f.controls.destroy();
city.fire('click');poi.fire('click');f.elements.search.value='Place';f.elements.search.fire('keydown',{key:'Enter'});f.doc.fire('keydown',{key:'Q'});f.elements.resetButtons[0].fire('click');f.frames.get(0)();assert.deepEqual(f.calls,[]);assert.equal(f.doc.body.children.length,0);
const f = fixture();
f.controls.showPois('a');
f.controls.createOrbitIndicator();
assert.equal(f.doc.body.children.length, 1);
const city = f.elements.pills.children[0];
const poi = f.elements.poiRow.children[0];
f.controls.destroy();
f.controls.destroy();
city.fire('click');
poi.fire('click');
f.elements.search.value = 'Place';
f.elements.search.fire('keydown', { key: 'Enter' });
f.doc.fire('keydown', { key: 'Q' });
f.elements.resetButtons[0].fire('click');
f.frames.get(0)();
assert.deepEqual(f.calls, []);
assert.equal(f.doc.body.children.length, 0);
});
test('location and POI keys route once while form controls retain typing', () => {
const f=fixture();f.doc.fire('keydown',{key:'W',target:{matches:()=>false}});f.doc.fire('keydown',{key:'Q',target:{matches:()=>true}});f.elements.resetButtons[1].fire('click');assert.deepEqual(f.calls,[['poi','a',1],['reset']]);
const f = fixture();
f.doc.fire('keydown', { key: 'W', target: { matches: () => false } });
f.doc.fire('keydown', { key: 'Q', target: { matches: () => true } });
f.elements.resetButtons[1].fire('click');
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
});
+28 -3
View File
@@ -1,7 +1,29 @@
/** One cancellable place lookup at a time, under the caller's camera authority. */
export class LocationSearch {
constructor({ input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }) {
Object.assign(this, { input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled });
constructor({
input,
begin,
isCurrent,
beforeFly,
search,
onStart,
onResult,
onMissing,
onError,
onSettled,
}) {
Object.assign(this, {
input,
begin,
isCurrent,
beforeFly,
search,
onStart,
onResult,
onMissing,
onError,
onSettled,
});
this.controller = null;
this.generation = 0;
this.destroyed = false;
@@ -19,7 +41,10 @@ export class LocationSearch {
const controller = new AbortController();
this.controller = controller;
const generation = ++this.generation;
const current = () => !this.destroyed && generation === this.generation && this.isCurrent(authority);
const current = () =>
!this.destroyed &&
generation === this.generation &&
this.isCurrent(authority);
this.onStart(authority);
this.input.classList.add('searching');
try {
+85 -32
View File
@@ -6,52 +6,105 @@ function fixture() {
const calls = [];
const requests = [];
let authority = 0;
const input = { classList: { add: name => calls.push(['add',name]), remove: name => calls.push(['remove',name]) }, blur: () => calls.push(['blur']) };
const input = {
classList: {
add: (name) => calls.push(['add', name]),
remove: (name) => calls.push(['remove', name]),
},
blur: () => calls.push(['blur']),
};
const search = new LocationSearch({
input,
begin: () => ++authority,
isCurrent: ticket => ticket === authority,
beforeFly: ticket => { calls.push(['fly',ticket]); return true; },
search: (query, options) => new Promise((resolve,reject) => requests.push({query,options,resolve,reject})),
onStart: ticket => calls.push(['start',ticket]),
onResult: (result,query) => calls.push(['result',result,query]),
isCurrent: (ticket) => ticket === authority,
beforeFly: (ticket) => {
calls.push(['fly', ticket]);
return true;
},
search: (query, options) =>
new Promise((resolve, reject) =>
requests.push({ query, options, resolve, reject }),
),
onStart: (ticket) => calls.push(['start', ticket]),
onResult: (result, query) => calls.push(['result', result, query]),
onMissing: () => calls.push(['missing']),
onError: error => calls.push(['error',error.message]),
onSettled: ticket => calls.push(['settled',ticket]),
onError: (error) => calls.push(['error', error.message]),
onSettled: (ticket) => calls.push(['settled', ticket]),
});
return {search,requests,calls,takeCamera:()=>++authority};
return { search, requests, calls, takeCamera: () => ++authority };
}
test('a newer lookup aborts its predecessor and only its result is presented', async () => {
const f=fixture();const first=f.search.run(' First ');const second=f.search.run('Second');
assert.equal(f.requests[0].query,'First');assert.equal(f.requests[0].options.signal.aborted,true);
assert.equal(f.requests[0].options.beforeFly(),false);
assert.equal(f.requests[1].options.beforeFly(),true);
f.requests[1].resolve({label:'Second'});await second;
f.requests[0].resolve({label:'First'});await first;
assert.deepEqual(f.calls.filter(x=>x[0]==='result'),[['result',{label:'Second'},'Second']]);
assert.equal(f.search.controller,null);
const f = fixture();
const first = f.search.run(' First ');
const second = f.search.run('Second');
assert.equal(f.requests[0].query, 'First');
assert.equal(f.requests[0].options.signal.aborted, true);
assert.equal(f.requests[0].options.beforeFly(), false);
assert.equal(f.requests[1].options.beforeFly(), true);
f.requests[1].resolve({ label: 'Second' });
await second;
f.requests[0].resolve({ label: 'First' });
await first;
assert.deepEqual(
f.calls.filter((x) => x[0] === 'result'),
[['result', { label: 'Second' }, 'Second']],
);
assert.equal(f.search.controller, null);
});
test('another camera action revokes a pending lookup without presenting an error', async () => {
const f=fixture();const pending=f.search.run('Place');f.takeCamera();
assert.equal(f.requests[0].options.beforeFly(),false);
f.requests[0].reject(new Error('old failure'));await pending;
assert.equal(f.calls.some(x=>x[0]==='error'||x[0]==='result'),false);
const f = fixture();
const pending = f.search.run('Place');
f.takeCamera();
assert.equal(f.requests[0].options.beforeFly(), false);
f.requests[0].reject(new Error('old failure'));
await pending;
assert.equal(
f.calls.some((x) => x[0] === 'error' || x[0] === 'result'),
false,
);
});
test('missing and failed current lookups settle the exact owning generation', async () => {
const f=fixture();let pending=f.search.run('Missing');f.requests[0].resolve(null);await pending;
assert.ok(f.calls.some(x=>x[0]==='missing'));
pending=f.search.run('Failure');f.requests[1].reject(new Error('unavailable'));await pending;
assert.deepEqual(f.calls.filter(x=>x[0]==='error'),[['error','unavailable']]);
assert.deepEqual(f.calls.filter(x=>x[0]==='settled'),[['settled',1],['settled',2]]);
const f = fixture();
let pending = f.search.run('Missing');
f.requests[0].resolve(null);
await pending;
assert.ok(f.calls.some((x) => x[0] === 'missing'));
pending = f.search.run('Failure');
f.requests[1].reject(new Error('unavailable'));
await pending;
assert.deepEqual(
f.calls.filter((x) => x[0] === 'error'),
[['error', 'unavailable']],
);
assert.deepEqual(
f.calls.filter((x) => x[0] === 'settled'),
[
['settled', 1],
['settled', 2],
],
);
});
test('destruction cancels work and rejects late presentation and future requests', async () => {
const f=fixture();const pending=f.search.run('Place');f.search.destroy();f.search.destroy();
assert.equal(f.requests[0].options.signal.aborted,true);
f.requests[0].resolve({label:'Place'});await pending;await f.search.run('Later');
assert.equal(f.requests.length,1);assert.equal(f.calls.some(x=>x[0]==='result'||x[0]==='settled'),false);
const f = fixture();
const pending = f.search.run('Place');
f.search.destroy();
f.search.destroy();
assert.equal(f.requests[0].options.signal.aborted, true);
f.requests[0].resolve({ label: 'Place' });
await pending;
await f.search.run('Later');
assert.equal(f.requests.length, 1);
assert.equal(
f.calls.some((x) => x[0] === 'result' || x[0] === 'settled'),
false,
);
});
test('empty queries and refused navigation do not issue a lookup', async () => {
const f=fixture();await f.search.run(' ');f.search.begin=()=>false;await f.search.run('Place');
assert.equal(f.requests.length,0);assert.ok(f.calls.some(x=>x[0]==='blur'));
const f = fixture();
await f.search.run(' ');
f.search.begin = () => false;
await f.search.run('Place');
assert.equal(f.requests.length, 0);
assert.ok(f.calls.some((x) => x[0] === 'blur'));
});