diff --git a/.changeset/vector-map-plugin.md b/.changeset/vector-map-plugin.md index 4d738ff31..20feac7f2 100644 --- a/.changeset/vector-map-plugin.md +++ b/.changeset/vector-map-plugin.md @@ -4,4 +4,4 @@ "@tabler/docs": minor --- -Added the `VectorMap` plugin: an SVG world map with values, color scales, a legend, markers, lines, tooltips and zoom. +Added the `VectorMap` plugin: an SVG world map with values, color scales, a legend, markers, lines, selection, tooltips and zoom. diff --git a/core/js/src/vector-map.ts b/core/js/src/vector-map.ts index 0ccd8579d..79cb70e08 100644 --- a/core/js/src/vector-map.ts +++ b/core/js/src/vector-map.ts @@ -46,6 +46,7 @@ export type VectorMapTooltipItem = { type: 'region'; code: string; name: string; type VectorMapValues = Record type VectorMapTooltip = boolean | ((item: VectorMapTooltipItem) => string) type VectorMapLegend = boolean | ((value: number) => string) +type VectorMapSelect = 'none' | 'single' | 'multiple' type Point = [number, number] // The part of the map in sight: its top left corner and how many times it is magnified type View = { x: number; y: number; scale: number } @@ -60,6 +61,8 @@ type ComponentConfig = { lines: VectorMapLine[] tooltip: VectorMapTooltip legend: VectorMapLegend + select: VectorMapSelect + selected: string[] zoom: boolean zoomMax: number zoomOnScroll: boolean @@ -80,6 +83,9 @@ const EVENT_KEY = `.${DATA_KEY}` const EVENT_RENDERED = `rendered${EVENT_KEY}` const EVENT_UPDATED = `updated${EVENT_KEY}` +const EVENT_REGION_CLICK = `regionclick${EVENT_KEY}` +const EVENT_MARKER_CLICK = `markerclick${EVENT_KEY}` +const EVENT_SELECTED = `selected${EVENT_KEY}` const EVENT_POINTERMOVE = `pointermove${EVENT_KEY}` const EVENT_POINTERLEAVE = `pointerleave${EVENT_KEY}` const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}` @@ -103,6 +109,8 @@ const CLASS_NAME_LEGEND = 'vector-map-legend' const CLASS_NAME_LEGEND_LABEL = 'vector-map-legend-label' const CLASS_NAME_LEGEND_SCALE = 'vector-map-legend-scale' const CLASS_NAME_LEGEND_STEP = 'vector-map-legend-step' +const CLASS_NAME_SELECTABLE = 'vector-map-selectable' +const CLASS_NAME_SELECTED = 'vector-map-region-selected' const CLASS_NAME_ZOOMABLE = 'vector-map-zoomable' const CLASS_NAME_DRAGGING = 'vector-map-dragging' const CLASS_NAME_CONTROLS = 'vector-map-controls' @@ -116,6 +124,16 @@ const ATTRIBUTE_ACTION = 'data-bs-vector-map-action' const SELECTOR_TOOLTIP_TARGET = `.${CLASS_NAME_REGION}, .${CLASS_NAME_MARKER}` const SELECTOR_ACTION = `[${ATTRIBUTE_ACTION}]` +// What a selected shape is moved in front of, so its outline is not covered +// by the shapes around it but stays under everything drawn over the map +const SELECTOR_OVER_SHAPES = `.${CLASS_NAME_POINT}, .${CLASS_NAME_LINE}, .${CLASS_NAME_MARKER}` + +const SELECT_NONE = 'none' +const SELECT_MULTIPLE = 'multiple' + +// How far a pointer has to move, in pixels, before a press becomes a drag +// and stops being a click +const DRAG_THRESHOLD = 4 const ACTION_ZOOM_IN = 'zoom-in' const ACTION_ZOOM_OUT = 'zoom-out' @@ -159,6 +177,8 @@ const Default: ComponentConfig = { lines: [], tooltip: true, legend: false, + select: 'none', + selected: [], zoom: false, zoomMax: 8, zoomOnScroll: true, @@ -177,6 +197,8 @@ const DefaultType: Record = { lines: 'array', tooltip: '(boolean|function)', legend: '(boolean|function)', + select: 'string', + selected: 'array', zoom: 'boolean', zoomMax: 'number', zoomOnScroll: 'boolean', @@ -279,6 +301,11 @@ class VectorMap extends BaseComponent { _frame = 0 // Pointers that are down on the map, with where each was last seen _pointers = new Map() + // Whether the pointers that are down have moved the map. A click that ends + // a drag is not a click on a region. + _dragged = false + // The codes of the selected regions; filled from `selected` on the first render + _selected: Set | null = null /** Maps added with `addMap()`, by name */ static maps: Record = {} @@ -312,6 +339,9 @@ class VectorMap extends BaseComponent { // `wheel` is not among the native events EventHandler knows, and the // listener must not be passive to keep the page from scrolling. this._element.addEventListener('wheel', this._onWheel, { passive: false }) + EventHandler.on(this._element, EVENT_CLICK, (event: MouseEvent) => { + this._onClick(event) + }) EventHandler.on(this._element, EVENT_DBLCLICK, (event: MouseEvent) => { if (this._config.zoom && this._isOnMap(event)) { this._zoomBy(ZOOM_STEP, this._toMapPoint(event.clientX, event.clientY), true) @@ -380,6 +410,7 @@ class VectorMap extends BaseComponent { this._element.append(svg) this._renderControls() this._applyColors() + this._applySelected() this._setView({ x: 0, y: 0, scale: 1 }) this._applyValues() @@ -394,6 +425,19 @@ class VectorMap extends BaseComponent { EventHandler.trigger(this._element, EVENT_UPDATED) } + // The codes of the selected regions, in the order they were selected + getSelected(): string[] { + return [...(this._selected ?? [])] + } + + // Selects exactly these regions. Codes that are not on the map are left + // out, and all but the first when only one region can be selected. + setSelected(codes: string[]): void { + this._selected = this._toSelected(codes) + this._applySelected() + EventHandler.trigger(this._element, EVENT_SELECTED, { selected: this.getSelected() }) + } + zoomIn(): void { this._zoomBy(ZOOM_STEP, null, true) } @@ -415,7 +459,7 @@ class VectorMap extends BaseComponent { this._tooltip?.remove() this._config.colors = [] this._applyColors() - this._element.classList.remove(CLASS_NAME_ZOOMABLE, CLASS_NAME_DRAGGING) + this._element.classList.remove(CLASS_NAME_ZOOMABLE, CLASS_NAME_DRAGGING, CLASS_NAME_SELECTABLE) this._element.removeEventListener('wheel', this._onWheel) super.dispose() } @@ -671,17 +715,12 @@ class VectorMap extends BaseComponent { return } + if (this._pointers.size === 0) { + this._dragged = false + } + this._pointers.set(event.pointerId, [event.clientX, event.clientY]) cancelAnimationFrame(this._frame) - - // The map keeps getting the moves of this pointer when it leaves the map. - // Capturing throws for a pointer the browser does not know, which is what - // an event made in a script has. - try { - this._getSvg()?.setPointerCapture(event.pointerId) - } catch { - // The drag still works while the pointer stays over the map - } } _onPointerUp(event: PointerEvent): void { @@ -700,6 +739,26 @@ class VectorMap extends BaseComponent { } const current: Point = [event.clientX, event.clientY] + + // A press that barely moves is a click, not a drag. Until the pointer has + // moved far enough the map stays put, and the click reaches the region. + if (!this._dragged) { + if (this._pointers.size === 1 && Math.hypot(current[0] - previous[0], current[1] - previous[1]) < DRAG_THRESHOLD) { + return + } + + this._dragged = true + + // The map keeps getting the moves of this pointer when it leaves the + // map. Capturing throws for a pointer the browser does not know, which + // is what an event made in a script has. + try { + this._getSvg()?.setPointerCapture(event.pointerId) + } catch { + // The drag still works while the pointer stays over the map + } + } + this._pointers.set(event.pointerId, current) this._element.classList.add(CLASS_NAME_DRAGGING) this._hideTooltip() @@ -770,6 +829,75 @@ class VectorMap extends BaseComponent { } } + _toSelected(codes: unknown): Set { + const { select } = this._config + const known = (Array.isArray(codes) ? (codes as unknown[]) : []).filter((code): code is string => typeof code === 'string' && this._map?.regions[code] !== undefined) + + if (select === SELECT_NONE) { + return new Set() + } + + return new Set(select === SELECT_MULTIPLE ? known : known.slice(0, 1)) + } + + // Marks the selected regions for the stylesheet. A shape that gets selected + // moves after the other shapes, where nothing covers its outline. + _applySelected(): void { + const svg = this._getSvg() + if (!svg) { + return + } + + this._selected ??= this._toSelected(this._config.selected) + this._element.classList.toggle(CLASS_NAME_SELECTABLE, this._config.select !== SELECT_NONE) + + const over = svg.querySelector(SELECTOR_OVER_SHAPES) + for (const region of svg.querySelectorAll(`.${CLASS_NAME_REGION}`)) { + const selected = this._selected.has(region.getAttribute(ATTRIBUTE_REGION) ?? '') + if (selected && !region.classList.contains(CLASS_NAME_SELECTED) && !region.classList.contains(CLASS_NAME_POINT)) { + svg.insertBefore(region, over) + } + + region.classList.toggle(CLASS_NAME_SELECTED, selected) + } + } + + // A click on a region or on a marker becomes an event of the map. A click on + // a region also selects it, unless the event was prevented. + _onClick(event: MouseEvent): void { + if (this._dragged || !this._isOnMap(event) || !this._map) { + return + } + + const target = event.target as Element + const marker = target.closest(`.${CLASS_NAME_MARKER}`) + if (marker) { + const index = [...this._element.querySelectorAll(`.${CLASS_NAME_MARKER}`)].indexOf(marker) + EventHandler.trigger(this._element, EVENT_MARKER_CLICK, { ...this._config.markers[index], relatedTarget: marker }) + return + } + + const region = target.closest(`.${CLASS_NAME_REGION}`) + const code = region?.getAttribute(ATTRIBUTE_REGION) + if (!region || !code) { + return + } + + const value = this._config.values[code] + const clickEvent = EventHandler.trigger(this._element, EVENT_REGION_CLICK, { code, name: this._map.regions[code]?.name ?? code, ...(value === undefined ? {} : { value }), relatedTarget: region }) + if (clickEvent?.defaultPrevented || this._config.select === SELECT_NONE) { + return + } + + // A click on a selected region takes it out again + const selected = this.getSelected() + const rest = selected.filter((other) => other !== code) + const next = rest.length < selected.length ? rest : this._config.select === SELECT_MULTIPLE ? [...selected, code] : [code] + this._selected = new Set(next) + this._applySelected() + EventHandler.trigger(this._element, EVENT_SELECTED, { selected: next, code }) + } + _getLegend(): HTMLElement | null { return this._element.querySelector(`.${CLASS_NAME_LEGEND}`) } diff --git a/core/js/tests/unit/vector-map.spec.ts b/core/js/tests/unit/vector-map.spec.ts index ebd16f38c..181de4359 100644 --- a/core/js/tests/unit/vector-map.spec.ts +++ b/core/js/tests/unit/vector-map.spec.ts @@ -226,6 +226,183 @@ describe('VectorMap', () => { }) }) + describe('click', () => { + it('should fire regionclick with the code, the name and the value of the region', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { map: squares, values: { AA: 5 } }) + + const spy = vi.fn() + el().addEventListener('regionclick.bs.vector-map', spy) + region('AA').dispatchEvent(new MouseEvent('click', { bubbles: true })) + region('BB').dispatchEvent(new MouseEvent('click', { bubbles: true })) + + expect(spy).toHaveBeenCalledTimes(2) + expect(spy.mock.calls[0][0]).toMatchObject({ code: 'AA', name: 'First', value: 5, relatedTarget: region('AA') }) + expect(spy.mock.calls[1][0]).toMatchObject({ code: 'BB', name: 'Second' }) + expect('value' in spy.mock.calls[1][0]).toBe(false) + }) + + it('should fire markerclick with the marker, and no regionclick', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { + map: squares, + markers: [ + { name: 'One', lat: 0, lng: 0 }, + { name: 'Two', lat: 1, lng: 2 }, + ], + }) + + const markerSpy = vi.fn() + const regionSpy = vi.fn() + el().addEventListener('markerclick.bs.vector-map', markerSpy) + el().addEventListener('regionclick.bs.vector-map', regionSpy) + el() + .querySelectorAll('.vector-map-marker-dot')[1] + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + + expect(markerSpy.mock.calls[0][0]).toMatchObject({ name: 'Two', lat: 1, lng: 2 }) + expect(regionSpy).not.toHaveBeenCalled() + }) + + it('should not take the click that ends a drag for a click on a region', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { map: squares, zoomMax: 2 }) + el().dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: -5000 })) + + const spy = vi.fn() + el().addEventListener('regionclick.bs.vector-map', spy) + const pointer = (type: string, x: number): void => { + region('BB').dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, pointerType: 'mouse', button: 0, clientX: x, clientY: 50 })) + } + + pointer('pointerdown', 100) + pointer('pointermove', 60) + pointer('pointerup', 60) + region('BB').dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(spy).not.toHaveBeenCalled() + + // A press that moves by a pixel or two is still a click + const viewBox = el().querySelector('svg')!.getAttribute('viewBox') + pointer('pointerdown', 100) + pointer('pointermove', 102) + pointer('pointerup', 102) + region('BB').dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(spy).toHaveBeenCalledTimes(1) + expect(el().querySelector('svg')!.getAttribute('viewBox')).toBe(viewBox) + }) + }) + + describe('select', () => { + const click = (code: string): void => { + region(code).dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + + const isSelected = (code: string): boolean => region(code).classList.contains('vector-map-region-selected') + + it('should not select anything by default', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares, selected: ['AA'] }) + + click('BB') + + expect(map.getSelected()).toEqual([]) + expect(isSelected('AA')).toBe(false) + expect(el().classList.contains('vector-map-selectable')).toBe(false) + }) + + it('should select one region at a time, and take it out on a second click', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares }) + const spy = vi.fn() + el().addEventListener('selected.bs.vector-map', spy) + + expect(el().classList.contains('vector-map-selectable')).toBe(true) + + click('AA') + expect(map.getSelected()).toEqual(['AA']) + expect(isSelected('AA')).toBe(true) + expect(spy.mock.calls[0][0]).toMatchObject({ selected: ['AA'], code: 'AA' }) + + click('BB') + expect(map.getSelected()).toEqual(['BB']) + expect(isSelected('AA')).toBe(false) + + click('BB') + expect(map.getSelected()).toEqual([]) + expect(spy).toHaveBeenCalledTimes(3) + }) + + it('should select several regions', () => { + fixtureEl.innerHTML = `
` + const map = new VectorMap(el(), { map: squares }) + + expect(map.getSelected()).toEqual(['CC']) + + click('AA') + expect(map.getSelected()).toEqual(['CC', 'AA']) + + click('CC') + expect(map.getSelected()).toEqual(['AA']) + expect(isSelected('CC')).toBe(false) + }) + + it('should keep the first of several codes when one region can be selected', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares, select: 'single', selected: ['BB', 'CC'] }) + + expect(map.getSelected()).toEqual(['BB']) + }) + + it('should not select when regionclick is prevented', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares }) + el().addEventListener('regionclick.bs.vector-map', (event) => { + event.preventDefault() + }) + + click('AA') + + expect(map.getSelected()).toEqual([]) + }) + + it('should set the selection from code', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares }) + const spy = vi.fn() + el().addEventListener('selected.bs.vector-map', spy) + + map.setSelected(['AA', 'CC', 'nowhere']) + expect(map.getSelected()).toEqual(['AA', 'CC']) + expect(spy.mock.calls[0][0]).toMatchObject({ selected: ['AA', 'CC'] }) + + map.setSelected([]) + expect(isSelected('AA')).toBe(false) + }) + + it('should draw a selected shape after the other shapes, under the markers', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { map: squares, markers: [{ name: 'One', lat: 0, lng: 0 }] }) + + click('AA') + + const children = [...el().querySelector('svg')!.children] + expect(children.indexOf(region('AA'))).toBeGreaterThan(children.indexOf(region('CC'))) + expect(children.indexOf(region('AA'))).toBeLessThan(children.indexOf(el().querySelector('.vector-map-marker')!)) + }) + + it('should keep the selection over update() and render()', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares }) + + click('BB') + map.update({ AA: 1 }) + map.render() + + expect(map.getSelected()).toEqual(['BB']) + expect(isSelected('BB')).toBe(true) + }) + }) + describe('legend', () => { const legend = (): HTMLElement | null => el().querySelector('.vector-map-legend') const labels = (): string[] => [...el().querySelectorAll('.vector-map-legend-label')].map((label) => label.textContent ?? '') diff --git a/core/package.json b/core/package.json index 7f20f9c7d..6979d7190 100644 --- a/core/package.json +++ b/core/package.json @@ -247,19 +247,19 @@ }, { "path": "./dist/js/tabler-vector-map.js", - "maxSize": "11.5 kB" + "maxSize": "13 kB" }, { "path": "./dist/js/tabler-vector-map.esm.js", - "maxSize": "11.5 kB" + "maxSize": "13 kB" }, { "path": "./dist/js/tabler-vector-map.min.js", - "maxSize": "8 kB" + "maxSize": "9 kB" }, { "path": "./dist/js/tabler-vector-map.esm.min.js", - "maxSize": "8 kB" + "maxSize": "9 kB" }, { "path": "./dist/js/maps/world.js", diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index f9e336bc2..9a5938edd 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -2273,6 +2273,8 @@ $vector-map-region-stroke-width: 1px !default; $vector-map-value-min: 0.1 !default; $vector-map-colors-max: 5 !default; $vector-map-point-size: 0.375rem !default; +$vector-map-selected-stroke: var(--body-color) !default; +$vector-map-selected-stroke-width: 2px !default; $vector-map-marker-size: 0.5rem !default; $vector-map-marker-color: var(--vector-map-color) !default; $vector-map-marker-halo-color: var(--bg-surface) !default; diff --git a/core/scss/ui/_vector-map.scss b/core/scss/ui/_vector-map.scss index e86cb7f18..2470ce95e 100644 --- a/core/scss/ui/_vector-map.scss +++ b/core/scss/ui/_vector-map.scss @@ -15,6 +15,8 @@ --vector-map-region-stroke-width: #{$vector-map-region-stroke-width}; --vector-map-value-min: #{$vector-map-value-min}; --vector-map-point-size: #{$vector-map-point-size}; + --vector-map-selected-stroke: #{$vector-map-selected-stroke}; + --vector-map-selected-stroke-width: #{$vector-map-selected-stroke-width}; --vector-map-marker-size: #{$vector-map-marker-size}; --vector-map-marker-color: #{$vector-map-marker-color}; --vector-map-marker-halo-color: #{$vector-map-marker-halo-color}; @@ -198,3 +200,18 @@ flex: 1; background: var(--vector-map-region-color); } + +// Regions can be clicked to select them +.vector-map-selectable .vector-map-region { + cursor: pointer; +} + +// A selected region gets an outline, and keeps the colour of its value +.vector-map-region-selected { + stroke: var(--vector-map-selected-stroke); + stroke-width: var(--vector-map-selected-stroke-width); + + .vector-map-region-point-halo { + stroke: var(--vector-map-selected-stroke); + } +} diff --git a/docs/content/ui/plugins/vector-map.mdx b/docs/content/ui/plugins/vector-map.mdx index aa4c1355c..905f387d4 100644 --- a/docs/content/ui/plugins/vector-map.mdx +++ b/docs/content/ui/plugins/vector-map.mdx @@ -170,6 +170,44 @@ Add `data-bs-zoom="true"` to let users look closer. The map gets two buttons, an While the pointer is over a zoomable map, the wheel zooms the map and does not scroll the page. Set `data-bs-zoom-on-scroll="false"` to keep the wheel for the page. On a touch screen a finger on the map moves the map, not the page, so leave some room around a large map. +## Selecting countries + +Set `data-bs-select` to let users pick countries with a click. With `single` a click on another country moves the selection. With `multiple` every click adds a country. A click on a selected country takes it out again. Pass the countries that start selected in `data-bs-selected`. + + +
+
+ +A selected country gets an outline and keeps the color of its value. Change the outline with `--tblr-vector-map-selected-stroke` and `--tblr-vector-map-selected-stroke-width`. + +The map fires `selected.bs.vector-map` after every change. Read the selection from the event, or with `getSelected()`: + +```js +const element = document.getElementById('compare'); + +element.addEventListener('selected.bs.vector-map', (event) => { + console.log(event.selected); // ['PL', 'FR', 'BR', 'DE'] +}); + +tabler.VectorMap.getInstance(element).setSelected(['US', 'CA']); +``` + +## Clicks + +The map fires `regionclick.bs.vector-map` when a country is clicked and `markerclick.bs.vector-map` when a marker is clicked, also when nothing can be selected. Use them to open a page or to filter a table. A drag on a zoomable map is not a click. + +```js +element.addEventListener('regionclick.bs.vector-map', (event) => { + console.log(event.code, event.name, event.value); // 'PL', 'Poland', 54 +}); + +element.addEventListener('markerclick.bs.vector-map', (event) => { + console.log(event.name, event.lat, event.lng); +}); +``` + +Call `event.preventDefault()` in `regionclick.bs.vector-map` to keep that click from changing the selection. + ## Tooltip A tooltip shows the name of the country or marker under the pointer, with the value when the country has one. Set `data-bs-tooltip="false"` to turn it off. @@ -198,6 +236,8 @@ Set options with data attributes or pass them to the constructor. For a data att | `lines` | array | `[]` | Connections as `{ from, to }`, by marker name. | | `tooltip` | boolean, function | `true` | Shows a tooltip, or builds its text. | | `legend` | boolean, function | `false` | Shows the scale under the map, or formats its two numbers. | +| `select` | string | `'none'` | Lets users select countries: `'none'`, `'single'` or `'multiple'`. | +| `selected` | array | `[]` | Codes of the countries that start selected. | | `zoom` | boolean | `false` | Lets users zoom and drag the map. | | `zoomMax` | number | `8` | The largest magnification. | | `zoomOnScroll` | boolean | `true` | Zooms with the mouse wheel. Needs `zoom`. | @@ -229,6 +269,8 @@ visits.update({ PL: 61, DE: 77, FR: 70 }); | --- | --- | | `update(values)` | Shades the regions with new values. Fires `updated.bs.vector-map`. | | `render()` | Reads the attributes again and draws the map from the start. Fires `rendered.bs.vector-map`. | +| `getSelected()` | Returns the codes of the selected countries. | +| `setSelected(codes)` | Selects exactly these countries. Fires `selected.bs.vector-map`. | | `zoomIn()` | Zooms in one step, around the middle of the view. | | `zoomOut()` | Zooms out one step. | | `reset()` | Goes back to the whole map. | @@ -241,6 +283,9 @@ visits.update({ PL: 61, DE: 77, FR: 70 }); | --- | --- | | `rendered.bs.vector-map` | Fired on the element after every render. | | `updated.bs.vector-map` | Fired on the element after `update()`. | +| `regionclick.bs.vector-map` | Fired when a country is clicked. Has `code`, `name`, `value` when the country has one, and the element in `relatedTarget`. Can be prevented. | +| `markerclick.bs.vector-map` | Fired when a marker is clicked. Has `name`, `lat`, `lng` and the element in `relatedTarget`. | +| `selected.bs.vector-map` | Fired after the selection changes. Has the codes in `selected`, and the clicked `code` when a click made the change. | ## Styling a single country @@ -266,6 +311,8 @@ The map reads its colors and sizes from custom properties on the element. Change | `--tblr-vector-map-color-1` to `-5` | none | Colors of a scale, from the lowest value to the highest. | | `--tblr-vector-map-colors` | none | Number of colors in the scale. | | `--tblr-vector-map-point-size` | `0.375rem` | Diameter of the dot of a small country. | +| `--tblr-vector-map-selected-stroke` | `var(--tblr-body-color)` | Outline of a selected country. | +| `--tblr-vector-map-selected-stroke-width` | `2px` | Width of that outline. | | `--tblr-vector-map-marker-size` | `0.5rem` | Diameter of a marker. | | `--tblr-vector-map-marker-color` | `var(--tblr-vector-map-color)` | Color of a marker. | | `--tblr-vector-map-marker-halo-color` | `var(--tblr-bg-surface)` | Color of the ring around a marker and around the dot of a small country. | @@ -308,6 +355,7 @@ Three territories have no ISO code. The map uses `XK` for Kosovo, `XC` for North - The data behind the map has to be reachable another way. Put a table of the same values next to or below the map. It serves screen reader users and anyone who prints the page. - The tooltip is for the pointer only. Do not put anything there that is not in the text version too. - Shades of one color are hard to tell apart for many users. Turn on the legend, and for exact numbers add a table. +- Countries and markers can be clicked with a pointer only. They are not in the tab order, because a world map would be two hundred tab stops. When a click or a selection does something, offer the same choice in another control, such as a list of checkboxes or a select, and keep the two in step with `setSelected()`. - The zoom buttons work with the keyboard and have accessible names. Change the names with `data-bs-zoom-in-label` and `data-bs-zoom-out-label` when the page is not in English. ## Variables diff --git a/preview/pages/maps-vector.astro b/preview/pages/maps-vector.astro index cdd7468de..a02c9344f 100644 --- a/preview/pages/maps-vector.astro +++ b/preview/pages/maps-vector.astro @@ -42,6 +42,14 @@ import DocsLink from '@ui/DocsLink.astro' +
+ + + Map with selection + + + +
diff --git a/shared/components/demo/MapVector.astro b/shared/components/demo/MapVector.astro index e21e28663..d8b154d01 100644 --- a/shared/components/demo/MapVector.astro +++ b/shared/components/demo/MapVector.astro @@ -8,6 +8,8 @@ interface MapData { zoom?: boolean colors?: string[] legend?: boolean + select?: 'single' | 'multiple' + selected?: string[] values?: Record markers?: { name: string; lat: number; lng: number }[] lines?: { from: string; to: string }[] @@ -36,6 +38,8 @@ const legend = legendProp ?? data?.legend ?? false data-bs-label={data.title} data-bs-zoom={data.zoom ? 'true' : undefined} data-bs-legend={legend ? 'true' : undefined} + data-bs-select={data.select} + data-bs-selected={data.selected && JSON.stringify(data.selected)} data-bs-colors={data.colors && JSON.stringify(data.colors)} data-bs-values={data.values && JSON.stringify(data.values)} data-bs-markers={data.markers && JSON.stringify(data.markers)} diff --git a/shared/data/maps-vector.json b/shared/data/maps-vector.json index 28c176c47..25733348b 100644 --- a/shared/data/maps-vector.json +++ b/shared/data/maps-vector.json @@ -534,5 +534,11 @@ "ZW": 1 }, "legend": true + }, + "world-select": { + "title": "Map with selection", + "map": "world", + "select": "multiple", + "selected": ["PL", "FR", "BR", "AU"] } }