a11y: use role=alert for messages from Django and JS

Also define a custom module+css for alerts (chore).
This commit is contained in:
David Larlet 2024-02-20 22:17:44 -05:00
parent 76ed2200cf
commit d6e4da3f40
No known key found for this signature in database
GPG key ID: 3E2953A359E7E7BD
15 changed files with 168 additions and 84 deletions

View file

@ -0,0 +1,34 @@
[data-alert] {
box-sizing: border-box;
min-height: 46px;
line-height: 46px;
padding-left: 10px;
width: calc(100% - 500px);
position: absolute;
left: 250px; /* Keep save/cancel button accessible. */
right: 250px;
box-shadow: 0 1px 7px #999999;
background: none repeat scroll 0 0 rgba(20, 22, 23, 0.8);
font-weight: bold;
color: #fff;
font-size: 0.8em;
z-index: 1012;
border-radius: 2px;
visibility: visible;
top: 23px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
[data-alert][data-level="error"] {
background-color: #c60f13;
}
[data-alert] [data-close] {
color: #fff;
padding-right: 10px;
width: 100px;
line-height: 1;
margin: .5rem;
background-color: #202425;
font-size: .7rem;
}

View file

@ -0,0 +1,54 @@
export default class Alerts {
constructor() {
this.alertNode = document.querySelector('[role="alert"]')
const observer = new MutationObserver(this._callback.bind(this))
observer.observe(this.alertNode, { childList: true })
// On initial page load, we want to display messages from Django.
Array.from(this.alertNode.children).forEach(this._display.bind(this))
}
_callback(mutationList, observer) {
for (const mutation of mutationList) {
this._display(
[...mutation.addedNodes].filter((item) => item.tagName === 'P').pop()
)
}
}
_display(alert) {
const duration = alert.dataset?.duration || 3000
const level = alert.dataset?.level || 'info'
const wrapper = document.createElement('div')
const alertHTML = alert.cloneNode(true).outerHTML
wrapper.innerHTML = `
<div data-level="${level}" data-alert data-toclose>
${alertHTML}
<button class="umap-close-link" type="button" data-close>
<i class="umap-close-icon"></i><span>${L._('Close')}</span>
</button>
</div>
`
const alertDiv = wrapper.firstElementChild
this.alertNode.after(alertDiv)
if (isFinite(duration)) {
setTimeout(() => {
alertDiv.remove()
}, duration)
}
}
add(message, level = 'info', duration = 3000) {
this.alertNode.innerHTML = `
<p data-level="${level}" data-duration="${duration}">
${message}
</p>
`
}
}
// TODISCUSS: this might be something we want somewhere else.
document.addEventListener('click', (event) => {
if (event.target.closest('[data-close]')) {
event.target.closest('[data-toclose]').remove()
}
})

View file

@ -1,5 +1,6 @@
import * as L from '../../vendors/leaflet/leaflet-src.esm.js' import * as L from '../../vendors/leaflet/leaflet-src.esm.js'
import URLs from './urls.js' import URLs from './urls.js'
import Alerts from './alerts.js'
import Browser from './browser.js' import Browser from './browser.js'
import { Request, ServerRequest, RequestError, HTTPError, NOKError } from './request.js' import { Request, ServerRequest, RequestError, HTTPError, NOKError } from './request.js'
// Import modules and export them to the global scope. // Import modules and export them to the global scope.
@ -7,4 +8,13 @@ import { Request, ServerRequest, RequestError, HTTPError, NOKError } from './req
// Copy the leaflet module, it's expected by leaflet plugins to be writeable. // Copy the leaflet module, it's expected by leaflet plugins to be writeable.
window.L = { ...L } window.L = { ...L }
window.U = { URLs, Request, ServerRequest, RequestError, HTTPError, NOKError, Browser } window.U = {
Alerts,
URLs,
Request,
ServerRequest,
RequestError,
HTTPError,
NOKError,
Browser,
}

View file

@ -1,5 +1,6 @@
// Uses `L._`` from Leaflet.i18n which we cannot import as a module yet // Uses `L._`` from Leaflet.i18n which we cannot import as a module yet
import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
import Alert from './alerts.js'
export class RequestError extends Error {} export class RequestError extends Error {}
@ -50,6 +51,7 @@ export class Request extends BaseRequest {
constructor(ui) { constructor(ui) {
super() super()
this.ui = ui this.ui = ui
this.alerts = new Alert()
} }
async _fetch(method, uri, headers, data) { async _fetch(method, uri, headers, data) {
@ -81,7 +83,7 @@ export class Request extends BaseRequest {
} }
_onError(error) { _onError(error) {
this.ui.alert({ content: L._('Problem in the response'), level: 'error' }) this.alerts.add(L._('Problem in the response'), 'error')
} }
_onNOK(error) { _onNOK(error) {
@ -127,10 +129,10 @@ export class ServerRequest extends Request {
try { try {
const data = await response.json() const data = await response.json()
if (data.info) { if (data.info) {
this.ui.alert({ content: data.info, level: 'info' }) this.alerts.add(data.info)
this.ui.closePanel() this.ui.closePanel()
} else if (data.error) { } else if (data.error) {
this.ui.alert({ content: data.error, level: 'error' }) this.alerts.add(data.error, 'error')
return this._onError(new Error(data.error)) return this._onError(new Error(data.error))
} }
return [data, response, null] return [data, response, null]
@ -145,10 +147,7 @@ export class ServerRequest extends Request {
_onNOK(error) { _onNOK(error) {
if (error.status === 403) { if (error.status === 403) {
this.ui.alert({ this.alerts.add(message || L._('Action not allowed :('), 'error')
content: message || L._('Action not allowed :('),
level: 'error',
})
} }
return [{}, error.response, error] return [{}, error.response, error]
} }

View file

@ -782,8 +782,7 @@ const ControlsMixin = {
if (datalayer.hasDataVisible()) found = true if (datalayer.hasDataVisible()) found = true
}) })
// TODO: display a results counter in the panel instead. // TODO: display a results counter in the panel instead.
if (!found) if (!found) this.alerts.add(L._('No results for these facets'))
this.ui.alert({ content: L._('No results for these facets'), level: 'info' })
} }
const fields = keys.map((current) => [ const fields = keys.map((current) => [
@ -1272,7 +1271,7 @@ U.Search = L.PhotonSearch.extend({
if (latlng.isValid()) { if (latlng.isValid()) {
this.reverse.doReverse(latlng) this.reverse.doReverse(latlng)
} else { } else {
this.map.ui.alert({ content: 'Invalid latitude or longitude', mode: 'error' }) this.map.alerts.add(L._('Invalid latitude or longitude'), 'error')
} }
return return
} }

View file

@ -686,10 +686,7 @@ U.Marker = L.Marker.extend({
const builder = new U.FormBuilder(this, coordinatesOptions, { const builder = new U.FormBuilder(this, coordinatesOptions, {
callback: function () { callback: function () {
if (!this._latlng.isValid()) { if (!this._latlng.isValid()) {
this.map.ui.alert({ this.map.alerts.add(L._('Invalid latitude or longitude'), 'error')
content: L._('Invalid latitude or longitude'),
level: 'error',
})
builder.resetField('_latlng.lat') builder.resetField('_latlng.lat')
builder.resetField('_latlng.lng') builder.resetField('_latlng.lng')
} }
@ -886,7 +883,7 @@ U.PathMixin = {
items.push({ items.push({
text: L._('Display measure'), text: L._('Display measure'),
callback: function () { callback: function () {
this.map.ui.alert({ content: this.getMeasure(), level: 'info' }) this.map.alerts.add(this.getMeasure())
}, },
context: this, context: this,
}) })

View file

@ -140,16 +140,15 @@ U.Importer = L.Class.extend({
this.map.processFileToImport(file, layer, type) this.map.processFileToImport(file, layer, type)
} }
} else { } else {
if (!type) if (!type) {
return this.map.ui.alert({ this.map.alerts.add(L._('Please choose a format'), 'error')
content: L._('Please choose a format'), return
level: 'error', }
})
if (this.rawInput.value && type === 'umap') { if (this.rawInput.value && type === 'umap') {
try { try {
this.map.importRaw(this.rawInput.value, type) this.map.importRaw(this.rawInput.value, type)
} catch (e) { } catch (e) {
this.ui.alert({ content: L._('Invalid umap data'), level: 'error' }) this.alerts.add(L._('Invalid umap data'), 'error')
console.error(e) console.error(e)
} }
} else { } else {

View file

@ -144,6 +144,7 @@ U.Map = L.Map.extend({
// After calling parent initialize, as we are doing initCenter our-selves // After calling parent initialize, as we are doing initCenter our-selves
if (geojson.geometry) this.options.center = this.latLng(geojson.geometry) if (geojson.geometry) this.options.center = this.latLng(geojson.geometry)
this.urls = new U.URLs(this.options.urls) this.urls = new U.URLs(this.options.urls)
this.alerts = new U.Alerts()
this.ui = new U.UI(this._container) this.ui = new U.UI(this._container)
this.ui.on('dataloading', (e) => this.fire('dataloading', e)) this.ui.on('dataloading', (e) => this.fire('dataloading', e))
@ -393,7 +394,9 @@ U.Map = L.Map.extend({
icon: 'umap-fake-class', icon: 'umap-fake-class',
iconLoading: 'umap-fake-class', iconLoading: 'umap-fake-class',
flyTo: this.options.easing, flyTo: this.options.easing,
onLocationError: (err) => this.ui.alert({ content: err.message }), onLocationError: (err) => {
this.alerts.add(err.message, 'error')
},
}) })
this._controls.fullscreen = new L.Control.Fullscreen({ this._controls.fullscreen = new L.Control.Fullscreen({
title: { false: L._('View Fullscreen'), true: L._('Exit Fullscreen') }, title: { false: L._('View Fullscreen'), true: L._('Exit Fullscreen') },
@ -677,10 +680,10 @@ U.Map = L.Map.extend({
} catch (e) { } catch (e) {
console.error(e) console.error(e)
this.removeLayer(tilelayer) this.removeLayer(tilelayer)
this.ui.alert({ this.alerts.add(
content: `${L._('Error in the tilelayer URL')}: ${tilelayer._url}`, `${L._('Error in the tilelayer URL')}: ${tilelayer._url}`,
level: 'error', 'error'
}) )
// Users can put tilelayer URLs by hand, and if they add wrong {variable}, // Users can put tilelayer URLs by hand, and if they add wrong {variable},
// Leaflet throw an error, and then the map is no more editable // Leaflet throw an error, and then the map is no more editable
} }
@ -712,10 +715,7 @@ U.Map = L.Map.extend({
} catch (e) { } catch (e) {
this.removeLayer(overlay) this.removeLayer(overlay)
console.error(e) console.error(e)
this.ui.alert({ this.alerts.add(`${L._('Error in the overlay URL')}: ${overlay._url}`, 'error')
content: `${L._('Error in the overlay URL')}: ${overlay._url}`,
level: 'error',
})
} }
}, },
@ -842,10 +842,7 @@ U.Map = L.Map.extend({
if (this.options.umap_id) { if (this.options.umap_id) {
// We do not want an extra message during the map creation // We do not want an extra message during the map creation
// to avoid the double notification/alert. // to avoid the double notification/alert.
this.ui.alert({ this.alerts.add(L._('The zoom and center have been modified.'))
content: L._('The zoom and center have been modified.'),
level: 'info',
})
} }
}, },
@ -889,12 +886,12 @@ U.Map = L.Map.extend({
processFileToImport: function (file, layer, type) { processFileToImport: function (file, layer, type) {
type = type || L.Util.detectFileType(file) type = type || L.Util.detectFileType(file)
if (!type) { if (!type) {
this.ui.alert({ this.alerts.add(
content: L._('Unable to detect format of file {filename}', { L._('Unable to detect format of file {filename}', {
filename: file.name, filename: file.name,
}), }),
level: 'error', 'error'
}) )
return return
} }
if (type === 'umap') { if (type === 'umap') {
@ -946,10 +943,10 @@ U.Map = L.Map.extend({
self.importRaw(rawData) self.importRaw(rawData)
} catch (e) { } catch (e) {
console.error('Error importing data', e) console.error('Error importing data', e)
self.ui.alert({ self.alerts.add(
content: L._('Invalid umap data in {filename}', { filename: file.name }), L._('Invalid umap data in {filename}', { filename: file.name }),
level: 'error', 'error'
}) )
} }
} }
}, },
@ -1060,10 +1057,10 @@ U.Map = L.Map.extend({
const uri = this.urls.get('map_save', { map_id: this.options.umap_id }) const uri = this.urls.get('map_save', { map_id: this.options.umap_id })
const [data, response, error] = await this.server.post(uri, {}, formData) const [data, response, error] = await this.server.post(uri, {}, formData)
if (!error) { if (!error) {
let duration = 3000, let alertDuration = 3000
alert = { content: L._('Map has been saved!'), level: 'info' } let alertMessage = L._('Map has been saved!')
if (!this.options.umap_id) { if (!this.options.umap_id) {
alert.content = L._('Congratulations, your map has been created!') alertMessage = L._('Congratulations, your map has been created!')
this.options.umap_id = data.id this.options.umap_id = data.id
this.permissions.setOptions(data.permissions) this.permissions.setOptions(data.permissions)
this.permissions.commit() this.permissions.commit()
@ -1072,8 +1069,8 @@ U.Map = L.Map.extend({
data.permissions.anonymous_edit_url && data.permissions.anonymous_edit_url &&
this.options.urls.map_send_edit_link this.options.urls.map_send_edit_link
) { ) {
alert.duration = Infinity alertDuration = Infinity
alert.content = alertMessage =
L._( L._(
'Your map has been created! As you are not logged in, here is your secret link to edit the map, please keep it safe:' 'Your map has been created! As you are not logged in, here is your secret link to edit the map, please keep it safe:'
) + `<br>${data.permissions.anonymous_edit_url}` ) + `<br>${data.permissions.anonymous_edit_url}`
@ -1108,8 +1105,9 @@ U.Map = L.Map.extend({
if (history && history.pushState) if (history && history.pushState)
history.pushState({}, this.options.name, data.url) history.pushState({}, this.options.name, data.url)
else window.location = data.url else window.location = data.url
alert.content = data.info || alert.content this.once('saved', () => {
this.once('saved', () => this.ui.alert(alert)) this.alerts.add(data.info || alertMessage, 'info', alertDuration)
})
this.ui.closePanel() this.ui.closePanel()
this.permissions.save() this.permissions.save()
} }
@ -1142,11 +1140,10 @@ U.Map = L.Map.extend({
}, },
star: async function () { star: async function () {
if (!this.options.umap_id) if (!this.options.umap_id) {
return this.ui.alert({ this.alerts.add(L._('Please save the map first'), 'error')
content: L._('Please save the map first'), return
level: 'error', }
})
const url = this.urls.get('map_star', { map_id: this.options.umap_id }) const url = this.urls.get('map_star', { map_id: this.options.umap_id })
const [data, response, error] = await this.server.post(url) const [data, response, error] = await this.server.post(url)
if (!error) { if (!error) {
@ -1154,7 +1151,7 @@ U.Map = L.Map.extend({
let msg = data.starred let msg = data.starred
? L._('Map has been starred') ? L._('Map has been starred')
: L._('Map has been unstarred') : L._('Map has been unstarred')
this.ui.alert({ content: msg, level: 'info' }) this.alerts.add(msg)
this.renderControls() this.renderControls()
} }
}, },

View file

@ -928,7 +928,7 @@ U.DataLayer = L.Evented.extend({
message: err[0].message, message: err[0].message,
}) })
} }
this.map.ui.alert({ content: message, level: 'error', duration: 10000 }) this.map.alerts.add(message, 'error', 10000)
console.error(err) console.error(err)
} }
if (result && result.features.length) { if (result && result.features.length) {
@ -955,7 +955,7 @@ U.DataLayer = L.Evented.extend({
const gj = JSON.parse(c) const gj = JSON.parse(c)
callback(gj) callback(gj)
} catch (err) { } catch (err) {
this.map.ui.alert({ content: `Invalid JSON file: ${err}` }) this.map.alerts.add(L._('Invalid JSON file: {error}', { error: err }), 'error')
return return
} }
} }
@ -1013,12 +1013,12 @@ U.DataLayer = L.Evented.extend({
return this.geojsonToFeatures(geometry.geometries) return this.geojsonToFeatures(geometry.geometries)
default: default:
this.map.ui.alert({ this.map.alerts.add(
content: L._('Skipping unknown geometry.type: {type}', { L._('Skipping unknown geometry.type: {type}', {
type: geometry.type || 'undefined', type: geometry.type || 'undefined',
}), }),
level: 'error', 'error'
}) )
} }
if (layer) { if (layer) {
this.addLayer(layer) this.addLayer(layer)
@ -1459,7 +1459,7 @@ U.DataLayer = L.Evented.extend({
if (!this.isVisible()) return if (!this.isVisible()) return
const bounds = this.layer.getBounds() const bounds = this.layer.getBounds()
if (bounds.isValid()) { if (bounds.isValid()) {
const options = {maxZoom: this.getOption("zoomTo")} const options = { maxZoom: this.getOption('zoomTo') }
this.map.fitBounds(bounds, options) this.map.fitBounds(bounds, options)
} }
}, },

View file

@ -53,10 +53,7 @@ U.MapPermissions = L.Class.extend({
edit: function () { edit: function () {
if (this.map.options.editMode !== 'advanced') return if (this.map.options.editMode !== 'advanced') return
if (!this.map.options.umap_id) if (!this.map.options.umap_id)
return this.map.ui.alert({ return this.map.alerts.add(L._('Please save the map first'))
content: L._('Please save the map first'),
level: 'info',
})
const container = L.DomUtil.create('div', 'permissions-panel'), const container = L.DomUtil.create('div', 'permissions-panel'),
fields = [], fields = [],
title = L.DomUtil.create('h3', '', container) title = L.DomUtil.create('h3', '', container)
@ -135,10 +132,7 @@ U.MapPermissions = L.Class.extend({
const [data, response, error] = await this.map.server.post(this.getAttachUrl()) const [data, response, error] = await this.map.server.post(this.getAttachUrl())
if (!error) { if (!error) {
this.options.owner = this.map.options.user this.options.owner = this.map.options.user
this.map.ui.alert({ this.map.alerts.add(L._('Map has been attached to your account'))
content: L._('Map has been attached to your account'),
level: 'info',
})
this.map.ui.closePanel() this.map.ui.closePanel()
} }
}, },

View file

@ -83,10 +83,10 @@ U.TableEditor = L.Class.extend({
validateName: function (name) { validateName: function (name) {
if (name.indexOf('.') !== -1) { if (name.indexOf('.') !== -1) {
this.datalayer.map.ui.alert({ this.datalayer.map.alerts.add(
content: L._('Invalide property name: {name}', { name: name }), L._('Invalid property name: {name}', { name: name }),
level: 'error', 'error'
}) )
return false return false
} }
return true return true

View file

@ -64,6 +64,7 @@
<link rel="stylesheet" href="../../umap/font.css" /> <link rel="stylesheet" href="../../umap/font.css" />
<link rel="stylesheet" href="../../umap/base.css" /> <link rel="stylesheet" href="../../umap/base.css" />
<link rel="stylesheet" href="../../umap/alerts.css" />
<link rel="stylesheet" href="../../umap/content.css" /> <link rel="stylesheet" href="../../umap/content.css" />
<link rel="stylesheet" href="../../umap/nav.css" /> <link rel="stylesheet" href="../../umap/nav.css" />
<link rel="stylesheet" href="../../umap/map.css" /> <link rel="stylesheet" href="../../umap/map.css" />

View file

@ -24,6 +24,7 @@
href="{% static 'umap/vendors/iconlayers/iconLayers.css' %}" /> href="{% static 'umap/vendors/iconlayers/iconLayers.css' %}" />
<link rel="stylesheet" href="{% static 'umap/font.css' %}" /> <link rel="stylesheet" href="{% static 'umap/font.css' %}" />
<link rel="stylesheet" href="{% static 'umap/base.css' %}" /> <link rel="stylesheet" href="{% static 'umap/base.css' %}" />
<link rel="stylesheet" href="{% static 'umap/alerts.css' %}" />
<link rel="stylesheet" href="{% static 'umap/content.css' %}" /> <link rel="stylesheet" href="{% static 'umap/content.css' %}" />
<link rel="stylesheet" href="{% static 'umap/nav.css' %}" /> <link rel="stylesheet" href="{% static 'umap/nav.css' %}" />
<link rel="stylesheet" href="{% static 'umap/map.css' %}" /> <link rel="stylesheet" href="{% static 'umap/map.css' %}" />

View file

@ -1,17 +1,16 @@
{% load umap_tags %} {% load umap_tags %}
<div role="alert">
{% for m in messages %}
{# We have just one, but we need to loop, as for messages API #}
<p data-level="{{ m.tags }}" data-duration="100000">{{ m }}</p>
{% endfor %}
</div>
<div id="map"></div> <div id="map"></div>
<!-- djlint:off --> <!-- djlint:off -->
<script defer type="text/javascript"> <script defer type="text/javascript">
window.addEventListener('DOMContentLoaded', (event) => { window.addEventListener('DOMContentLoaded', (event) => {
U.MAP = new U.Map("map", {{ map_settings|notag|safe }}) U.MAP = new U.Map("map", {{ map_settings|notag|safe }})
{% for m in messages %}
{# We have just one, but we need to loop, as for messages API #}
U.MAP.ui.alert({
content: "{{ m }}",
level: "{{ m.tags }}",
duration: 100000
})
{% endfor %}
}) })
</script> </script>
<!-- djlint:on --> <!-- djlint:on -->

View file

@ -1,5 +1,5 @@
<div class="wrapper"> <div class="wrapper">
<div class="row"> <div class="row" role="alert">
{% if messages %} {% if messages %}
<ul class="messages"> <ul class="messages">
{% for message in messages %} {% for message in messages %}