Add HTML map markers and a popup card for MapLibre maps

This commit is contained in:
codecalm committed 2026-10-04 16:49:47 +02:00
1 parent 5b2cddc30b
commit b6904b93bd
8 files changed
+377 -16

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": patch
---
Added `.map-marker` with dot, pin and color variants and `.map-popup-card` for MapLibre maps to `tabler-vendors.css`.
+6 -6
View File
@@ -196,19 +196,19 @@
},
{
"path": "./dist/css/tabler-vendors.css",
"maxSize": "4.5 kB"
"maxSize": "5 kB"
},
{
"path": "./dist/css/tabler-vendors.min.css",
"maxSize": "4 kB"
},
{
"path": "./dist/css/tabler-vendors.rtl.css",
"maxSize": "4.5 kB"
},
{
"path": "./dist/css/tabler-vendors.rtl.css",
"maxSize": "5 kB"
},
{
"path": "./dist/css/tabler-vendors.rtl.min.css",
"maxSize": "4 kB"
"maxSize": "4.5 kB"
},
{
"path": "./dist/js/tabler.js",
+13
View File
@@ -1279,6 +1279,19 @@ $ribbon-bookmark-border-size: 1rem !default;
$ribbon-bookmark-notch-width: 0.5rem !default;
// scss-docs-end ribbon-variables
// scss-docs-start map-marker-variables
$map-marker-size: 1.75rem !default;
$map-marker-dot-size: 0.875rem !default;
$map-marker-bg: var(--primary) !default;
$map-marker-color: var(--primary-fg) !default;
$map-marker-border-width: 2px !default;
$map-marker-border-color: var(--bg-surface) !default;
$map-marker-font-size: $h5-font-size !default;
$map-marker-icon-size: 1rem !default;
$map-marker-shadow: drop-shadow(0 1px 2px rgba($black, 0.3)) !default;
$map-popup-card-width: 16rem !default;
// scss-docs-end map-marker-variables
// Accordion
// scss-docs-start accordion-variables
$accordion-padding-y: 1rem !default;
@@ -37,17 +37,21 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre
"--plyr-color-main",
"--tblr-active-bg",
"--tblr-azure",
"--tblr-azure-fg",
"--tblr-backdrop-bg",
"--tblr-bg-forms",
"--tblr-bg-surface",
"--tblr-bg-surface-inverted",
"--tblr-bg-surface-secondary",
"--tblr-black",
"--tblr-blue",
"--tblr-blue-fg",
"--tblr-body-bg",
"--tblr-body-color",
"--tblr-border-color",
"--tblr-border-color-translucent",
"--tblr-border-radius",
"--tblr-border-radius-pill",
"--tblr-border-style",
"--tblr-border-width",
"--tblr-card-bg",
@@ -61,7 +65,12 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre
"--tblr-control-padding-x",
"--tblr-control-padding-y",
"--tblr-control-placeholder-color",
"--tblr-cyan",
"--tblr-cyan-fg",
"--tblr-danger",
"--tblr-danger-fg",
"--tblr-dark",
"--tblr-dark-fg",
"--tblr-focus-ring",
"--tblr-focus-ring-color",
"--tblr-focus-ring-offset",
@@ -79,26 +88,56 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre
"--tblr-form-valid-border-color",
"--tblr-gray-500",
"--tblr-green",
"--tblr-green-fg",
"--tblr-icon-size",
"--tblr-indigo",
"--tblr-indigo-fg",
"--tblr-info",
"--tblr-info-fg",
"--tblr-light",
"--tblr-light-fg",
"--tblr-lime",
"--tblr-lime-fg",
"--tblr-map-marker-bg",
"--tblr-map-marker-border-color",
"--tblr-map-marker-border-width",
"--tblr-map-marker-color",
"--tblr-map-marker-font-size",
"--tblr-map-marker-icon-size",
"--tblr-map-marker-size",
"--tblr-muted",
"--tblr-muted-fg",
"--tblr-orange",
"--tblr-orange-fg",
"--tblr-pink",
"--tblr-pink-fg",
"--tblr-primary",
"--tblr-primary-200",
"--tblr-primary-fg",
"--tblr-primary-lt",
"--tblr-purple",
"--tblr-purple-fg",
"--tblr-red",
"--tblr-red-fg",
"--tblr-secondary",
"--tblr-secondary-bg",
"--tblr-secondary-fg",
"--tblr-shadow-card",
"--tblr-shadow-color",
"--tblr-shadow-dropdown",
"--tblr-shadow-input",
"--tblr-signal-color",
"--tblr-success",
"--tblr-success-fg",
"--tblr-teal",
"--tblr-teal-fg",
"--tblr-tertiary",
"--tblr-text-inverted",
"--tblr-warning",
"--tblr-warning-fg",
"--tblr-white",
"--tblr-yellow",
"--tblr-yellow-fg",
"--ts-pr-caret",
"--ts-pr-clear-button",
]
+85
View File
@@ -122,6 +122,91 @@
border-left-color: var(--bg-surface);
}
// Popup that holds a card-like block: an image on top, padded content below
.map-popup-card {
.maplibregl-popup-content {
width: #{$map-popup-card-width};
padding: 0;
overflow: hidden;
}
.maplibregl-popup-close-button {
color: var(--body-color);
background-color: var(--bg-surface);
}
}
// Marker drawn in HTML. MapLibre moves the marker with `transform`, so the
// shape is drawn by the pseudo-element and the root keeps no transform.
.map-marker {
// scss-docs-start map-marker-css-vars
--map-marker-size: #{$map-marker-size};
--map-marker-bg: #{$map-marker-bg};
--map-marker-color: #{$map-marker-color};
--map-marker-border-width: #{$map-marker-border-width};
--map-marker-border-color: #{$map-marker-border-color};
--map-marker-font-size: #{$map-marker-font-size};
--map-marker-icon-size: #{$map-marker-icon-size};
// scss-docs-end map-marker-css-vars
position: relative;
z-index: 0;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--map-marker-size);
min-height: var(--map-marker-size);
padding: var(--map-marker-border-width);
font-size: var(--map-marker-font-size);
font-weight: var(--font-weight-medium);
line-height: 1;
color: var(--map-marker-color);
cursor: pointer;
filter: #{$map-marker-shadow};
&::before {
position: absolute;
inset: 0;
z-index: -1;
content: '';
background: var(--map-marker-bg);
border: var(--map-marker-border-width) solid var(--map-marker-border-color);
@include border-radius(var(--border-radius-pill));
}
.icon {
--icon-size: var(--map-marker-icon-size);
}
}
.map-marker-dot {
--map-marker-size: #{$map-marker-dot-size};
}
// Teardrop: a square with one sharp corner, turned to point down. Its tip
// reaches 20.7% below the circle, so the root is that much taller.
.map-marker-pin {
box-sizing: content-box;
width: var(--map-marker-size);
min-width: 0;
height: var(--map-marker-size);
min-height: 0;
padding: 0 0 calc(var(--map-marker-size) * 0.207);
&::before {
bottom: auto;
height: var(--map-marker-size);
@include border-radius(50% 50% 50% 0);
transform: rotate(-45deg);
}
}
@each $name, $color in $theme-colors {
.map-marker-#{$name} {
--map-marker-bg: var(--#{$name});
--map-marker-color: var(--#{$name}-fg);
}
}
// The control icons are black SVG images
@if $enable-dark-mode {
@include color-mode(dark) {
+117 -2
View File
@@ -4,17 +4,31 @@ summary: A map shows places and markers on an interactive world map. Tabler prov
description: Show interactive maps with markers using MapLibre GL JS and free map tiles. No account or access token is needed.
related: [/ui/plugins/vector-map]
docs-libs: [maplibre-gl]
source: core/scss/_utilities.scss
source: core/scss/vendor/_maplibre.scss
css-vars: map-marker
classnames:
component:
- class: ratio
desc: Wrapper that keeps the embed at a fixed aspect ratio
desc: Wrapper that keeps the map at a fixed aspect ratio
- class: map-marker
desc: Marker drawn in HTML, a circle that can hold an icon, a count or an avatar
modifier:
- class: map-marker-dot
desc: Small dot with no content
- class: map-marker-pin
desc: Teardrop pin that points down, used with the bottom anchor
- class: map-popup-card
desc: Popup with no padding and a fixed width, for an image above the text
color:
- class: map-marker-{color}
desc: Any theme color, for example map-marker-red or map-marker-green
size:
- class: ratio-{ratio}
desc: The aspect to hold, for example ratio-16x9 or ratio-21x9
---
import Example from '@components/Example.astro';
import Map from '@shared/components/demo/Map.astro';
import Icon from '@ui/Icon.astro';
import { Code } from 'astro:components';
import { site } from '@shared/lib/site.ts';
@@ -122,6 +136,69 @@ cities.forEach(function (coords) {
The `color` option is written to the `fill` attribute of the marker icon. A CSS variable works there, so `var(--tblr-primary)` keeps the markers in your theme color, including after a theme change.
### Custom markers
To draw a marker in HTML, create an element with the `.map-marker` class and pass it as `element`. The marker is a circle in the primary color. Put an icon, a short count or an avatar inside it.
```js
const element = document.createElement('span')
element.className = 'map-marker map-marker-green'
element.textContent = '12'
new maplibregl.Marker({ element: element }).setLngLat([13.35, 52.512]).addTo(map)
```
Add `.map-marker-dot` for a small dot, or `.map-marker-pin` for a pin that points down. A pin needs `anchor: 'bottom'`, so its tip sits on the place. Change the color with `.map-marker-{color}`.
```js
const pin = document.createElement('span')
pin.className = 'map-marker map-marker-pin map-marker-red'
new maplibregl.Marker({ element: pin, anchor: 'bottom' }).setLngLat([13.47, 52.518]).addTo(map)
```
<Example>
<Map mapId="marker-styles" title="Marker styles" center={[52.522, 13.405]} zoom={11}>
<div data-lng="13.345" data-lat="52.54"><span class="map-marker map-marker-dot"></span></div>
<div data-lng="13.39" data-lat="52.545"><span class="map-marker map-marker-dot map-marker-red"></span></div>
<div data-lng="13.445" data-lat="52.538"><span class="map-marker"><Icon name="building-store" /></span></div>
<div data-lng="13.35" data-lat="52.512"><span class="map-marker map-marker-green">12</span></div>
<div data-lng="13.405" data-lat="52.522" data-anchor="bottom"><span class="map-marker map-marker-pin"><Icon name="home" /></span></div>
<div data-lng="13.47" data-lat="52.518" data-anchor="bottom"><span class="map-marker map-marker-pin map-marker-red"><Icon name="star" /></span></div>
<div data-lng="13.43" data-lat="52.508"><span class="map-marker"><span class="avatar avatar-sm" style="background-image: url(/static/avatars/000m.jpg)"></span></span></div>
</Map>
</Example>
You can also write the markers in the page and move them to the map. The preview pages do this: each marker sits in a hidden element with `data-lng` and `data-lat`, and a short loop passes it to MapLibre.
```html
<div id="map-markers" hidden>
<div data-lng="13.405" data-lat="52.522">
<span class="map-marker map-marker-pin">...</span>
</div>
</div>
```
```js
document.querySelectorAll('#map-markers > [data-lng]').forEach(function (item) {
new maplibregl.Marker({ element: item.firstElementChild, anchor: 'bottom' })
.setLngLat([Number(item.dataset.lng), Number(item.dataset.lat)])
.addTo(map)
})
```
### Draggable markers
Pass `draggable: true` to let people move a marker, for example to pick an address. Read the new position in the `dragend` event.
```js
const marker = new maplibregl.Marker({ element: pin, anchor: 'bottom', draggable: true }).setLngLat([13.405, 52.52]).addTo(map)
marker.on('dragend', function () {
console.log(marker.getLngLat())
})
```
### Popups
Attach a popup to a marker with `setPopup`. It opens when the marker is clicked. Click a marker on the map above to see one.
@@ -135,6 +212,44 @@ new maplibregl.Marker({ color: 'var(--tblr-primary)' })
Use `setHTML` instead of `setText` for richer content. Only pass HTML you trust, as it is not escaped.
### Popup card
For a popup with an image, add the `map-popup-card` class with the `className` option. It removes the padding and sets a fixed width, so the image reaches the edges. Keep the content in a `<template>` and pass it with `setDOMContent`.
```html
<template id="popup-museum">
<div class="img-responsive img-responsive-21x9" style="background-image: url(...)"></div>
<div class="p-3">
<div class="fw-medium">Museum Island</div>
<div class="text-secondary mb-3">Five museums on the Spree, open daily.</div>
<a href="#" class="btn btn-primary btn-sm">View details</a>
</div>
</template>
```
```js
const popup = new maplibregl.Popup({ offset: 40, className: 'map-popup-card', maxWidth: 'none' })
.setDOMContent(document.getElementById('popup-museum').content)
new maplibregl.Marker({ element: pin, anchor: 'bottom' }).setLngLat([13.4019, 52.5169]).setPopup(popup).addTo(map)
```
<Example>
<Map mapId="popup-card" title="Popup card" center={[52.546, 13.4]} zoom={11} ratio="4x3">
<div data-lng="13.4019" data-lat="52.5169" data-anchor="bottom" data-open>
<span class="map-marker map-marker-pin"><Icon name="map-pin" /></span>
<template class="map-popup-card">
<div class="img-responsive img-responsive-21x9" style="background-image: url(/static/photos/a-visit-to-the-bookstore.jpg)"></div>
<div class="p-3">
<div class="fw-medium">Museum Island</div>
<div class="text-secondary mb-3">Five museums on the Spree, open daily.</div>
<a href="#" class="btn btn-primary btn-sm">View details</a>
</div>
</template>
</div>
</Map>
</Example>
### Controls
Add the zoom and compass buttons with `NavigationControl`. They go to the top right corner by default.
+71 -1
View File
@@ -5,13 +5,23 @@ import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import maps from '@data/maps.json'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import DocsLink from '@ui/DocsLink.astro'
import Avatar from '@ui/Avatar.astro'
import Icon from '@ui/Icon.astro'
import { staticPath } from '@shared/lib/assets'
type MapData = { title?: string; card?: boolean }
const mapEntries = Object.entries(maps as Record<string, MapData>)
const places = [
{ name: 'Museum Island', text: 'Five museums on the Spree, open daily.', photo: 'a-visit-to-the-bookstore.jpg', lat: 52.5169, lng: 13.4019, open: true },
{ name: 'Bellevue Palace', text: 'Residence by the Tiergarten park.', photo: 'book-on-the-grass.jpg', lat: 52.5176, lng: 13.3531, open: false },
{ name: 'Weissensee', text: 'A lake with a beach in the north-east.', photo: 'brainstorming-session-with-creative-designers.jpg', lat: 52.5546, lng: 13.4633, open: false },
]
---
<DefaultLayout title="Maps" pageMenu="plugins.maps" pageLibs={['maplibre-gl']} description="Interactive MapLibre maps — plain, styled, or full-card with markers.">
<DefaultLayout title="Maps" pageMenu="plugins.maps" pageLibs={['maplibre-gl']} description="Interactive MapLibre maps — plain, styled, with custom markers and popups.">
<DocsLink slot="page-header-actions" path="/ui/components/map" />
<div class="row row-cards">
{
@@ -34,5 +44,65 @@ const mapEntries = Object.entries(maps as Record<string, MapData>)
),
)
}
<div class="col-lg-6">
<Card>
<CardBody>
<CardTitle>Marker styles</CardTitle>
<CardSubtitle>Draw markers in HTML with <code>.map-marker</code>: a dot, a circle with an icon or a count, a pin, or an avatar.</CardSubtitle>
<Map mapId="marker-styles" title="Marker styles" center={[52.522, 13.405]} zoom={11} ratio="4x3">
<div data-lng="13.345" data-lat="52.54"><span class="map-marker map-marker-dot"></span></div>
<div data-lng="13.39" data-lat="52.545"><span class="map-marker map-marker-dot map-marker-red"></span></div>
<div data-lng="13.445" data-lat="52.538"><span class="map-marker"><Icon name="building-store" /></span></div>
<div data-lng="13.35" data-lat="52.512"><span class="map-marker map-marker-green">12</span></div>
<div data-lng="13.405" data-lat="52.52" data-anchor="bottom"><span class="map-marker map-marker-pin"><Icon name="home" /></span></div>
<div data-lng="13.47" data-lat="52.518" data-anchor="bottom"><span class="map-marker map-marker-pin map-marker-red"><Icon name="star" /></span></div>
<div data-lng="13.43" data-lat="52.503"><span class="map-marker"><Avatar size="sm" src={staticPath('avatars/000m.jpg')} /></span></div>
</Map>
</CardBody>
</Card>
</div>
<div class="col-lg-6">
<Card>
<CardBody>
<CardTitle>Popup card</CardTitle>
<CardSubtitle>Put the popup content in a <code>&lt;template&gt;</code> next to the marker. <code>.map-popup-card</code> makes room for an image.</CardSubtitle>
<Map mapId="popup-card" title="Popup card" center={[52.546, 13.4]} zoom={11} ratio="4x3">
{
places.map((place) => (
<div data-lng={place.lng} data-lat={place.lat} data-anchor="bottom" data-open={place.open ? '' : undefined}>
<span class="map-marker map-marker-pin">
<Icon name="map-pin" />
</span>
<template class="map-popup-card">
<div class="img-responsive img-responsive-21x9" style={`background-image: url(${staticPath(`photos/${place.photo}`)})`} />
<div class="p-3">
<div class="fw-medium">{place.name}</div>
<div class="text-secondary mb-3">{place.text}</div>
<a href="#" class="btn btn-primary btn-sm">
View details
</a>
</div>
</template>
</div>
))
}
</Map>
</CardBody>
</Card>
</div>
<div class="col-lg-12">
<Card>
<CardBody>
<CardTitle>Draggable marker</CardTitle>
<CardSubtitle>Add <code>draggable: true</code> to let people move a marker, for example to pick an address.</CardSubtitle>
<Map mapId="draggable" title="Draggable marker" center={[52.52, 13.405]} zoom={12} ratio="21x9">
<div data-lng="13.405" data-lat="52.52" data-anchor="bottom" data-draggable><span class="map-marker map-marker-pin"><Icon name="map-pin" /></span></div>
</Map>
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+39 -7
View File
@@ -1,34 +1,42 @@
---
// MapLibre GL map on OpenFreeMap tiles: no account and no access token.
// The map follows the page theme: `style` in light mode, `darkStyle` in dark.
// Renders nothing unless maps[mapId] exists.
// Markers written in HTML go in the default slot: each child has `data-lng`
// and `data-lat`, holds the marker element, and optionally a `<template>` with
// the popup content. Renders nothing unless maps[mapId] exists or `center` is set.
import maps from '@data/maps.json';
import CaptureScript from '../CaptureScript.astro';
interface Props {
/** Key into the maps data */
/** Key into the maps data; also the suffix of the container id */
mapId: string;
ratio?: string;
/** for a map with no entry in the maps data: [latitude, longitude] */
center?: [number, number];
zoom?: number;
title?: string;
style?: string;
}
const { mapId, ratio } = Astro.props;
const { mapId, ratio, center: centerProp, zoom: zoomProp, title, style: styleProp } = Astro.props;
type MapData = {
title?: string;
title?: string | undefined;
ratio?: string;
card?: boolean;
/** OpenFreeMap style name: liberty (default), bright, positron, dark or fiord */
style?: string;
style?: string | undefined;
/** style used in dark mode, `dark` by default */
darkStyle?: string;
/** adds the zoom and compass buttons */
controls?: boolean;
zoom?: number;
zoom?: number | undefined;
center?: [number, number];
markers?: { name?: string; center: [number, number] }[];
};
const data = (maps as unknown as Record<string, MapData>)[mapId];
const data: MapData | undefined = (maps as unknown as Record<string, MapData>)[mapId] ?? (centerProp ? { title, center: centerProp, zoom: zoomProp, style: styleProp } : undefined);
const hasHtmlMarkers = Astro.slots.has('default');
const mapContainerId = `map-${mapId}`;
const styleUrl = (name: string) => `https://tiles.openfreemap.org/styles/${name}`;
@@ -51,6 +59,13 @@ const ratioClass = data?.ratio ?? ratio ?? '16x9';
</div>
)
}
{
data && hasHtmlMarkers && (
<div id={`${mapContainerId}-markers`} hidden>
<slot />
</div>
)
}
{
data && (
<CaptureScript>
@@ -79,6 +94,23 @@ const ratioClass = data?.ratio ?? ratio ?? '16x9';
.addTo(map);
});
// Markers written in HTML: the first child is the marker, a <template> is its popup
document.querySelectorAll(`#${mapContainerId}-markers > [data-lng]`).forEach((item) => {
const anchor = item.dataset.anchor ?? 'center';
const marker = new maplibregl.Marker({ element: item.firstElementChild, anchor, draggable: 'draggable' in item.dataset });
const popup = item.querySelector('template');
if (popup) {
marker.setPopup(new maplibregl.Popup({ offset: anchor === 'bottom' ? 40 : 20, className: popup.className, maxWidth: 'none', focusAfterOpen: !('open' in item.dataset) }).setDOMContent(popup.content));
}
marker.setLngLat([Number(item.dataset.lng), Number(item.dataset.lat)]).addTo(map);
if (popup && 'open' in item.dataset) {
marker.togglePopup();
}
});
// Change the map style when the page theme changes
new MutationObserver(() => map.setStyle(themeStyle())).observe(document.documentElement, { attributeFilter: ['data-bs-theme'] });