doxa-maps/library/components/search
doxa-maps-88655c70 53e2c6623e The last English in the shared header, panel and facet rail — and a 24px touch target
E RESIDUALS, from rega's in-gallery re-sweep. All three lived in SHARED
components, which is why a grep of my own plugin found nothing:

  SearchFilterHeader  its label map was a literal object of English defaults —
                      the fallbacks every host gets, since no host passes
                      `labels`. Filters, Selected, World and the chip remove
                      button now come from the catalogue; a host's own labels
                      still win, which is what the prop is for.
  ResearchMapFilterPanel  the two "Any" options and the "{n} active" pill.
  DashboardFacetRail  the Expand/Collapse aria template, which repeats per row:
                      one untranslated template is dozens of English strings in
                      a screen reader.
  dashboard.config.js the facet SECTION titles (People, Place, …). The earlier
                      pass converted `label:` and missed `title:`.

★ AND A LOOKUP BUG THAT LOOKED EXACTLY LIKE A MISSING TRANSLATION. 'Any' and
'0 active' stayed English after being catalogued, because the panel asks for
`controls.<key>` while I had authored them under `dashboard.<key>`. The
catalogue had the words; the component was looking one namespace over. Keys now
live where the panel reads, and its lookup falls back across both, so a string
authored in either place renders.

MEASURED after, ru, on the built tree: 0 English chrome strings on Simple
Research and 0 on Big Picture.
One probe correction worth keeping: a substring match on 'People'/'World'
flagged 'South Asian Peoples' and 'Arab World' inside aria labels whose frame
was already translated — data, not chrome. Whole-string matching separates them.

TOUCH TARGET (regions' phone sweep): .rd-proj "🌐 Globe" measured 74x23 at
390x844, one pixel under WCAG 2.5.8 AA. min-height:24px under
@media (pointer:coarse) — stating the 24 rather than growing padding, because
the height is padding plus a line box the renderer derives from the font and the
arithmetic lands at 23.7. Mouse sizing untouched. MEASURED after: 73.5x34.

Every bundle embedding the changed shared components is rebuilt in this commit —
a shared fix is not landed until its consumers are.
2026-09-25 09:15:30 +00:00
..
FilterDropdown.vue FilterDropdown: translate when the host has i18n, never require it 2026-09-25 03:19:23 +00:00
fuzzy.js Search tier 2: a misspelled country resolves to THE country (best match, 2-edit cap) 2026-09-24 00:34:19 +00:00
README.md Search is FUZZY again, Fuse-parity: typo tolerance restored on every surface 2026-09-23 23:08:48 +00:00
SearchBar.vue Restore cap-4 i18n context (configureApp) and re-theme shared search components to the DX palette 2026-09-20 05:15:02 +00:00
SearchFilterHeader.vue The last English in the shared header, panel and facet rail — and a 24px touch target 2026-09-25 09:15:30 +00:00

@map/components/search — shared search + filter primitives

ONE source of truth for the search-bar, the searchable filter-dropdown, and the fuzzy matcher. Consumed by BOTH plugins so there is no duplication:

  • cap-2 plugin-designs/people-groups-grid/ — its own header (default) drives the grid.
  • cap-1 plugin-designs/big-picture/ — the Unified Research Dashboard header (BpResearchHeader.vue) can adopt these to replace its hand-rolled <input> + native <select>s (card BUILD-G1-CAT4-BIG-PICTURE-DASHBOARD wires them in).

All three are props-driven, emit-based, framework-agnostic — no Nuxt context, no i18n composables, no plugin-specific hardcoding. Styles are inlined + scoped so each self-styles inside a standalone custom-element shadow root.

Import via the @map alias (resolves to library/):

import SearchBar          from '@map/components/search/SearchBar.vue'
import FilterDropdown     from '@map/components/search/FilterDropdown.vue'
import SearchFilterHeader from '@map/components/search/SearchFilterHeader.vue'
import Fuse               from '@map/components/search/fuzzy.js'
  • research-dashboard plugin-designs/research-dashboard/ — bakes SearchFilterHeader in as header-1 (search) + header-2 (dropdowns) via the thin adapter src/components/dashboards/DashboardResearchFilters.vue (couples it to the selection bus).

SearchBar.vue

A magnifier-icon + <input type="search">.

Prop Type Default Purpose
modelValue String '' Bound value; supports v-model.
placeholder String 'Search…' Input placeholder text.
ariaLabel String 'Search' Accessible label.
maxWidth String '' Optional CSS max-width (e.g. '480px'); empty = fill container.
Event Payload When
update:modelValue String On every input (enables v-model).
search { value } On every input; value is the raw string (read in-control, so shadow-DOM retargeting never loses it).
<SearchBar :model-value="term" placeholder="Search people groups…" max-width="480px" @search="onSearch" />
<!-- onSearch(payload) { term = payload.value } -->

FilterDropdown.vue

A searchable single-select with per-option counts (the four axes: Region / Block / People-Group / Religion in the grid; Region / Status in the dashboard).

Prop Type Required Default Purpose
label String yes — Control label, e.g. "Region".
name String yes — Axis/field key echoed back in events.
options Array no [] [{ value, label, count?, type? }].
value String no '' Currently-selected value (controlled).
placeholder String no 'Type to search...' In-menu search placeholder.
Event Payload When
filter-change { name, value, label, type } An option was picked.
filter-clear { name } Selection was cleared.
<FilterDropdown label="Region" name="wagf_region" :options="opts" :value="sel"
  @filter-change="onFilterChange" @filter-clear="onFilterClear" />

SearchFilterHeader.vue

The DX-life research-page filter header, cloned as ONE parameterized element: header-1 (a SearchBar) + header-2 (an "expand filters" row of FilterDropdowns + optional boolean toggle-pills + a removable active-chip strip). Pure UI/interaction — no bus, no fetch — so the consumer supplies option lists + selected values and reacts to the emits. The dropdown row is a SINGLE row that never wraps — it horizontal-scrolls on overflow (card BUILD-DROPDOWNS-HORIZONTAL-SCROLL-ONE-ROW).

Prop Type Default Purpose
search String '' Search-bar value (supports v-model:search).
filters Array [] Dropdown axes: [{ name, label, options, value?, placeholder? }].
toggles Array [] Boolean pills: [{ name, label, active? }].
activeChips Array | null null Removable chips [{ name, label }]; omit to auto-derive.
searchPlaceholder String 'Search…' header-1 placeholder.
searchMaxWidth String '520px' header-1 search box max-width.
showFilters Bool | String true Render the header-2 filter row at all.
startExpanded Boolean false Show the dropdown row without a first click.
collapsible Boolean true Show the Show/Hide-Filters toggle; false = always open.
labels Object {} i18n overrides (show/hide_filters, clear_all, type_to_search…).
Event Payload When
update:search String Search changed (v-model).
search { value } Every keystroke.
filter-change { name, value, label, type } A dropdown option was picked.
filter-clear { name } A dropdown selection cleared.
toggle { name, active } A toggle-pill flipped.
remove-filter { name } An active chip's ✕ clicked.
clear-all — "Clear all" clicked.
<SearchFilterHeader
  v-model:search="term" :filters="filters" :active-chips="chips" start-expanded
  @search="onSearch" @filter-change="onPick" @filter-clear="onClear"
  @remove-filter="onRemove" @clear-all="onClearAll" />

fuzzy.js

A zero-dependency Fuse.js-shaped matcher (so no npm dep is added to the shared package.json/bun.lock).

const fuse = new Fuse(list, {
  keys: ['name', 'country_code.label', 'religion.label'], // dotted paths
  threshold: 0,          // 0 = exact substring only; >0 = fuzzy (k-errors edit distance, Fuse-parity)
  minMatchCharLength: 3, // shortest run counted as a match
})
fuse.search(term) // -> [{ item, matches: [{ key, indices: [[start,end], …] }] }]
  • Empty term → every item with matches: [].
  • matches[].indices drive <span class="search-highlight"> wrapping (same shape as Fuse's includeMatches).

Verification (headless)

  • fuzzy.js: exact + fuzzy + empty-term contracts asserted via a node harness.
  • SearchBar / FilterDropdown: SSR-mounted (@vue/server-renderer) — render icon + input, trigger + label, and reflect a selected value.
  • bun run build builds the people-groups-grid bundle green consuming these shared imports (all guards pass).