Events
<mapvx-website> reports what the visitor does through two DOM events dispatched on the element itself: userAction, which covers every interaction the component reports, and cardSelected, a legacy event for card clicks. Use userAction in new integrations.
<mapvx-website api-key="YOUR_API_KEY" institution-id="YOUR_INSTITUTION_ID"></mapvx-website>
<script>
const el = document.querySelector('mapvx-website')
el.addEventListener('userAction', (event) => {
const { type, data } = event.detail
console.log(type, data)
})
</script>The userAction event
Every reportable interaction is dispatched as a userAction event. Its detail is a UserAction object, a union discriminated by type:
type FilterSelection = { filter: string; filters: string[] }
type UserAction =
| { type: 'select-filter'; data: FilterSelection }
| { type: 'show-place'; data: { placeId: string; alias: string | undefined } }
| { type: 'select-destination'; data: { destinationId: string; alias: string | undefined } }
| { type: 'search'; data: { searchTerm: string } }
| { type: 'return-to-home'; data: FilterSelection }The package exports this type, plus one interface per member (SelectFilterAction, ShowPlaceAction, SelectDestinationAction, SearchAction, ReturnToHomeAction). They exist only as types, so import them with import type (see Types only). The declarations are listed in TypeScript types.
import type { UserAction } from '@mapvx/web-components-ssr'placeId and destinationId are the place’s MapVX id (mapvxId). alias is the place’s client id (clientId): its alias in MapVX data, or its external id when it has no alias, and undefined when it has neither. The tenant and destination deep-link parameters accept either value.
Action types
type | data | Emitted when |
|---|---|---|
select-filter | { filter, filters } | The visitor turns a category on or off. |
show-place | { placeId, alias } | A place profile is displayed, however it was opened. |
select-destination | { destinationId, alias } | The visitor clicks Pedestrian-friendly directions on a profile. |
search | { searchTerm } | A search runs with a new term. |
return-to-home | { filter, filters } | The component goes back to its home screen from a profile or from search. |
filters is the whole category selection, which can hold several names or none. filter is the first of them, or 'All' when none are on; it predates multi-select and is kept so links that write it into ?tab= keep working. Prefer filters in new code.
categoriesChange
A separate event from userAction, dispatched whenever the set of categories the filter offers changes. Its detail is a CategoryFiltersData[]:
interface CategoryFiltersData {
name: string
placeData: string[] // mapvxId of every place in the category
}Use it to draw your own category control. The list is the resolved one, not the institution’s raw declaration: categories whose places this API key never returns are dropped, and the All entry is added. Reproducing those rules yourself would drift from them.
const map = document.querySelector('mapvx-website')
map.addEventListener('categoriesChange', (event) => {
renderCheckboxes(event.detail) // [{ name, placeData }, …]
})
map.addEventListener('userAction', (event) => {
if (event.detail.type === 'select-filter') {
syncCheckboxes(event.detail.data.filters)
}
})
// Write the visitor's choice back in.
onCheckboxToggle((names) => {
map.setAttribute('selected-categories', names.join(','))
})Pair it with selected-categories to drive the filter, and show-category-filters="false" to hide the built-in row.
These five types are the complete list. There are no events for choosing a city, switching between Grid View and Map View, changing page, picking a route origin or destination on the route screen, starting or stepping through a route, clicking Driving directions or a website link, or using the map controls. There is also no event for loading, readiness or errors.
select-filter
- Emitted when the visitor turns a category on or off, including turning the last one off.
filtersis the whole selection, in the order the categories were turned on. It is empty when none are on, which means no category filter.filteris the first selected name, orAllwhen none are on. It predates multi-select and is kept so hosts writing it into?tab=keep working.- Not emitted when the selection is applied automatically, by the
tabquery parameter or by theselected-categoriesattribute.
show-place
Emitted every time the profile screen resolves the place to display:
- a card click (grid, map-view list or search results);
- a map marker click, in Map View (home screen or search results) or on a profile’s map;
- a
tenantdeep link; - the Back button of the route screen, which returns to the profile of the place the route screen was opened for.
It can fire more than once for the same place, because the profile resolves the place again whenever the component’s place list changes, for example while data is still loading. If you count views, treat consecutive show-place events with the same placeId as one. When the place is not in the loaded list, the component requests it from the API first, so the event can arrive after a network round trip.
Opening a profile also smoothly scrolls the host page to the top of the first <mapvx-website> in the document (see The page scrolls when a place opens).
select-destination
Emitted when the visitor clicks Pedestrian-friendly directions on a profile, right before the route screen opens. destinationId and alias identify the profile’s place. The Driving directions link (it opens Google Maps in a new tab) and a destination deep link emit nothing.
search
Emitted by the search results view once the visitor has stopped typing for 500 ms, with the trimmed term, and only when that term is not empty and differs from the last term that view reported. It is not emitted on every keystroke. It also fires:
- for a
searchdeep link; - when the visitor comes back from a profile that was opened from search results: after the profile’s
return-to-home, the component shows those results again and emits the remembered term again.
The clear (×) button of the search box emits return-to-home, not search. Deleting the text by hand emits nothing, and the search results stay open.
return-to-home
Emitted right before the component navigates to its home screen, from:
- the Back button of a place profile;
- the Back button of the search results view;
- the clear (×) button of the search box;
- any
popstateevent onwindowwhile a profile is open (see Browser Back).
filters is the category selection at that moment, and filter its first name or All when nothing is on. They still report a selection when the category row is hidden, for example with show-category-filters="false", even though the list is then not filtered by category. The route screen’s Back button does not emit it: it returns to the profile, which emits show-place.
citiesChange
Dispatched whenever the set of cities the view can be scoped to changes. Its detail is a CityChoice[]:
interface CityChoice {
name: string // the city, labelled as the built-in picker labels it
id: string // write this into `institution-id` to scope the view to it
}Use it to draw a city control of your own. Hiding the built-in picker with show-city-filter="false" takes away the control but keeps the scope, so this is what is left to move it:
const map = document.querySelector('mapvx-website')
map.addEventListener('citiesChange', (event) => {
renderCityOptions(event.detail) // [{ name, id }, …]
})
// picking one
map.setAttribute('institution-id', id)- The cities are the grouped ones the picker shows, not the raw options: an institution that sits inside a city is one entry of that city rather than a sibling of it.
- A single entry means there is nothing to pick. The built-in picker hides itself in that case, and a control of your own can disable itself.
idis the option’sfilterId, which identifies the city itself.institution-idaccepts an institution’s own id too, so either resolves, but this one is unambiguous.
droppedPinChange
Dispatched whenever the visitor drops the origin pin or drags it somewhere else. Its detail is a LatLng:
interface LatLng {
lat: number
lng: number
}The pin is the one thing on the map the visitor moves and the host cannot see, so this is the only way to follow it. Writing the coordinate straight back into center keeps the map on the pin:
const map = document.querySelector('mapvx-website')
map.addEventListener('droppedPinChange', (event) => {
const { lat, lng } = event.detail
map.setAttribute('center', `${lat},${lng}`)
})- It fires on the first drop, on every later tap that moves the pin while the mode is armed, and on every drag, since all three leave the pin somewhere new.
- It always carries a coordinate. Nothing takes the pin off the map once it is down, so there is no emptied state to guard against.
- The pin also survives navigation, so the last coordinate reported stays the right one until the visitor moves it again.
zoomChange
Dispatched whenever the map settles on a zoom, whoever asked for it. Its detail is the zoom level, a number:
const map = document.querySelector('mapvx-website')
map.addEventListener('zoomChange', (event) => {
zoomReadout.textContent = event.detail.toFixed(2)
})It is the reporting half of zoom, and the only half the map can offer: it announces its zoom but never its centre, so a host can follow one and not the other.
- It covers the zooms nothing asked for: a pinch, a double tap, the built-in zoom buttons, and the fit the component performs on the places it is listing.
- Only the end of a gesture is reported. The frames in between are the animation rather than zooms the camera stopped at.
- Writing what it carries straight back into
zoomis safe. A zoom the map is already on is not treated as a move, so the attribute and the map cannot chase each other.
The cardSelected event (legacy)
cardSelected is the component’s older event. Its detail is a string: the MapVX id (mapvxId) of the clicked place.
el.addEventListener('cardSelected', (event) => {
console.log('Card clicked:', event.detail)
})It is emitted only when a place card is clicked (grid, map-view list or search results), before the profile opens. Version 1.6.1 still emits it, but new code should use userAction, which covers every way a place can be opened. show-place is not a drop-in replacement:
| Trigger | cardSelected | userAction with show-place |
|---|---|---|
| Card click | Yes | Yes, once the profile has resolved the place |
| Map marker click | No | Yes |
tenant deep link | No | Yes |
| Back from the route screen | No | Yes |
| Repeats for the same opening | No, one event per click | Can repeat while place data changes |
| Payload | mapvxId string | { placeId, alias } |
A card click emits both events: cardSelected first, then show-place once the profile has resolved the place. If you listen to both, do not count the click twice.
Listening to events
Both events are standard DOM CustomEvent objects:
| Property | Value |
|---|---|
| Names | userAction and cardSelected, case-sensitive |
| Target | The <mapvx-website> element |
| Payload | event.detail |
bubbles | false |
composed | false |
cancelable | false |
In practice:
- Use the exact names.
useraction,user-actionandUserActionnever fire. - Listen on the element. The events do not bubble, so a delegated listener on
documentor on a parent element is not called (except in the capture phase). - Events are notifications.
preventDefault()has no effect; you cannot stop the component from navigating. - Handlers run synchronously, while the interaction is being handled. For
return-to-home,select-destinationandcardSelectedthat is before the component navigates, which is what makes URL sync work.
const el = document.querySelector('mapvx-website')
el.addEventListener('userAction', (event) => {
const action = event.detail
switch (action.type) {
case 'select-filter':
case 'return-to-home':
console.log('Category:', action.data.filter)
break
case 'show-place':
console.log('Place:', action.data.placeId, action.data.alias)
break
case 'select-destination':
console.log('Directions to:', action.data.destinationId, action.data.alias)
break
case 'search':
console.log('Search:', action.data.searchTerm)
break
}
})In TypeScript, cast the event with (event as CustomEvent<UserAction>).detail. The package ships no typed event map for the element; see Element typing.
When to attach the listener
- You can call
addEventListenerbefore the bundle has loaded or before the element is defined: it is a plain DOM listener on the same node. There is no need to pollcustomElements.get()or retry withsetTimeout. If you need to wait for the element for other reasons, usecustomElements.whenDefined('mapvx-website')(see Waiting for the element). - Attach it as early as possible. Events are not queued or replayed, so a listener added later misses what already fired, such as the
show-placeof atenantdeep link or thesearchof asearchdeep link. - Events are dispatched only while the element is in the document. If you remove the element and create a new one, attach the listener to the new element.
Framework recipes
Load the bundle as described in Installation first. Then:
React
A ref plus addEventListener works in React 18 and 19. Remove the listener in the effect cleanup.
import { useEffect, useRef } from 'react'
import type { UserAction } from '@mapvx/web-components-ssr'
export function MapvxWebsite({ onAction }: { onAction: (action: UserAction) => void }) {
const ref = useRef<HTMLElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const handler = (event: Event) => onAction((event as CustomEvent<UserAction>).detail)
el.addEventListener('userAction', handler)
return () => el.removeEventListener('userAction', handler)
}, [onAction])
return <mapvx-website ref={ref} api-key="YOUR_API_KEY" institution-id="YOUR_INSTITUTION_ID" />
}TSX needs a JSX declaration for mapvx-website; see Element typing. In the Next.js App Router, put 'use client' at the top of this file, because it uses useEffect. React 19 also accepts event props that keep the event name’s case after on: onuserAction and oncardSelected. onUserAction does not fire, and React 18 does not attach event props on custom elements, which is why the ref version is shown here.
In any other framework, get a reference to the element and call addEventListener with the exact event name, as in Listening to events.
Keeping the host URL in sync
The component keeps its own navigation in memory and never writes to the host page’s URL or history. If you want shareable URLs, state that survives a reload, and a browser Back button that closes a profile, update the URL yourself from userAction events, using the parameter names the component reads on load (see Deep links).
const MAPVX_PARAMS = ['search', 'tenant', 'destination', 'tab']
function writeMapvxParams(params, mode) {
const url = new URL(location.href)
for (const name of MAPVX_PARAMS) url.searchParams.delete(name)
for (const [name, value] of Object.entries(params)) {
if (value) url.searchParams.set(name, value)
}
if (url.href === location.href) return
if (mode === 'push') history.pushState(null, '', url)
else history.replaceState(history.state, '', url)
}
document.querySelector('mapvx-website').addEventListener('userAction', (event) => {
const { type, data } = event.detail
switch (type) {
case 'select-filter':
case 'return-to-home':
// Drops tenant, destination and search so the home screen does not reopen them
writeMapvxParams({ tab: data.filter }, 'replace')
break
case 'search':
writeMapvxParams({ search: data.searchTerm }, 'replace')
break
case 'show-place': {
const params = new URLSearchParams(location.search)
const current = params.get('tenant') ?? params.get('destination')
const samePlace = current === data.placeId || (data.alias && current === data.alias)
// New history entry only when a different place opens
writeMapvxParams({ tenant: data.alias || data.placeId }, samePlace ? 'replace' : 'push')
break
}
case 'select-destination':
writeMapvxParams({ destination: data.alias || data.destinationId }, 'replace')
break
}
})Why it is written this way:
- Update the URL synchronously in the listener. The component emits
return-to-homebefore it navigates, and the home screen reads the query string when it is created. An update deferred to later (for example through an asynchronous router call) may arrive after the old parameters have already been read. - Remove
tenant,destinationandsearchonreturn-to-home. They are read again every time the home screen is created, so a leftovertenantsends the visitor straight back to that profile. - Replace on
return-to-home. It is also emitted when the visitor presses the browser Back button on a profile; pushing at that moment would add an entry and discard the forward history. - Push only when a different place opens.
show-placerepeats for the same place, and it also fires for a deep link (whose id may be themapvxIdwhile the event carries the alias) and on the way back from the route screen. Replacing in those cases avoids duplicate entries. - Replace for
searchandselect-filter.searchfires for each new term, so pushing would create one entry per term. Keeptabcurrent, because the component applies it again whenever the category list changes. - Keep the rest of the URL. Building the URL with
URLandURLSearchParamspreserves your own query parameters and hash, and encodes ids and search terms.
In a single-page app, call the History API directly as shown. A router’s navigation method may run asynchronously, and a navigation that remounts the page containing <mapvx-website> replaces the element with a new one, which does not keep the previous search text or selected category, and needs its listener attached again. Some routers also keep their own data in history.state; the example preserves it when replacing, but check how your router handles direct History API calls.
Deep links
The component reads four query parameters from the host page’s location.search:
| Parameter | Value | Effect |
|---|---|---|
search | Search term | Fills the search box and opens the search results. |
tenant | Place mapvxId or alias | Opens that place’s profile. |
destination | Place mapvxId or alias | Opens the route screen with that place as the destination. |
tab | Category names | Turns those categories on, several separated by commas. |
<a href="/directory?tenant=YOUR_PLACE_ID">Open a place</a>
<a href="/directory?search=coffee">Search for coffee</a>
<a href="/directory?destination=YOUR_PLACE_ID">Get directions to a place</a>
<a href="/directory?tab=Shopping">Show only Shopping</a>
<a href="/directory?tab=Shopping,Hotels">Show Shopping and Hotels</a>- Priority. Only one of
search,tenantanddestinationis used:searchwins overtenant, andtenantwins overdestination.tabis applied independently. - Ids.
tenantanddestinationaccept the place’smapvxIdor its client id (thealiasof the events). Fortenant, an id that is not in the loaded place list is requested from the API. The profile waits until places have loaded, so it stays empty if they never load (for example without a validapi-key). destinationdepends on the institution. The route screen pre-fills the destination only when the place belongs to the currently selected institution. Ifinstitution-idis missing or matches no institution, adestinationlink opens the route screen with an empty destination. See institution-id.tabmust match exactly. Each value must equal a category name, including case. Unknown names are dropped rather than emptying the directory, and spacing around the commas is ignored.?tab=Allmeans no category filter, which is the same as leaving it out. The component applies it again whenever the category list is recomputed and whenever the list is shown again.- Query string only. The hash is not read, so hash-based routes such as
#/directory?tenant=...do not work. - When they are read.
search,tenantanddestinationare read every time the home screen is created: on first load, and each time the visitor returns home from a place profile. - Read, not watched. Changing the query string later does not navigate the component, and there is no method to open a place from script.
- Events. A
tenantlink emitsshow-placeand asearchlink emitssearch.destinationandtablinks emit nothing.
Rewrite deep links on return-to-home. If the page was opened with ?tenant=X (or ?destination=X) and the URL never changes, the component’s Back button returns to the home screen, which reads the same parameter and opens X again. Update the URL on return-to-home as shown in Keeping the host URL in sync.
These parameter names are effectively reserved. On any page that contains <mapvx-website>, search, tenant, destination and tab are read as deep links. If your page uses one of them for something else, rename your own parameter.
Browser Back
The component never changes the host page’s URL or history. While a place profile is open, it listens for popstate on window. Any popstate, whether it comes from the browser’s Back or Forward button, history.back(), history.go(), a host router moving through history, or a link or script that changes only the hash (#section), makes the component emit return-to-home and return to its home screen. The listener is removed when the profile closes.
What this means for your page:
- Without URL sync, Back goes to the previous history entry. The component adds no entries of its own, so Back takes the visitor to whatever came before, usually the previous page. If Back should close the profile instead, push an entry when a profile opens, as in the URL sync example.
- With the URL sync example, Back on a profile returns to your home URL and the component returns to its home screen. The
return-to-homehandler replaces the current entry, so the forward entry is kept. - Forward does not reopen the place. After Back, the component is on its home screen, where it does not listen for
popstate: Forward changes only the URL. The place would open again on reload, because the URL then containstenant. - Search results and the route screen do not listen. Browser Back on those screens changes only the host URL; the component stays where it is.
- Host navigation counts too. If your own router traverses history while a profile is open, the component also emits
return-to-homeand goes home.