Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
143a9a3
Preliminary search control UI
prushforth May 8, 2026
66ade87
Make icons a bit bolder to match the search icon which is paths of a …
prushforth May 8, 2026
9f635a0
Add disabled support to search control. Bound to checked, but not bo…
prushforth May 8, 2026
808b30d
Update linux screenshots due to bolding of zoom, reload controls to m…
prushforth May 9, 2026
8f9a92a
Make calculation of total size of controls omit the search control mo…
prushforth May 9, 2026
79945fb
Add waitUntil: 'networkidle', increase some timeouts to de-flake some…
prushforth May 9, 2026
523feaa
Prettier formatting of tests changed in previous commit
prushforth May 9, 2026
d07ea72
Implement Phase 2 of search implementation plan
prushforth May 9, 2026
56a0f14
Implement custom search handler. Use geoname search as example.
prushforth May 9, 2026
6ef4ccb
Add searcj example using geonames.org
prushforth May 9, 2026
22c183b
Search panel grabs focus on mouseenter
prushforth May 9, 2026
c79a91a
Update search, suggestions map-link skill
prushforth May 9, 2026
215cf64
Add localization messages for search button.
prushforth May 11, 2026
c6ce6d0
Add second search layer to index.html mapml-viewer (multiple search l…
prushforth May 12, 2026
486023e
Update node to v22 LTS
prushforth May 12, 2026
7eda5ed
Add playwright browser caching
prushforth May 12, 2026
fd797a4
Prevent npm ci from installing browsers, as that is done by the playw…
prushforth May 12, 2026
f81e4c2
Ignore postinstall scripts
prushforth May 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/prompts/plan-searchButtonDisabledStateTests.prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## Plan: Search Button Disabled State Tests

Create a test fixture and test file in `test/e2e/core/` to verify the search button's disabled state responds correctly to `<map-link rel="search">` presence/absence in both local and remote layers, including dynamic mutations.

**Steps**

### Phase A — Test Data (parallel)
1. Create `test/e2e/data/search-layer.mapml` — minimal remote .mapml with `<map-link rel="search" tref="...">` inside a `<map-extent>`. Based on the `dummy-cbmtile-cbmt.mapml` pattern.
2. Create `test/e2e/data/no-search-layer.mapml` — same structure, no search link.

### Phase B — Test Fixture
3. Create `test/e2e/core/searchDisabled.html` with `controlslist="search"`, a local (inline) `<map-layer>` with no search link, and a remote `<map-layer src="no-search-layer.mapml">` checked.

### Phase C — Tests
4. Create `test/e2e/core/searchDisabled.test.js`:

**Initial state:**
- Button has `aria-disabled="true"` when no layer has `map-link[rel=search]`

**Local layer — dynamic add/remove:**
- Enabled (`aria-disabled="false"`) after appending `<map-link rel="search">` to inline layer
- Disabled again after removing it

**Layer checked/unchecked:**
- Disabled when the layer with search link is unchecked
- Re-enabled when re-checked

**Remote (src) layer:**
- Enabled when remote layer's `src` changes to `search-layer.mapml` (wait for `loadedmetadata`)
- Disabled when `src` changes back to `no-search-layer.mapml`

**Multiple layers:**
- Stays enabled if one of two layers has a search link
- Disabled only when all search-capable layers are unchecked

**Relevant files**
- `src/mapml/control/SearchButton.js` — `_hasSearchLayers()`, `_updateDisabled()` under test
- `test/e2e/data/dummy-cbmtile-cbmt.mapml` — template for new .mapml files
- `test/e2e/core/mapElement.html` — fixture pattern reference
- `test/server.js` — already serves `test/e2e/core/` and `test/e2e/data/` statically

**Verification**
1. `npx playwright test test/e2e/core/searchDisabled.test.js --reporter=list` — all tests pass
2. `grunt` — build succeeds

**Decisions**
- Tests in `test/e2e/core/` — this is core control behavior
- Remote layer test changes `src` dynamically, waits for `loadedmetadata`
- `hidden` attribute does NOT affect disabled state (per earlier decision)
89 changes: 89 additions & 0 deletions .github/prompts/plan-searchPhase2-defaultHandler.prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@

---

## Plan: Search Phase 2 — Default Handler, Fetch, Events & Formats

Wire `<map-link rel="search">` and `<map-link rel="suggestions">` to the search control. Implement debounced fetch for suggestions on input and search on Enter/click. Dispatch cancelable events (`mapsearch`, `mapsuggestions`) on the viewer element so authors can `preventDefault()` and supply custom rendering. Provide default handlers that render GeoJSON FeatureCollection responses (the format returned by Nominatim `format=geojson` and Photon).

**Steps**

### Phase A — map-link.js: accept `search` and `suggestions` rel values

1. In map-link.js, add `'search'` and `'suggestions'` to the array in the `set rel()` setter.
2. In `connectedCallback()`, add `case 'search':` and `case 'suggestions':` — no-op (the link is discoverable via DOM query from `SearchButton`).
3. In `whenReady()`, add `case 'search':` and `case 'suggestions':` that resolve immediately.

### Phase B — SearchButton.js: fetch logic, events, default handlers

4. Add debounced `input` event handler on `this._input` — 300ms debounce, minimum 2 chars, `AbortController` for stale requests → calls `_fetchSuggestions(query)`.

5. Implement `_getSearchLinks()` and `_getSuggestionsLinks()` — query checked layers, look in `layer.shadowRoot` (remote) or `layer` (local) for `map-link[rel=search]` / `map-link[rel=suggestions]`. Return `[{ link, layer }]`. First per layer wins.

6. Implement `_fetchSuggestions(query)`:
- Resolve each link's `tref` replacing `{searchTerms}` with `encodeURIComponent(query)`.
- Fetch all in parallel via `Promise.allSettled()`.
- Dispatch cancelable `mapsuggestions` CustomEvent on `this._mapEl` with `{ detail: { query, responses } }`.
- If not prevented → `_defaultSuggestionsHandler(detail)`.

7. Implement `_defaultSuggestionsHandler()`:
- Render each GeoJSON feature as a `<button class="mapml-search-result">` in `this._results`.
- Display text: `properties.display_name || properties.name`.
- Click → `_selectResult(feature, layer)`.

8. Implement search on Enter → `_doSearch(query)`:
- Same pattern as suggestions: resolve tref, fetch, dispatch cancelable `mapsearch` event, default handler renders results.

9. Implement `_selectResult(feature, layer)`:
- If `feature.bbox` → `map.fitBounds()`.
- Else → `map.setView([lat, lon], 14)`.
- Close panel.

### Phase C — CSS for results

10. Add styles: `.mapml-search-results` scrollable, `.mapml-search-result` full-width button with hover, ellipsis overflow.

### Phase D — Test data and mock routes

11. Add mock routes in server.js:
- `GET /search/suggestions?q=...` → static GeoJSON FeatureCollection (2-3 features).
- `GET /search/results?q=...` → static GeoJSON FeatureCollection (1 feature with bbox).

12. Create `test/e2e/data/search-with-tref.mapml` — remote layer with both `<map-link rel="suggestions" tref="...">` and `<map-link rel="search" tref="...">` in `<map-head>`.

### Phase E — Tests

13. Create `test/e2e/core/searchDefault.html` + `searchDefault.test.js`:
- Suggestions appear after typing
- Search results appear on Enter
- Click result → map moves / fits bounds
- `mapsuggestions` / `mapsearch` events fire with correct detail
- `preventDefault()` suppresses default rendering
- No fetch when button is disabled

**Relevant files**
- SearchButton.js — main target: fetch, events, default handlers
- map-link.js — `connectedCallback()`, `set rel()`, `whenReady()`
- mapml.css — result item styles
- server.js — mock routes
- search-layer.mapml — existing structure reference

**Verification**
1. `npx playwright test test/e2e/core/searchDefault.test.js --reporter=list`
2. `npx playwright test searchDisabled.test.js --reporter=list` — no regressions
3. `npx playwright test domApi-mapml-viewer.test.js --reporter=list` — no regressions
4. `grunt` — build succeeds

**Decisions**
- Default format: **GeoJSON FeatureCollection** — works with Nominatim (`format=geojson`) and Photon out of the box
- `{searchTerms}` is the only template variable — simple string replace, no `<map-input>` siblings needed
- `<map-link rel="search|suggestions">` goes in `<map-head>` (remote) or direct child of `<map-layer>` (local) — NOT inside `<map-extent>`
- Events are cancelable and bubble; `preventDefault()` suppresses default handler
- One search + one suggestions link per layer (first wins); multiple layers' results are merged
- Suggestions are optional — if no `rel="suggestions"` link exists, only Enter triggers search

**Further Considerations**
1. **Nominatim usage policy** — test fixtures use local mock routes, not live API. Authors using Nominatim are responsible for compliance. Should be documented.
2. **No suggestions link fallback** — typing waits for Enter. Recommendation: this is correct behavior.
3. **Search vs. suggestions visual distinction** — same treatment in Phase 2; can differentiate in Phase 3.

---
204 changes: 204 additions & 0 deletions .github/skills/map-link-markup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ defines several uses of existing and new `rel` keyword values.
| `zoomout` | The link `href` is followed automatically by the polyfill when the map is zoomed out by the user to a value less than the minimum value of the zoom range of the current layer. The referenced map layer resource replaces the current map layer. The polyfill does not represent this link as a user-visible affordance, it is followed automatically. If the remote resource does not contain a reciprocal `zoomin` link, the map state change is one-way i.e. the layer is permanently replaced. |
| `legend` | The `legend` link relation designates a link to metadata, typically an image, describing the symbology used by the current layer. Currently, the polyfill creates a hyperlink for the label of the layer in the layer control, which opens in a new browsing context. |
| `query` | The `query` link relation is used in combination with the `tref="..."` attribute to establish a URL template that composes a map query URL based on user map gestures such as click or touch. These URLs are fetched and the response presented on top of the map as a popup. Such queries can return text/html or text/mapml responses. In the latter case, the response may contain more than one feature, in which case a 'paged' popup is generated, allowing the user to cycle through the features' individual metadata. |
| `search` | The `search` link relation is used with the `tref="..."` attribute to define a URL template for a search endpoint. The template must contain the `{searchTerms}` variable reference, which is replaced with the user's URL-encoded search query. The search is triggered when the user presses Enter or clicks a suggestion. The response is expected to be a GeoJSON `FeatureCollection` (the default handler format). Only the first `<map-link rel="search">` per `<map-layer>` is honored. The link must be a direct child of `<map-layer>` (for local/inline layers) or placed inside `<map-head>` (for remote `.mapml` layers). The search control is opt-in via `controlslist="search"` on the `<mapml-viewer>` or `<map is="web-map">` element. The search button is disabled when no visible (checked) layer provides a `<map-link rel="search">`. |
| `suggestions` | The `suggestions` link relation is used with the `tref="..."` attribute to define a URL template for a suggestions/autocomplete endpoint. Like `search`, the template must contain the `{searchTerms}` variable reference. Suggestions are fetched automatically as the user types (debounced, minimum 2 characters). The default handler expects a GeoJSON `FeatureCollection` response, rendering each feature as a clickable result button (using `properties.display_name` or `properties.name`). Only the first `<map-link rel="suggestions">` per `<map-layer>` is honored. Suggestions are optional — if no `rel="suggestions"` link exists, only Enter triggers a search. |
| `stylesheet` | The link imports a CSS stylesheet from the `href` value. |


Expand Down Expand Up @@ -135,4 +137,206 @@ Projection values [defined by the polyfill](../mapml-viewer#projection) include:
</map-extent>
</layer->
</mapml-viewer>
```

### Search and Suggestions

The search control is opt-in: add `controlslist="search"` to your `<mapml-viewer>`
or `<map is="web-map">` element. A magnifying-glass button appears in the
top-left controls. The button is disabled (grayed out, `aria-disabled="true"`)
when no visible (checked) `<map-layer>` has a descendant `<map-link rel="search">`.

The `{searchTerms}` template variable in `tref` is the only required variable.
It is replaced with the user's URL-encoded query string. No sibling
`<map-input>` elements are needed for search/suggestions links.

#### Placement rules

- **Inline (local) layers:** place `<map-link rel="search">` and
`<map-link rel="suggestions">` as direct children of `<map-layer>`.
- **Remote layers (`.mapml` files):** place them inside `<map-head>`.
- Do **not** place search/suggestions links inside `<map-extent>`.
- Only the **first** `<map-link rel="search">` and first
`<map-link rel="suggestions">` per `<map-layer>` are honored.
- Multiple layers may each contribute their own search/suggestions links;
responses are merged.

#### Default handler format (GeoJSON)

The default handler expects a **GeoJSON `FeatureCollection`** response from
both search and suggestions endpoints. Each `Feature` should include:

- `geometry` with coordinates (used for `setView` fallback)
- `bbox` (4-element array `[west, south, east, north]`; used for `fitBounds`)
- `properties.display_name` or `properties.name` (rendered as button text)

This format is compatible with Nominatim (`format=geojson`) and Photon out of
the box. Example minimal response:

```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"bbox": [-75.76, 45.35, -75.62, 45.46],
"geometry": { "type": "Point", "coordinates": [-75.69, 45.42] },
"properties": { "display_name": "Ottawa, Ontario, Canada" }
}
]
}
```

#### Default handler — inline layer example

```html
<mapml-viewer projection="OSMTILE" zoom="14" lat="45.4" lon="-75.7"
controls controlslist="search">
<map-layer label="OpenStreetMap" checked>
<!-- suggestions fetched as the user types (debounced, min 2 chars) -->
<map-link rel="suggestions"
tref="https://nominatim.openstreetmap.org/search?q={searchTerms}&format=geojson&limit=5"></map-link>
<!-- search fetched on Enter -->
<map-link rel="search"
tref="https://nominatim.openstreetmap.org/search?q={searchTerms}&format=geojson&limit=10"></map-link>
<map-extent units="OSMTILE" checked>
<map-input name="z" type="zoom" min="0" max="18"></map-input>
<map-input name="x" type="location" units="tilematrix" axis="column"></map-input>
<map-input name="y" type="location" units="tilematrix" axis="row"></map-input>
<map-link rel="tile" tref="https://tile.openstreetmap.org/{z}/{x}/{y}.png"></map-link>
</map-extent>
</map-layer>
</mapml-viewer>
```

#### Default handler — remote layer example

In the HTML page:

```html
<mapml-viewer projection="CBMTILE" zoom="5" lat="45.4" lon="-75.7"
controls controlslist="search">
<map-layer label="Canada Base Map" src="canada.mapml" checked></map-layer>
</mapml-viewer>
```

In `canada.mapml`:

```xml
<mapml->
<map-head>
<map-meta charset="utf-8"></map-meta>
<map-link rel="suggestions" tref="https://geogratis.gc.ca/services/geoname/en/geonames.json?q={searchTerms}*&num=20"></map-link>
<map-link rel="search" tref="https://geogratis.gc.ca/services/geoname/en/geonames.json?q={searchTerms}&num=20"></map-link>
</map-head>
<map-body>
<map-extent units="CBMTILE" checked>
<map-input name="z" type="zoom" min="0" max="17"></map-input>
<map-input name="y" type="location" units="tilematrix" axis="row"></map-input>
<map-input name="x" type="location" units="tilematrix" axis="column"></map-input>
<map-link rel="tile" tref="https://example.com/tiles/{z}/{y}/{x}.png"></map-link>
</map-extent>
</map-body>
</mapml->
```

#### Custom handler — overriding the default with `preventDefault()`

When the server's response format does not match GeoJSON `FeatureCollection`,
use `preventDefault()` on the `mapsuggestions` and/or `mapsearch` events to
suppress the default handler and render results yourself.

The polyfill dispatches two cancelable `CustomEvent`s on the `<mapml-viewer>`
(or `<map is="web-map">`) element:

| Event | Fires when | `e.detail` properties |
|-------------------|-------------------------------------------------|-----------------------------------|
| `mapsuggestions` | Suggestion responses arrive (user is typing) | `query`, `responses` |
| `mapsearch` | Search responses arrive (user pressed Enter) | `query`, `responses` |

`e.detail.responses` is an array of `{ data, link, layer }` objects — one per
layer that contributed a link. `data` is the parsed JSON response body; `link`
is the `<map-link>` element; `layer` is the `<map-layer>` element.

To render results, query the search panel's results container from the map and
append your own HTML. Results should use the class `mapml-search-result` on
`<button>` elements for consistent styling.

Example using the geonames.gc.ca API (non-GeoJSON response shape):

```html
<mapml-viewer projection="CBMTILE" zoom="5" lat="45.4" lon="-75.7"
controls controlslist="search">
<map-layer label="Geonames Layer" src="geonames-layer.mapml" checked></map-layer>
</mapml-viewer>

<script>
const viewer = document.querySelector('mapml-viewer');

viewer.addEventListener('mapsuggestions', (e) => {
e.preventDefault();
// Access the results container via the map's internal DOM
const container = viewer._map._container.querySelector('.mapml-search-results');
container.innerHTML = '';
for (const { data } of e.detail.responses) {
if (!data || !data.items) continue;
for (const item of data.items) {
const btn = document.createElement('button');
btn.className = 'mapml-search-result';
btn.setAttribute('type', 'button');
btn.textContent = item.name;
btn.addEventListener('click', () => {
if (item.bbox && item.bbox.length === 4) {
const [west, south, east, north] = item.bbox;
viewer._map.fitBounds([[south, west], [north, east]]);
} else {
viewer._map.setView([item.latitude, item.longitude], 10);
}
});
container.appendChild(btn);
}
}
});

viewer.addEventListener('mapsearch', (e) => {
e.preventDefault();
// Same pattern — parse the non-standard response and render results
const container = viewer._map._container.querySelector('.mapml-search-results');
container.innerHTML = '';
for (const { data } of e.detail.responses) {
if (!data || !data.items) continue;
for (const item of data.items) {
const btn = document.createElement('button');
btn.className = 'mapml-search-result';
btn.setAttribute('type', 'button');
btn.textContent = item.name;
btn.addEventListener('click', () => {
if (item.bbox && item.bbox.length === 4) {
const [west, south, east, north] = item.bbox;
viewer._map.fitBounds([[south, west], [north, east]]);
} else {
viewer._map.setView([item.latitude, item.longitude], 10);
}
});
container.appendChild(btn);
}
}
});
</script>
```

#### Search without suggestions

Suggestions are optional. If only `<map-link rel="search">` is provided
(no `rel="suggestions"`), the control will not fetch anything as the user
types — only pressing Enter will trigger a search request.

```html
<map-layer label="Search Only" checked>
<map-link rel="search"
tref="https://nominatim.openstreetmap.org/search?q={searchTerms}&format=geojson&limit=10"></map-link>
<map-extent units="OSMTILE" checked>
<!-- ... map-inputs and tile link ... -->
</map-extent>
</map-layer>
```
22 changes: 14 additions & 8 deletions .github/workflows/ci-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: latest
- run: sudo apt-get install xvfb
- run: npm install
- run: npx playwright install --with-deps
- run: npm install -g grunt-cli
- run: grunt default
node-version: 22
cache: npm
- run: npm ci --ignore-scripts
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: npx playwright install-deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
- run: npx grunt default
- run: xvfb-run --auto-servernum -- npx playwright test --grep-invert="popupTabNavigation\.test\.js|layerContextMenuKeyboard\.test\.js" --workers=1 --retries=3
# - run: xvfb-run --auto-servernum -- npx playwright test --grep="popupTabNavigation\.test\.js|layerContextMenuKeyboard\.test\.js" --workers=1 --retries=3
# - run: xvfb-run --auto-servernum -- npm run jest
env:
CI: true
Loading
Loading