Make the Data Layers finder keep Escape, its category and keyboard users

- Escape in the field closes the search alone, focus back on its button,
  without folding the panel or leaving the cockpit.
- The category is the group's key (LayerRows' data-group), so a language
  change keeps it; a group that goes away clears it.
- On and Off follow whether the layer is enabled, not its feed's state.
- The filter is a disclosure of two groups of toggle buttons: opening it
  focuses the chosen option, the arrows, Home and End move, Escape closes
  it, and it stays inside the panel.
- No layers match is translated, the search matches names only, and the
  rows are filtered once per frame at most.
- A voice request to show a layer the finder hides clears the finder.

Co-authored-by: Manjunath Paparaju <[email protected]>
This commit is contained in:
Sameh KhamisandManjunath Paparaju committed 2026-11-14 16:25:32 -08:00
1 parent 6ff0923f30
commit 919ea8d4c0
18 files changed
+207 -81

No files matched your search

+2 -1
View File
@@ -3986,5 +3986,6 @@
"Status": "الحالة", "Status": "الحالة",
"Search layers…": "ابحث في الطبقات…", "Search layers…": "ابحث في الطبقات…",
"Layer groups": "مجموعات الطبقات", "Layer groups": "مجموعات الطبقات",
"Jump to {name}": "الانتقال إلى {name}" "Jump to {name}": "الانتقال إلى {name}",
"No layers match": "لا توجد طبقات مطابقة"
} }
+2 -1
View File
@@ -3638,5 +3638,6 @@
"Status": "Status", "Status": "Status",
"Search layers…": "Ebenen suchen…", "Search layers…": "Ebenen suchen…",
"Layer groups": "Ebenengruppen", "Layer groups": "Ebenengruppen",
"Jump to {name}": "Zu {name} springen" "Jump to {name}": "Zu {name} springen",
"No layers match": "Keine passenden Ebenen"
} }
+2 -1
View File
@@ -3725,5 +3725,6 @@
"Status": "Estado", "Status": "Estado",
"Search layers…": "Buscar capas…", "Search layers…": "Buscar capas…",
"Layer groups": "Grupos de capas", "Layer groups": "Grupos de capas",
"Jump to {name}": "Ir a {name}" "Jump to {name}": "Ir a {name}",
"No layers match": "Ninguna capa coincide"
} }
+2 -1
View File
@@ -3725,5 +3725,6 @@
"Status": "État", "Status": "État",
"Search layers…": "Rechercher des couches…", "Search layers…": "Rechercher des couches…",
"Layer groups": "Groupes de couches", "Layer groups": "Groupes de couches",
"Jump to {name}": "Aller à {name}" "Jump to {name}": "Aller à {name}",
"No layers match": "Aucune couche ne correspond"
} }
+2 -1
View File
@@ -3725,5 +3725,6 @@
"Status": "מצב", "Status": "מצב",
"Search layers…": "חיפוש שכבות…", "Search layers…": "חיפוש שכבות…",
"Layer groups": "קבוצות שכבות", "Layer groups": "קבוצות שכבות",
"Jump to {name}": "מעבר אל {name}" "Jump to {name}": "מעבר אל {name}",
"No layers match": "אין שכבות תואמות"
} }
+2 -1
View File
@@ -3725,5 +3725,6 @@
"Status": "Stato", "Status": "Stato",
"Search layers…": "Cerca livelli…", "Search layers…": "Cerca livelli…",
"Layer groups": "Gruppi di livelli", "Layer groups": "Gruppi di livelli",
"Jump to {name}": "Vai a {name}" "Jump to {name}": "Vai a {name}",
"No layers match": "Nessun livello corrisponde"
} }
+2 -1
View File
@@ -3551,5 +3551,6 @@
"Status": "状態", "Status": "状態",
"Search layers…": "レイヤーを検索…", "Search layers…": "レイヤーを検索…",
"Layer groups": "レイヤーのグループ", "Layer groups": "レイヤーのグループ",
"Jump to {name}": "{name} へ移動" "Jump to {name}": "{name} へ移動",
"No layers match": "一致するレイヤーはありません"
} }
+2 -1
View File
@@ -3725,5 +3725,6 @@
"Status": "Estado", "Status": "Estado",
"Search layers…": "Pesquisar camadas…", "Search layers…": "Pesquisar camadas…",
"Layer groups": "Grupos de camadas", "Layer groups": "Grupos de camadas",
"Jump to {name}": "Ir para {name}" "Jump to {name}": "Ir para {name}",
"No layers match": "Nenhuma camada corresponde"
} }
+2 -1
View File
@@ -3812,5 +3812,6 @@
"Status": "Состояние", "Status": "Состояние",
"Search layers…": "Поиск слоёв…", "Search layers…": "Поиск слоёв…",
"Layer groups": "Группы слоёв", "Layer groups": "Группы слоёв",
"Jump to {name}": "Перейти к «{name}»" "Jump to {name}": "Перейти к «{name}»",
"No layers match": "Нет подходящих слоёв"
} }
+2 -1
View File
@@ -3638,5 +3638,6 @@
"Status": "Status", "Status": "Status",
"Search layers…": "Sök lager…", "Search layers…": "Sök lager…",
"Layer groups": "Lagergrupper", "Layer groups": "Lagergrupper",
"Jump to {name}": "Gå till {name}" "Jump to {name}": "Gå till {name}",
"No layers match": "Inga lager matchar"
} }
+2 -1
View File
@@ -3812,5 +3812,6 @@
"Status": "Стан", "Status": "Стан",
"Search layers…": "Пошук шарів…", "Search layers…": "Пошук шарів…",
"Layer groups": "Групи шарів", "Layer groups": "Групи шарів",
"Jump to {name}": "Перейти до «{name}»" "Jump to {name}": "Перейти до «{name}»",
"No layers match": "Немає відповідних шарів"
} }
+2 -1
View File
@@ -3551,5 +3551,6 @@
"Status": "状态", "Status": "状态",
"Search layers…": "搜索图层…", "Search layers…": "搜索图层…",
"Layer groups": "图层分组", "Layer groups": "图层分组",
"Jump to {name}": "跳转到{name}" "Jump to {name}": "跳转到{name}",
"No layers match": "没有匹配的图层"
} }
+2 -1
View File
@@ -3551,5 +3551,6 @@
"Status": "狀態", "Status": "狀態",
"Search layers…": "搜尋圖層…", "Search layers…": "搜尋圖層…",
"Layer groups": "圖層分組", "Layer groups": "圖層分組",
"Jump to {name}": "跳至{name}" "Jump to {name}": "跳至{name}",
"No layers match": "沒有符合的圖層"
} }
+3
View File
@@ -248,6 +248,9 @@ export function onKeyDown(event) {
if (event.target?.closest?.('.cesium-credit-lightbox')) return; if (event.target?.closest?.('.cesium-credit-lightbox')) return;
// An open Display or Radio popover takes Escape itself. // An open Display or Radio popover takes Escape itself.
if (this.isUtilityDisclosureOpen?.()) return; if (this.isUtilityDisclosureOpen?.()) return;
// So do the Data Layers search field and filter menu.
if (event.target?.closest?.('.layer-finder__field, .layer-finder__menu'))
return;
if (this.context?.contains(event.target) && !this.contextCollapsed) { if (this.context?.contains(event.target) && !this.contextCollapsed) {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
+5 -3
View File
@@ -22,9 +22,11 @@ defineProps({
<template> <template>
<template v-for="item in items" :key="item.key" <template v-for="item in items" :key="item.key"
><h3 v-if="item.heading" class="data-layer-group-heading">{{ ><h3
item.heading v-if="item.heading"
}}</h3 class="data-layer-group-heading"
:data-group="item.key"
>{{ item.heading }}</h3
><div ><div
v-else v-else
class="data-toggle-row" class="data-toggle-row"
+11 -5
View File
@@ -151,10 +151,10 @@
display: none !important; display: none !important;
} }
#data-toggles.is-filtering[data-shown='0']::after { .layer-finder__empty {
content: 'No layers match'; flex-basis: 100%;
display: block; margin: 0;
padding: 1rem 0.5rem; padding: 0.6rem 0.5rem;
color: rgba(199, 214, 222, 0.5); color: rgba(199, 214, 222, 0.5);
font-size: 0.72rem; font-size: 0.72rem;
text-align: center; text-align: center;
@@ -162,6 +162,7 @@
.layer-finder__menu { .layer-finder__menu {
max-height: 60vh; max-height: 60vh;
box-sizing: border-box;
overflow-y: auto; overflow-y: auto;
min-width: 170px; min-width: 170px;
} }
@@ -175,7 +176,12 @@
text-transform: uppercase; text-transform: uppercase;
} }
.layer-finder__menu-head:not(:first-child) { /* Each group of options lays out as part of the menu's grid. */
.layer-finder__menu > span {
display: contents;
}
.layer-finder__menu > span + span > .layer-finder__menu-head {
margin-top: 0.2rem; margin-top: 0.2rem;
border-top: 1px solid rgba(255, 255, 255, 0.08); border-top: 1px solid rgba(255, 255, 255, 0.08);
} }
+158 -60
View File
@@ -1,6 +1,9 @@
<script> <script>
import { ref as moduleRef } from 'vue'; import { ref as moduleRef } from 'vue';
/** Voice and other callers ask for this event to show every row again. */
export const LAYER_FINDER_CLEAR_EVENT = 'gev:layer-finder-clear';
// The header buttons and the bar under the header are two instances that // The header buttons and the bar under the header are two instances that
// share one finder. // share one finder.
const finder = { const finder = {
@@ -8,13 +11,16 @@ const finder = {
show: moduleRef('all'), show: moduleRef('all'),
searching: moduleRef(false), searching: moduleRef(false),
filtering: moduleRef(false), filtering: moduleRef(false),
// A group's stable key (LayerRows' `data-group`), not its translated name,
// so a language change keeps the choice.
category: moduleRef(''), category: moduleRef(''),
categories: moduleRef([]), categories: moduleRef([]),
shown: moduleRef(0),
}; };
</script> </script>
<script setup> <script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { nextTick, onBeforeUnmount, onMounted, ref, watch, computed } from 'vue';
import { msg, t } from '../../../i18n/index.js'; import { msg, t } from '../../../i18n/index.js';
// Search and filter for the Data Layers list. The rows are rendered elsewhere // Search and filter for the Data Layers list. The rows are rendered elsewhere
@@ -26,8 +32,8 @@ const props = defineProps({
part: { type: String, default: 'buttons' }, part: { type: String, default: 'buttons' },
}); });
const { query, show, searching, filtering, category, categories } = finder; const { query, show, searching, filtering, category, categories, shown } =
const input = ref(null); finder;
const SHOWS = [ const SHOWS = [
{ id: 'all', label: msg('All layers') }, { id: 'all', label: msg('All layers') },
@@ -35,36 +41,44 @@ const SHOWS = [
{ id: 'off', label: msg('Off') }, { id: 'off', label: msg('Off') },
]; ];
const narrowed = computed(
() =>
Boolean(query.value.trim()) || show.value !== 'all' || Boolean(category.value),
);
const categoryLabel = computed(
() => categories.value.find((c) => c.key === category.value)?.label || '',
);
function apply() { function apply() {
const list = document.getElementById('data-toggles'); const list = document.getElementById('data-toggles');
if (!list) return; if (!list) return;
const q = query.value.trim().toLocaleLowerCase(); const q = query.value.trim().toLocaleLowerCase();
let heading = null; let heading = null;
let headingHasRows = false; let headingHasRows = false;
let shown = 0; let count = 0;
let group = ''; let group = '';
const groups = []; const groups = [];
const close = () => heading?.classList.toggle('is-filtered', !headingHasRows); const close = () =>
heading?.classList.toggle('is-filtered', !headingHasRows);
for (const el of list.children) { for (const el of list.children) {
if (el.classList.contains('data-layer-group-heading')) { if (el.classList.contains('data-layer-group-heading')) {
close(); close();
heading = el; heading = el;
headingHasRows = false; headingHasRows = false;
group = el.textContent.trim(); group = el.dataset.group || el.textContent.trim();
groups.push(group); groups.push({ key: group, label: el.textContent.trim() });
continue; continue;
} }
if (!el.classList.contains('data-toggle-row')) continue; if (!el.classList.contains('data-toggle-row')) continue;
// The name on screen, in the UI language, and the English name too. // The name on screen, in the UI language, and the English name too.
const name = [ const name = [el.querySelector('.data-name')?.textContent, el.dataset.layerName]
el.querySelector('.data-name')?.textContent,
el.dataset.layerName,
el.querySelector('.data-toggle-meta')?.textContent,
]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
.toLocaleLowerCase(); .toLocaleLowerCase();
const on = (el.querySelector('.data-toggle-btn')?.dataset.feedState || 'off') !== 'off'; // On means the layer is enabled, whatever its feed is doing.
const on = Boolean(
el.querySelector('.data-toggle-btn')?.classList.contains('active'),
);
const match = const match =
(!q || name.includes(q)) && (!q || name.includes(q)) &&
(show.value === 'all' || (show.value === 'on') === on) && (show.value === 'all' || (show.value === 'on') === on) &&
@@ -72,92 +86,176 @@ function apply() {
el.classList.toggle('is-filtered', !match); el.classList.toggle('is-filtered', !match);
if (match) { if (match) {
headingHasRows = true; headingHasRows = true;
shown++; count++;
} }
} }
close(); close();
list.classList.toggle('is-filtering', Boolean(q) || show.value !== 'all' || Boolean(category.value)); list.classList.toggle('is-filtering', narrowed.value);
if (groups.join('|') !== categories.value.join('|')) categories.value = groups; const same =
list.dataset.shown = String(shown); groups.length === categories.value.length &&
groups.every(
(g, i) =>
g.key === categories.value[i].key && g.label === categories.value[i].label,
);
if (!same) categories.value = groups;
// A group that went away takes its filter with it.
if (category.value && !groups.some((g) => g.key === category.value))
category.value = '';
shown.value = count;
}
function clearAll() {
query.value = '';
show.value = 'all';
category.value = '';
searching.value = false;
filtering.value = false;
apply();
} }
let observer = null; let observer = null;
onMounted(() => { let frame = 0;
if (props.part !== 'bar') return; if (props.part === 'bar') {
const list = document.getElementById('data-toggles'); onMounted(() => {
if (!list) return; const list = document.getElementById('data-toggles');
observer = new MutationObserver(() => apply()); if (!list) return;
observer.observe(list, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-feed-state'] }); // Rows repaint many times a second; filter once per frame at most.
apply(); observer = new MutationObserver(() => {
}); if (frame) return;
onBeforeUnmount(() => observer?.disconnect()); frame = requestAnimationFrame(() => {
if (props.part === 'bar') watch([query, show, category], apply); frame = 0;
apply();
});
});
observer.observe(list, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['class', 'data-feed-state'],
});
apply();
document.addEventListener(LAYER_FINDER_CLEAR_EVENT, clearAll);
});
onBeforeUnmount(() => {
observer?.disconnect();
cancelAnimationFrame(frame);
document.removeEventListener(LAYER_FINDER_CLEAR_EVENT, clearAll);
});
watch([query, show, category], apply);
}
const searchBtn = () => document.getElementById('layer-finder-search-btn');
async function toggleSearch() { async function toggleSearch() {
searching.value = !searching.value; searching.value = !searching.value;
if (!searching.value) query.value = ''; if (!searching.value) query.value = '';
await nextTick(); await nextTick();
if (searching.value) document.getElementById('layer-finder-input')?.focus(); if (searching.value) document.getElementById('layer-finder-input')?.focus();
} }
function pick(id) { // Escape in the field closes the search and stays there: it must not also
show.value = id; // fold the panel or leave the cockpit.
async function closeSearch() {
searching.value = false;
query.value = '';
await nextTick();
searchBtn()?.focus();
} }
function pickCategory(name) {
category.value = category.value === name ? '' : name;
}
const active = () => show.value !== 'all' || Boolean(category.value);
// The menu stays open while choosing; a click outside it or Esc closes it. // The filter is a disclosure of two groups of toggle buttons. Opening it moves
// focus to the chosen option; Escape, or a press outside, closes it and gives
// focus back to its button.
const filterEl = ref(null); const filterEl = ref(null);
function onOutside(e) { const menuEl = ref(null);
if (filtering.value && filterEl.value && !filterEl.value.contains(e.target)) filtering.value = false; const triggerEl = ref(null);
const options = () => [...(menuEl.value?.querySelectorAll('button') || [])];
async function toggleFilter() {
filtering.value = !filtering.value;
if (!filtering.value) return;
await nextTick();
const menu = menuEl.value;
if (!menu) return;
// Keep the menu inside the panel, which clips what leaves it.
const panel = menu.closest('.ui-panel, [data-panel-id]');
const room = panel
? panel.getBoundingClientRect().bottom - menu.getBoundingClientRect().top - 8
: 0;
menu.style.maxHeight = room > 80 ? `${Math.floor(room)}px` : '';
(menu.querySelector('[aria-pressed="true"]') || options()[0])?.focus();
} }
function onEsc(e) { function closeFilter({ refocus = false } = {}) {
if (e.key === 'Escape') filtering.value = false; filtering.value = false;
if (refocus) triggerEl.value?.focus();
}
function onMenuKey(event) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeFilter({ refocus: true });
return;
}
const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
const edge = { Home: 0, End: -1 }[event.key];
if (step === undefined && edge === undefined) return;
event.preventDefault();
const all = options();
const at = all.indexOf(document.activeElement);
const next =
step !== undefined
? all[(at + step + all.length) % all.length]
: all.at(edge);
next?.focus();
}
function onOutside(e) {
if (filtering.value && filterEl.value && !filterEl.value.contains(e.target))
closeFilter();
}
function pickCategory(key) {
category.value = category.value === key ? '' : key;
} }
if (props.part === 'buttons') { if (props.part === 'buttons') {
onMounted(() => { onMounted(() => document.addEventListener('pointerdown', onOutside, true));
document.addEventListener('pointerdown', onOutside, true); onBeforeUnmount(() =>
document.addEventListener('keydown', onEsc); document.removeEventListener('pointerdown', onOutside, true),
}); );
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onOutside, true);
document.removeEventListener('keydown', onEsc);
});
} }
</script> </script>
<template> <template>
<template v-if="part === 'buttons'"> <template v-if="part === 'buttons'">
<button type="button" class="layer-finder__btn" :class="{ active: searching || query }" :aria-pressed="String(searching)" :aria-label="t('Search layers')" :title="t('Search layers')" @click="toggleSearch"> <button id="layer-finder-search-btn" type="button" class="layer-finder__btn" :class="{ active: searching || query }" :aria-pressed="String(searching)" :aria-label="t('Search layers')" :title="t('Search layers')" @click="toggleSearch">
<span class="material-symbols-outlined" aria-hidden="true">search</span> <span class="material-symbols-outlined" aria-hidden="true">search</span>
</button> </button>
<span ref="filterEl" class="layer-finder__filter"> <span ref="filterEl" class="layer-finder__filter">
<button type="button" class="layer-finder__btn" :class="{ active: show !== 'all' || category }" :aria-expanded="String(filtering)" :aria-label="t('Filter layers')" :title="t('Filter layers')" @click="filtering = !filtering"> <button ref="triggerEl" type="button" class="layer-finder__btn" :class="{ active: show !== 'all' || category }" :aria-expanded="String(filtering)" aria-controls="layer-finder-menu" :aria-label="t('Filter layers')" :title="t('Filter layers')" @click="toggleFilter">
<span class="material-symbols-outlined" aria-hidden="true">filter_list</span> <span class="material-symbols-outlined" aria-hidden="true">filter_list</span>
</button> </button>
<span v-if="filtering" class="layer-finder__menu" role="menu"> <span v-if="filtering" id="layer-finder-menu" ref="menuEl" class="layer-finder__menu" role="group" :aria-label="t('Filter layers')" @keydown="onMenuKey">
<span class="layer-finder__menu-head">{{ t('Status') }}</span> <span role="group" :aria-label="t('Status')">
<button v-for="s in SHOWS" :key="s.id" type="button" role="menuitemradio" :aria-checked="String(show === s.id)" :class="{ active: show === s.id }" @click="pick(s.id)"> <span class="layer-finder__menu-head" aria-hidden="true">{{ t('Status') }}</span>
<span class="material-symbols-outlined" aria-hidden="true">{{ show === s.id ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>{{ t(s.label) }} <button v-for="s in SHOWS" :key="s.id" type="button" :aria-pressed="String(show === s.id)" :class="{ active: show === s.id }" @click="show = s.id">
</button> <span class="material-symbols-outlined" aria-hidden="true">{{ show === s.id ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>{{ t(s.label) }}
<span v-if="categories.length" class="layer-finder__menu-head">{{ t('Category') }}</span> </button>
<button v-for="c in categories" :key="c" type="button" role="menuitemradio" :aria-checked="String(category === c)" :class="{ active: category === c }" @click="pickCategory(c)"> </span>
<span class="material-symbols-outlined" aria-hidden="true">{{ category === c ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>{{ c }} <span v-if="categories.length" role="group" :aria-label="t('Category')">
</button> <span class="layer-finder__menu-head" aria-hidden="true">{{ t('Category') }}</span>
<button v-for="c in categories" :key="c.key" type="button" :aria-pressed="String(category === c.key)" :class="{ active: category === c.key }" @click="pickCategory(c.key)">
<span class="material-symbols-outlined" aria-hidden="true">{{ category === c.key ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>{{ c.label }}
</button>
</span>
</span> </span>
</span> </span>
</template> </template>
<div v-else-if="searching || active()" class="layer-finder__bar"> <div v-else-if="searching || narrowed" class="layer-finder__bar">
<label v-if="searching" class="layer-finder__field"> <label v-if="searching" class="layer-finder__field">
<span class="material-symbols-outlined" aria-hidden="true">search</span> <span class="material-symbols-outlined" aria-hidden="true">search</span>
<input id="layer-finder-input" ref="input" v-model="query" type="search" :placeholder="t('Search layers…')" :aria-label="t('Search layers')" @keydown.esc="toggleSearch" /> <input id="layer-finder-input" v-model="query" type="search" :placeholder="t('Search layers…')" :aria-label="t('Search layers')" @keydown.esc.stop.prevent="closeSearch" />
</label> </label>
<button v-if="show !== 'all'" type="button" class="layer-finder__chip" @click="show = 'all'"> <button v-if="show !== 'all'" type="button" class="layer-finder__chip" @click="show = 'all'">
{{ show === 'on' ? t('On') : t('Off') }}<span class="material-symbols-outlined" aria-hidden="true">close</span> {{ show === 'on' ? t('On') : t('Off') }}<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button> </button>
<button v-if="category" type="button" class="layer-finder__chip" @click="category = ''"> <button v-if="category" type="button" class="layer-finder__chip" @click="category = ''">
{{ category }}<span class="material-symbols-outlined" aria-hidden="true">close</span> {{ categoryLabel }}<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button> </button>
<p v-if="narrowed && shown === 0" class="layer-finder__empty" role="status">{{ t('No layers match') }}</p>
</div> </div>
</template> </template>
+4
View File
@@ -3727,6 +3727,10 @@ function findDataLayerRow(layerId) {
} }
function focusDataLayerRow(row, 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.scrollIntoView({ block: 'center', behavior: 'smooth' });
row.classList.remove('gev-voice-focus'); row.classList.remove('gev-voice-focus');
void row.offsetWidth; void row.offsetWidth;