From 584e77dc4f8fd0a23601d258247f67b1f15c5c05 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Fri, 13 Nov 2026 10:31:58 +0530 Subject: [PATCH 01/12] Add a finder and a section index to Data Layers The Data Layers panel gains a search field, an All / On / Off and category filter, and a tick index down its side for jumping between sections. Both sit beside the Data Library browse control. Co-authored-by: Sameh Khamis --- src/ui/styles/layer-finder.css | 187 ++++++++++++++++++++++ src/ui/styles/layer-section-index.css | 98 ++++++++++++ src/ui/views/LayerPanelsView.vue | 5 + src/ui/views/panels/LayerFinder.vue | 148 +++++++++++++++++ src/ui/views/panels/LayerSectionIndex.vue | 74 +++++++++ 5 files changed, 512 insertions(+) create mode 100644 src/ui/styles/layer-finder.css create mode 100644 src/ui/styles/layer-section-index.css create mode 100644 src/ui/views/panels/LayerFinder.vue create mode 100644 src/ui/views/panels/LayerSectionIndex.vue diff --git a/src/ui/styles/layer-finder.css b/src/ui/styles/layer-finder.css new file mode 100644 index 000000000..865e6b9c4 --- /dev/null +++ b/src/ui/styles/layer-finder.css @@ -0,0 +1,187 @@ +/* ── Data Layers search and filter ── */ +.layer-finder__btn { + display: grid; + flex: none; + place-items: center; + width: 22px; + height: 22px; + padding: 0; + color: rgba(206, 232, 239, 0.65); + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + cursor: pointer; +} + +.layer-finder__btn .material-symbols-outlined { + font-size: 14px; +} + +.layer-finder__btn:hover, +.layer-finder__btn:focus-visible { + color: #fff; + border-color: rgba(255, 255, 255, 0.24); +} + +.layer-finder__btn.active { + color: #36dcff; + background: rgba(54, 220, 255, 0.12); + border-color: rgba(54, 220, 255, 0.45); +} + +.layer-finder__filter { + position: relative; + display: inline-flex; +} + +.layer-finder__menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 20; + display: grid; + min-width: 140px; + padding: 4px; + background: rgba(16, 20, 28, 0.96); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5); +} + +.layer-finder__menu button { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.4rem 0.55rem; + color: rgba(225, 236, 242, 0.85); + font-size: 0.72rem; + text-align: start; + background: none; + border: 0; + border-radius: 7px; + cursor: pointer; +} + +.layer-finder__menu button .material-symbols-outlined { + font-size: 14px; + color: rgba(199, 214, 222, 0.6); +} + +.layer-finder__menu button:hover { + background: rgba(255, 255, 255, 0.06); +} + +.layer-finder__menu button.active, +.layer-finder__menu button.active .material-symbols-outlined { + color: #7af0ff; +} + +.layer-finder__bar { + display: flex; + flex: none; + align-items: center; + gap: 0.4rem; + margin: 0.2rem 0 0.5rem; +} + +.layer-finder__field { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: 0.35rem; + padding: 0 0.55rem; + height: 30px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(54, 220, 255, 0.3); + border-radius: 8px; +} + +.layer-finder__field .material-symbols-outlined { + color: rgba(199, 214, 222, 0.6); + font-size: 15px; +} + +.layer-finder__field input { + flex: 1 1 auto; + min-width: 0; + color: rgba(236, 244, 248, 0.95); + font-size: 0.75rem; + background: none; + border: 0; + outline: none; +} + +.layer-finder__chip { + display: inline-flex; + flex: none; + align-items: center; + gap: 0.25rem; + height: 26px; + padding: 0 0.5rem; + color: #7af0ff; + font-family: var(--font-mono); + font-size: 0.56rem; + letter-spacing: 0.1rem; + text-transform: uppercase; + background: rgba(54, 220, 255, 0.12); + border: 1px solid rgba(54, 220, 255, 0.45); + border-radius: 999px; + cursor: pointer; +} + +.layer-finder__chip .material-symbols-outlined { + font-size: 12px; +} + +/* Rows and group headings the search or filter leaves out. */ +#data-toggles .is-filtered { + display: none !important; +} + +#data-toggles.is-filtering[data-shown='0']::after { + content: 'No layers match'; + display: block; + padding: 1rem 0.5rem; + color: rgba(199, 214, 222, 0.5); + font-size: 0.72rem; + text-align: center; +} + +.layer-finder__menu { + max-height: 60vh; + overflow-y: auto; + min-width: 170px; +} + +.layer-finder__menu-head { + padding: 0.45rem 0.55rem 0.2rem; + color: rgba(199, 214, 222, 0.5); + font-family: var(--font-mono); + font-size: 0.52rem; + letter-spacing: 0.14rem; + text-transform: uppercase; +} + +.layer-finder__menu-head:not(:first-child) { + margin-top: 0.2rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +/* Several chips can show at once. */ +.layer-finder__bar { + flex-wrap: wrap; +} + +/* Chips sized like the rows' ON/OFF switches: small and square. */ +.layer-finder__chip { + height: 20px; + gap: 0.2rem; + padding: 0 6px 0 8px; + font-size: 9px; + letter-spacing: 0.08rem; + border-radius: 4px; +} + +.layer-finder__chip .material-symbols-outlined { + font-size: 11px; +} diff --git a/src/ui/styles/layer-section-index.css b/src/ui/styles/layer-section-index.css new file mode 100644 index 000000000..a2695e019 --- /dev/null +++ b/src/ui/styles/layer-section-index.css @@ -0,0 +1,98 @@ +/* ── Data Layers group index: ticks along the panel's right edge ─────── */ +#data-panel { + position: relative; +} + +.layer-section-index { + position: absolute; + top: 64px; + right: 2px; + bottom: 24px; + z-index: 2; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + width: 10px; +} + +#data-panel.collapsed .layer-section-index { + display: none; +} + +.layer-section-index__tick { + position: relative; + width: 10px; + height: 16px; + padding: 0; + background: none; + border: 0; + cursor: pointer; +} + +.layer-section-index__tick::before { + content: ''; + position: absolute; + top: 2px; + bottom: 2px; + left: 4px; + width: 2px; + border-radius: 2px; + background: rgba(199, 214, 222, 0.32); + transition: + background 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.layer-section-index__tick:hover::before, +.layer-section-index__tick:focus-visible::before { + background: rgba(236, 244, 248, 0.85); + transform: scaleX(1.5); +} + +.layer-section-index__tick.is-current::before { + background: #36dcff; + box-shadow: 0 0 6px rgba(54, 220, 255, 0.6); +} + +.layer-section-index__tick:focus-visible { + outline: none; +} + +/* The group's name, shown to the left (inside the panel) on hover or + focus. Hidden labels take no room, so nothing reaches past the panel. */ +.layer-section-index__label { + position: absolute; + top: 50%; + right: 14px; + padding: 0.25rem 0.5rem; + color: rgba(236, 244, 248, 0.92); + font-family: var(--font-mono); + font-size: 0.56rem; + letter-spacing: 0.12rem; + text-transform: uppercase; + white-space: nowrap; + background: rgba(12, 12, 20, 0.88); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + display: none; + opacity: 0; + transform: translate(4px, -50%); + pointer-events: none; + z-index: 3; + transition: + opacity 120ms ease, + transform 120ms ease; +} + +.layer-section-index__tick:hover .layer-section-index__label, +.layer-section-index__tick:focus-visible .layer-section-index__label { + display: block; + opacity: 1; + transform: translate(0, -50%); +} + +body.mobile-shell .layer-section-index { + display: none; +} diff --git a/src/ui/views/LayerPanelsView.vue b/src/ui/views/LayerPanelsView.vue index b87525530..0d577ca1d 100644 --- a/src/ui/views/LayerPanelsView.vue +++ b/src/ui/views/LayerPanelsView.vue @@ -14,6 +14,8 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue'; import DrawControls from './panels/DrawControls.vue'; import GeoImport from './panels/GeoImport.vue'; import UiSelect from '../kit/UiSelect.js'; +import LayerSectionIndex from './panels/LayerSectionIndex.vue'; +import LayerFinder from './panels/LayerFinder.vue'; import DataLibraryBrowse from './panels/DataLibraryBrowse.vue'; // Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents @@ -43,7 +45,10 @@ async function importChosen(event) {
+ +
+
diff --git a/src/ui/views/panels/LayerFinder.vue b/src/ui/views/panels/LayerFinder.vue new file mode 100644 index 000000000..5d4d89601 --- /dev/null +++ b/src/ui/views/panels/LayerFinder.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/ui/views/panels/LayerSectionIndex.vue b/src/ui/views/panels/LayerSectionIndex.vue new file mode 100644 index 000000000..5bf6e394d --- /dev/null +++ b/src/ui/views/panels/LayerSectionIndex.vue @@ -0,0 +1,74 @@ + + + From 0550f7c2d263881f15ccfc82b8aa858c7265c422 Mon Sep 17 00:00:00 2001 From: Manjunath Paparaju Date: Sat, 14 Nov 2026 16:12:59 -0800 Subject: [PATCH 02/12] Place the Data Layers group label outside the panel and soften the finder's focus The group label is drawn on the page just outside the panel's edge instead of over the rows; the panel clips what leaves it, so the label is teleported to the body and placed from the tick. The finder shows focus as a soft ring on the field's frame in place of the hard outline round the bare input. Co-authored-by: Sameh Khamis --- src/ui/styles/layer-finder.css | 13 ++++++++ src/ui/styles/layer-section-index.css | 38 +++++++++++++---------- src/ui/views/panels/LayerSectionIndex.vue | 22 +++++++++++-- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/ui/styles/layer-finder.css b/src/ui/styles/layer-finder.css index 865e6b9c4..32a09092f 100644 --- a/src/ui/styles/layer-finder.css +++ b/src/ui/styles/layer-finder.css @@ -111,6 +111,19 @@ outline: none; } +/* Focus shows on the field's frame, as a soft ring, in place of the hard + keyboard outline round the bare input. */ +.layer-finder__field input:focus, +.layer-finder__field input:focus-visible { + outline: none !important; + box-shadow: none; +} + +.layer-finder__field:focus-within { + border-color: rgba(54, 220, 255, 0.7); + box-shadow: 0 0 0 3px rgba(54, 220, 255, 0.1); +} + .layer-finder__chip { display: inline-flex; flex: none; diff --git a/src/ui/styles/layer-section-index.css b/src/ui/styles/layer-section-index.css index a2695e019..1046f153e 100644 --- a/src/ui/styles/layer-section-index.css +++ b/src/ui/styles/layer-section-index.css @@ -60,12 +60,11 @@ outline: none; } -/* The group's name, shown to the left (inside the panel) on hover or - focus. Hidden labels take no room, so nothing reaches past the panel. */ +/* The group's name, drawn on the page just outside the panel's edge while a + tick is pointed at or focused, so it covers none of the rows. */ .layer-section-index__label { - position: absolute; - top: 50%; - right: 14px; + position: fixed; + z-index: 30; padding: 0.25rem 0.5rem; color: rgba(236, 244, 248, 0.92); font-family: var(--font-mono); @@ -76,21 +75,26 @@ background: rgba(12, 12, 20, 0.88); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px; - display: none; - opacity: 0; - transform: translate(4px, -50%); + transform: translateY(-50%); pointer-events: none; - z-index: 3; - transition: - opacity 120ms ease, - transform 120ms ease; + animation: layer-section-index-label 120ms ease both; } -.layer-section-index__tick:hover .layer-section-index__label, -.layer-section-index__tick:focus-visible .layer-section-index__label { - display: block; - opacity: 1; - transform: translate(0, -50%); +@keyframes layer-section-index-label { + from { + opacity: 0; + translate: -4px 0; + } + to { + opacity: 1; + translate: 0 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .layer-section-index__label { + animation: none; + } } body.mobile-shell .layer-section-index { diff --git a/src/ui/views/panels/LayerSectionIndex.vue b/src/ui/views/panels/LayerSectionIndex.vue index 5bf6e394d..79f5b6262 100644 --- a/src/ui/views/panels/LayerSectionIndex.vue +++ b/src/ui/views/panels/LayerSectionIndex.vue @@ -32,6 +32,17 @@ function syncCurrent() { if (scrolls && list.scrollTop + list.clientHeight >= list.scrollHeight - 2) index = sections.value.length - 1; current.value = Math.max(0, index); } +// The name of the tick being pointed at or focused. It is drawn on the page +// itself, just outside the panel's edge: the panel clips what leaves it, and a +// label inside would cover the rows. +const tip = ref(null); +function showTip(event, section) { + const box = event.currentTarget.getBoundingClientRect(); + tip.value = { label: section.label, left: `${Math.round(box.right + 10)}px`, top: `${Math.round(box.top + box.height / 2)}px` }; +} +const hideTip = () => { + tip.value = null; +}; function jump(i) { const h = headings()[i]; if (!h || !list) return; @@ -67,8 +78,13 @@ onBeforeUnmount(() => { :aria-label="t('Jump to {name}', { name: section.label })" :aria-current="section.i === current ? 'true' : undefined" @click="jump(section.i)" - > - - + @mouseenter="showTip($event, section)" + @mouseleave="hideTip" + @focus="showTip($event, section)" + @blur="hideTip" + > + + + From 8da6fb793186a8adfa62ac928d1f27b039bee181 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 16:16:24 -0800 Subject: [PATCH 03/12] Find layers by the name on screen, and share the finder's state in its module The finder matched only the English name, so a search in another language found nothing. Its header buttons and its bar share state through the component's module rather than a global. Co-authored-by: Manjunath Paparaju --- src/ui/views/panels/LayerFinder.vue | 45 +++++++++++++++++++---------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/ui/views/panels/LayerFinder.vue b/src/ui/views/panels/LayerFinder.vue index 5d4d89601..e55584b4d 100644 --- a/src/ui/views/panels/LayerFinder.vue +++ b/src/ui/views/panels/LayerFinder.vue @@ -1,6 +1,21 @@ + + diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9a38408b2..703a6bd69 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -3727,6 +3727,10 @@ function findDataLayerRow(layerId) { } function focusDataLayerRow(row, layerId) { + // A row the Data Layers finder hides could not be seen: show every row + // again (LAYER_FINDER_CLEAR_EVENT in LayerFinder.vue). + if (row.classList.contains('is-filtered')) + document.dispatchEvent(new CustomEvent('gev:layer-finder-clear')); row.scrollIntoView({ block: 'center', behavior: 'smooth' }); row.classList.remove('gev-voice-focus'); void row.offsetWidth; From f94048449825576749fbbe0117d40c27e10be931 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 16:25:32 -0800 Subject: [PATCH 08/12] Read the section index only when the groups change, and keep its label on screen Live counts repaint the rows many times a second; the index now reads its headings at most once a frame, and only when a heading changes. The label goes before the tick when there is no room after it, as in a right-to-left language, and the ticks sit at the panel's inline end. Co-authored-by: Manjunath Paparaju --- src/ui/styles/layer-section-index.css | 7 +++- src/ui/views/panels/LayerSectionIndex.vue | 42 ++++++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/ui/styles/layer-section-index.css b/src/ui/styles/layer-section-index.css index 1046f153e..0e3283ae3 100644 --- a/src/ui/styles/layer-section-index.css +++ b/src/ui/styles/layer-section-index.css @@ -6,7 +6,7 @@ .layer-section-index { position: absolute; top: 64px; - right: 2px; + inset-inline-end: 2px; bottom: 24px; z-index: 2; display: flex; @@ -80,6 +80,11 @@ animation: layer-section-index-label 120ms ease both; } +/* Drawn before the tick when there is no room after it. */ +.layer-section-index__label--before { + transform: translate(-100%, -50%); +} + @keyframes layer-section-index-label { from { opacity: 0; diff --git a/src/ui/views/panels/LayerSectionIndex.vue b/src/ui/views/panels/LayerSectionIndex.vue index 79f5b6262..6dbd76055 100644 --- a/src/ui/views/panels/LayerSectionIndex.vue +++ b/src/ui/views/panels/LayerSectionIndex.vue @@ -17,9 +17,33 @@ function headings() { return list ? [...list.querySelectorAll('.data-layer-group-heading:not(.is-filtered)')] : []; } function readSections() { - sections.value = headings().map((h, i) => ({ i, label: h.textContent.trim() })); + const next = headings().map((h, i) => ({ i, label: h.textContent.trim() })); + // Live counts repaint the rows all the time; keep the ticks unless the + // groups themselves changed. + const same = + next.length === sections.value.length && + next.every((s, i) => s.label === sections.value[i].label); + if (!same) sections.value = next; syncCurrent(); } +// Only headings matter: a heading added, removed, renamed (a language change) +// or filtered out. Rows' counts and states change many times a second. +const isHeading = (node) => + node?.classList?.contains('data-layer-group-heading') || + node?.parentElement?.classList?.contains('data-layer-group-heading'); +let frame = 0; +function onMutations(mutations) { + if (frame) return; + const relevant = mutations.some( + (m) => + (m.type === 'childList' && m.target === list) || isHeading(m.target), + ); + if (!relevant) return; + frame = requestAnimationFrame(() => { + frame = 0; + readSections(); + }); +} function syncCurrent() { if (!list) return; const top = list.getBoundingClientRect().top + 8; @@ -38,7 +62,15 @@ function syncCurrent() { const tip = ref(null); function showTip(event, section) { const box = event.currentTarget.getBoundingClientRect(); - tip.value = { label: section.label, left: `${Math.round(box.right + 10)}px`, top: `${Math.round(box.top + box.height / 2)}px` }; + // Beside the tick on whichever side has room: the stack sits on the right + // in a right-to-left language. + const before = window.innerWidth - box.right < 180; + tip.value = { + label: section.label, + before, + left: `${Math.round(before ? box.left - 10 : box.right + 10)}px`, + top: `${Math.round(box.top + box.height / 2)}px`, + }; } const hideTip = () => { tip.value = null; @@ -52,7 +84,7 @@ function jump(i) { onMounted(() => { list = document.getElementById('data-toggles'); if (!list) return; - observer = new MutationObserver(readSections); + observer = new MutationObserver(onMutations); observer.observe(list, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] }); list.addEventListener('scroll', syncCurrent, { passive: true }); // The list is hidden while the panel is shut; read again when it opens. @@ -63,6 +95,7 @@ onMounted(() => { onBeforeUnmount(() => { observer?.disconnect(); resize?.disconnect(); + cancelAnimationFrame(frame); list?.removeEventListener('scroll', syncCurrent); }); @@ -84,7 +117,8 @@ onBeforeUnmount(() => { @blur="hideTip" > - + From a375ace8ebdf7ce8a054e774700e4579f6524283 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 16:25:32 -0800 Subject: [PATCH 09/12] Test the Data Layers finder and section index Co-authored-by: Manjunath Paparaju --- scripts/format-scope.json | 3 +- src/ui/layerFinder.test.mjs | 200 ++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/ui/layerFinder.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ff876cef9..7f3542bae 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1464,5 +1464,6 @@ "src/ui/styles/scope-off-shadows.css", "src/ui/styles/cockpit-panels.css", "src/ui/styles/layer-section-index.css", - "src/ui/styles/layer-finder.css" + "src/ui/styles/layer-finder.css", + "src/ui/layerFinder.test.mjs" ] diff --git a/src/ui/layerFinder.test.mjs b/src/ui/layerFinder.test.mjs new file mode 100644 index 000000000..8cd61ff1a --- /dev/null +++ b/src/ui/layerFinder.test.mjs @@ -0,0 +1,200 @@ +// src/ui/layerFinder.test.mjs — the Data Layers finder and section index work +// on the rows LayerRows renders: search, the On/Off and category filter, +// Escape, the clear request voice sends, and the group ticks. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, h, nextTick } from 'vue'; +import LayerFinder, { + LAYER_FINDER_CLEAR_EVENT, +} from './views/panels/LayerFinder.vue'; +import LayerSectionIndex from './views/panels/LayerSectionIndex.vue'; + +const ROWS = [ + { group: 'movement', heading: 'Movement' }, + { id: 'flights', name: 'Live Flights', english: 'Live Flights', on: true }, + { id: 'ships', name: 'Ships', english: 'Ships', on: false }, + { group: 'weather', heading: 'Weather' }, + { id: 'radar', name: 'Radar météo', english: 'Weather Radar', on: false }, +]; + +const flush = async () => { + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); +}; + +function fixture(t) { + document.body.replaceChildren(); + // Frames run at once, so the observers' batching does not hold a test up. + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + const panel = document.body.appendChild(document.createElement('section')); + panel.id = 'data-panel'; + const header = panel.appendChild(document.createElement('div')); + const bar = panel.appendChild(document.createElement('div')); + const list = panel.appendChild(document.createElement('div')); + list.id = 'data-toggles'; + for (const row of ROWS) { + if (row.heading) { + const heading = list.appendChild(document.createElement('h3')); + heading.className = 'data-layer-group-heading'; + heading.dataset.group = row.group; + heading.textContent = row.heading; + continue; + } + const el = list.appendChild(document.createElement('div')); + el.className = 'data-toggle-row'; + el.dataset.layerId = row.id; + el.dataset.layerName = row.english; + el.innerHTML = `${row.name}`; + } + const index = panel.appendChild(document.createElement('div')); + const apps = [ + createApp(() => h(LayerFinder, { part: 'buttons' })), + createApp(() => h(LayerFinder, { part: 'bar' })), + createApp(LayerSectionIndex), + ]; + apps[0].mount(header); + apps[1].mount(bar); + apps[2].mount(index); + t.after(async () => { + document.dispatchEvent(new CustomEvent(LAYER_FINDER_CLEAR_EVENT)); + for (const app of apps) app.unmount(); + }); + const shown = () => + [...list.querySelectorAll('.data-toggle-row:not(.is-filtered)')].map( + (row) => row.dataset.layerId, + ); + const search = async (text) => { + if (!document.getElementById('layer-finder-input')) { + document.getElementById('layer-finder-search-btn').click(); + await flush(); + } + const input = document.getElementById('layer-finder-input'); + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); + await flush(); + }; + const openFilter = async () => { + document.querySelector('[aria-controls="layer-finder-menu"]').click(); + await flush(); + return document.getElementById('layer-finder-menu'); + }; + return { list, panel, shown, search, openFilter }; +} + +test('search finds a layer by the name on screen or its English name', async (t) => { + const { shown, search, list } = fixture(t); + await search('météo'); + assert.deepEqual(shown(), ['radar']); + await search('weather radar'); + assert.deepEqual(shown(), ['radar']); + // The Movement heading has no rows left, so it is hidden too. + assert.equal( + list + .querySelector('[data-group="movement"]') + .classList.contains('is-filtered'), + true, + ); + await search('nothing like it'); + assert.deepEqual(shown(), []); + assert.match( + document.querySelector('.layer-finder__empty')?.textContent || '', + /No layers match/, + ); +}); + +test('On and Off follow whether the layer is enabled', async (t) => { + const { shown, openFilter } = fixture(t); + const menu = await openFilter(); + const on = [...menu.querySelectorAll('button')].find((b) => + b.textContent.trim().endsWith('On'), + ); + on.click(); + await flush(); + assert.deepEqual(shown(), ['flights']); + assert.equal(on.getAttribute('aria-pressed'), 'true'); +}); + +test('a chosen category survives its heading being translated', async (t) => { + const { shown, openFilter, list } = fixture(t); + const menu = await openFilter(); + [...menu.querySelectorAll('button')] + .find((b) => b.textContent.trim().endsWith('Weather')) + .click(); + await flush(); + assert.deepEqual(shown(), ['radar']); + list.querySelector('[data-group="weather"]').textContent = 'Météo'; + await flush(); + assert.deepEqual(shown(), ['radar']); + assert.match( + document.querySelector('.layer-finder__chip').textContent, + /Météo/, + ); +}); + +test('Escape in the search field closes it without reaching the panel', async (t) => { + const { search, panel } = fixture(t); + let reached = false; + panel.addEventListener('keydown', () => (reached = true)); + await search('ships'); + document + .getElementById('layer-finder-input') + .dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await flush(); + assert.equal(reached, false); + assert.equal(document.getElementById('layer-finder-input'), null); + assert.equal(document.activeElement?.id, 'layer-finder-search-btn'); +}); + +test('the filter menu takes focus, moves with the arrows and closes on Escape', async (t) => { + const { openFilter } = fixture(t); + const menu = await openFilter(); + const buttons = [...menu.querySelectorAll('button')]; + assert.equal(document.activeElement, buttons[0]); + menu.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + assert.equal(document.activeElement, buttons[1]); + menu.dispatchEvent( + new KeyboardEvent('keydown', { key: 'End', bubbles: true }), + ); + assert.equal(document.activeElement, buttons.at(-1)); + menu.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await flush(); + assert.equal(document.getElementById('layer-finder-menu'), null); + assert.equal( + document.activeElement, + document.querySelector('[aria-controls="layer-finder-menu"]'), + ); +}); + +test('a clear request shows every row again', async (t) => { + const { shown, search } = fixture(t); + await search('ships'); + assert.deepEqual(shown(), ['ships']); + document.dispatchEvent(new CustomEvent(LAYER_FINDER_CLEAR_EVENT)); + await flush(); + assert.deepEqual(shown(), ['flights', 'ships', 'radar']); +}); + +test('the section index has a tick per group and drops filtered groups', async (t) => { + const { search } = fixture(t); + await flush(); + assert.equal( + document.querySelectorAll('.layer-section-index__tick').length, + 2, + ); + await search('ships'); + await flush(); + // One group left: no index. + assert.equal( + document.querySelectorAll('.layer-section-index__tick').length, + 0, + ); +}); From 00794de1460052efa041d497a3c3d40a64b6b9fb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 16:25:55 -0800 Subject: [PATCH 10/12] Describe the Data Layers finder and section index Co-authored-by: Manjunath Paparaju --- docs/CURRENT-STATE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6b814f4d6..7bc9c7a07 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -723,6 +723,27 @@ and touch screens, while the rails are hidden (clean view, recording), and it follows the rails' sides in a right-to-left language. On a phone, Visual is in the More sheet with the dock's Visual Presets, as before. +## Data Layers finder and section index — November 15, 2026 + +The Data Layers header has a search button and a filter button +(`src/ui/views/panels/LayerFinder.vue`). Search opens a field under the +header that matches each layer's name on screen and its English name; Escape +closes it and leaves the panel open. The filter is a small popover with two +groups, Status (All layers, On, Off: whether the layer is enabled) and +Category (the list's groups, kept by their key, so a language change keeps +the choice). The arrows, Home and End move through it and Escape closes it. +Rows and groups that are left out hide; chips under the header show the +active filters and clear them, and "No layers match" says when nothing is +left. Neither the search nor the filter is saved or put in share links. A +voice request that points at a hidden layer clears the finder first +(`gev:layer-finder-clear`). + +A column of ticks down the list's inline end +(`src/ui/views/panels/LayerSectionIndex.vue`) has one tick per group still +shown; hovering or focusing a tick names its group beside it, on whichever +side has room, and clicking scrolls the list there. The tick for the group in +view is lit. Phones do not show it. + ## Cockpit side panels — November 14, 2026 On the desktop, the cockpit's side panels match the main page's From 4c4f24355f681a20eea2eb9f65c43b871a68d407 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 16:27:54 -0800 Subject: [PATCH 11/12] Give the voice test's stub row a classList.contains focusDataLayerRow now asks whether the finder hides the row. Co-authored-by: Manjunath Paparaju --- src/voice/gevActions.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 98bc786db..3b421e340 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -1954,7 +1954,11 @@ test('showing a layer opens the panel that holds its row: Directions lives in Ut const focused = []; const rowIn = (panelId) => ({ dataset: { layerName: panelId === 'utilities-panel' ? 'Directions' : 'Live Flights' }, - classList: { add: (name) => focused.push(name), remove() {} }, + classList: { + add: (name) => focused.push(name), + remove() {}, + contains: () => false, + }, scrollIntoView() {}, closest: (selector) => (selector === '[data-panel-id]' ? { id: panelId } : null), }); From 36e26108c1c7e4cc699c60e48225c1a7051fd71e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 16:33:53 -0800 Subject: [PATCH 12/12] Note the Data Layers finder in the changelog Co-authored-by: Manjunath Paparaju --- changelog.d/1740-layer-finder.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1740-layer-finder.md diff --git a/changelog.d/1740-layer-finder.md b/changelog.d/1740-layer-finder.md new file mode 100644 index 000000000..6079c6f44 --- /dev/null +++ b/changelog.d/1740-layer-finder.md @@ -0,0 +1,3 @@ +- Add a search and a filter (On / Off and category) to the Data Layers panel, + matching layer names in the UI language, and a section index down its side + that jumps to each group (manjunath22466, samehkhamis, #1740).