feat(directions): keyless A→B directions with turn-by-turn steps and FLY (#564)

Supersedes #219 by @spcpza, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: spcpza <[email protected]>
This commit is contained in:
Bilawal Sidhuandspcpza authored and GitHub committed 2026-09-14 20:09:41 -05:00
1 parent 9fc8d1966b
commit d78696dc17
29 files changed
+4658 -65

No files matched your search

+103 -3
View File
@@ -206,7 +206,23 @@ export class LayerPanel {
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
this._syncRowControls(controls, layer);
// An ordered list below the chips, for a layer whose row carries a
// sequence (turn-by-turn directions). Its own delegated listener, its
// own container — the chip row stays a chip row.
const list = document.createElement('ol');
list.className = 'data-row-list';
list.hidden = true;
this._bind(list, 'click', (event) => {
const button = event.target?.closest?.('.data-row-list-item');
if (!button || button.disabled) return;
const item = this._rowControlsFor(layer.id)?.list?.items?.find(
(entry) => entry.id === button.dataset.listItemId,
);
if (item?.params)
this.setLayerParams(layer.id, item.params, { origin: 'user' });
});
row.appendChild(list);
this._syncRowControls(controls, layer, list);
}
this._toggleContainer.appendChild(row);
@@ -232,12 +248,14 @@ export class LayerPanel {
* are replaced freely.
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
* @param {object} layer Registered layer entry.
* @param {HTMLElement|null} [listContainer] The row's `.data-row-list` node.
*/
_syncRowControls(container, layer) {
_syncRowControls(container, layer, listContainer = null) {
if (!container) return;
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
const chips = controls?.chips || [];
const legend = controls?.legend || [];
this._syncRowList(listContainer, controls?.list || null);
container.hidden = chips.length === 0 && legend.length === 0;
for (const node of [...container.children]) {
@@ -285,6 +303,84 @@ export class LayerPanel {
}
}
/**
* Render a row's ordered list (turn-by-turn directions).
*
* Each entry is a real `<button>` inside a real `<li>`, so Tab reaches it and
* Enter activates it with no key handling of our own, and the `<ol>` carries
* the ordering a screen reader announces. Items are reconciled in place,
* keyed by id, for the same reason chips are: this runs on every refresh —
* including the one a click on the list triggers — and replacing the node
* would drop keyboard focus mid-interaction.
* @param {HTMLElement|null} container The row's `.data-row-list` node.
* @param {{ariaLabel?: string, items?: Array<object>}|null} list Descriptor.
*/
_syncRowList(container, list) {
if (!container) return;
const items = list?.items || [];
container.hidden = items.length === 0;
if (list?.ariaLabel) container.setAttribute('aria-label', list.ariaLabel);
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.listItemId) stale.set(node.dataset.listItemId, node);
}
let previous = null;
let activeButton = null;
for (const item of items) {
let entry = stale.get(item.id);
stale.delete(item.id);
let button;
if (!entry) {
entry = document.createElement('li');
entry.dataset.listItemId = item.id;
button = document.createElement('button');
button.type = 'button';
button.className = 'data-row-list-item';
button.dataset.listItemId = item.id;
const lead = document.createElement('span');
lead.className = 'data-row-list-lead';
const text = document.createElement('span');
text.className = 'data-row-list-text';
button.append(lead, text);
entry.appendChild(button);
} else {
button = entry.querySelector('.data-row-list-item');
}
// Keep DOM order in step with descriptor order without rebuilding.
const anchor = previous ? previous.nextSibling : container.firstChild;
if (entry !== anchor) container.insertBefore(entry, anchor);
previous = entry;
if (!button) continue;
const lead = button.querySelector('.data-row-list-lead');
const text = button.querySelector('.data-row-list-text');
const leadText = String(item.lead ?? '');
const bodyText = String(item.text ?? '');
if (lead && lead.textContent !== leadText) lead.textContent = leadText;
if (text && text.textContent !== bodyText) text.textContent = bodyText;
button.disabled = Boolean(item.disabled);
button.classList.toggle('note', Boolean(item.disabled));
button.classList.toggle('active', Boolean(item.active));
button.classList.toggle('current', Boolean(item.current));
button.setAttribute('aria-current', item.current ? 'step' : 'false');
button.setAttribute('aria-pressed', item.active ? 'true' : 'false');
button.title = bodyText;
if (item.current) activeButton = button;
}
for (const node of stale.values()) node.remove();
// Follow the flight, but never steal a scroll the reader is making
// themselves: only when the step actually changed.
if (
activeButton &&
container.dataset.currentId !== activeButton.dataset.listItemId
) {
container.dataset.currentId = activeButton.dataset.listItemId;
activeButton.scrollIntoView?.({ block: 'nearest' });
} else if (!activeButton) {
delete container.dataset.currentId;
}
}
_refreshTogglePanel() {
if (this._destroyed || !this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
@@ -314,7 +410,11 @@ export class LayerPanel {
meta.textContent = this._buildMetaText(layer);
}
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
this._syncRowControls(
row.querySelector('.data-toggle-controls'),
layer,
row.querySelector('.data-row-list'),
);
}
}