fix(borne): confirmation avant Abandon de la commande (#102)
This commit is contained in:
parent
6bf3597b5e
commit
3c53908952
5 changed files with 203 additions and 2 deletions
|
|
@ -989,6 +989,42 @@ button {
|
||||||
animation: composer-fade-in var(--transition-base) both;
|
animation: composer-fade-in var(--transition-base) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modale de confirmation d'un geste destructeur (Abandon). */
|
||||||
|
.confirm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
z-index: 210;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
animation: composer-fade-in var(--transition-base) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-overlay);
|
||||||
|
padding: var(--space-6);
|
||||||
|
max-width: 28rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal__message {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes composer-fade-in {
|
@keyframes composer-fade-in {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
|
|
|
||||||
73
src/public/borne/assets/js/confirm-modal.js
Normal file
73
src/public/borne/assets/js/confirm-modal.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* confirm-modal.js — Modale de confirmation reutilisable pour un geste destructeur
|
||||||
|
* (ex. Abandon de toute la commande). CSP-safe (createElement + addEventListener,
|
||||||
|
* aucun handler inline). Accessible : role="dialog" aria-modal, fond mis aria-hidden,
|
||||||
|
* Echap et clic sur le fond = annuler, focus piege dans la modale et rendu au
|
||||||
|
* declencheur a la fermeture. Defaut sur Annuler : un appui accidentel sur Entree
|
||||||
|
* n'execute pas l'action destructrice. Public non-technique : message + 2 boutons clairs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escHtml } from './state.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche une demande de confirmation. onConfirm n'est appele que si l'utilisateur
|
||||||
|
* confirme explicitement ; Annuler / Echap / clic-fond ferment sans rien faire.
|
||||||
|
* @param {{message:string, confirmLabel?:string, cancelLabel?:string, onConfirm:Function}} opts
|
||||||
|
* @returns {{close:Function}}
|
||||||
|
*/
|
||||||
|
export function confirmAction({ message, confirmLabel = 'Confirmer', cancelLabel = 'Annuler', onConfirm }) {
|
||||||
|
const previouslyFocused = document.activeElement;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'confirm-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-msg">
|
||||||
|
<p class="confirm-modal__message" id="confirm-modal-msg">${escHtml(message)}</p>
|
||||||
|
<div class="confirm-modal__actions">
|
||||||
|
<button type="button" class="confirm-modal__cancel btn btn--secondary">${escHtml(cancelLabel)}</button>
|
||||||
|
<button type="button" class="confirm-modal__confirm btn btn--primary">${escHtml(confirmLabel)}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
const bgSiblings = Array.from(document.body.children).filter(el => el !== overlay);
|
||||||
|
bgSiblings.forEach(el => el.setAttribute('aria-hidden', 'true'));
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
bgSiblings.forEach(el => el.removeAttribute('aria-hidden'));
|
||||||
|
overlay.remove();
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Echap = annuler ; Tab/Shift+Tab pieges sur les boutons de la modale.
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') { close(); return; }
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
const focusable = Array.from(overlay.querySelectorAll('button:not([disabled])'));
|
||||||
|
if (!focusable.length) return;
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||||
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelector('.confirm-modal__cancel').addEventListener('click', close);
|
||||||
|
overlay.querySelector('.confirm-modal__confirm').addEventListener('click', () => {
|
||||||
|
close();
|
||||||
|
onConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus initial sur Annuler (defaut sur), pour ne pas confirmer par inadvertance.
|
||||||
|
requestAnimationFrame(() => overlay.querySelector('.confirm-modal__cancel').focus());
|
||||||
|
|
||||||
|
return { close };
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
getMode,
|
getMode,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
import { refreshCartBadge } from './nav.js';
|
import { refreshCartBadge } from './nav.js';
|
||||||
|
import { confirmAction } from './confirm-modal.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcule le total d'une ligne en centimes (menu : avec supplement de taille ;
|
* Calcule le total d'une ligne en centimes (menu : avec supplement de taille ;
|
||||||
|
|
@ -206,9 +207,18 @@ export function renderOrderPanel(container) {
|
||||||
|
|
||||||
const abandon = container.querySelector('.order-panel__abandon');
|
const abandon = container.querySelector('.order-panel__abandon');
|
||||||
if (abandon) {
|
if (abandon) {
|
||||||
|
// Geste destructeur (efface toute la commande) -> confirmation explicite
|
||||||
|
// avant d'agir, plutot qu'un effacement immediat au moindre tap.
|
||||||
abandon.addEventListener('click', () => {
|
abandon.addEventListener('click', () => {
|
||||||
clearCart();
|
confirmAction({
|
||||||
window.location.href = 'index.html';
|
message: 'Abandonner toute la commande ? Votre selection sera perdue.',
|
||||||
|
confirmLabel: 'Oui, abandonner',
|
||||||
|
cancelLabel: 'Continuer ma commande',
|
||||||
|
onConfirm: () => {
|
||||||
|
clearCart();
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
67
tests/js/confirm-modal.test.js
Normal file
67
tests/js/confirm-modal.test.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/*
|
||||||
|
* Tests de confirm-modal.js (node:test + jsdom). Modale de confirmation d'un geste
|
||||||
|
* destructeur : onConfirm n'est appele QUE sur confirmation explicite ; Annuler /
|
||||||
|
* Echap / clic-fond ferment sans agir. Import dynamique apres pose des globals jsdom.
|
||||||
|
*/
|
||||||
|
import { test, before, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
let confirmAction;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/products.html' });
|
||||||
|
global.window = dom.window;
|
||||||
|
global.document = dom.window.document;
|
||||||
|
global.localStorage = dom.window.localStorage;
|
||||||
|
global.requestAnimationFrame = (cb) => cb();
|
||||||
|
({ confirmAction } = await import('../../src/public/borne/assets/js/confirm-modal.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => { document.body.innerHTML = ''; });
|
||||||
|
|
||||||
|
test('confirmAction: affiche une modale role=dialog avec le message', () => {
|
||||||
|
confirmAction({ message: 'Abandonner ?', onConfirm: () => {} });
|
||||||
|
const modal = document.querySelector('.confirm-overlay .confirm-modal[role="dialog"]');
|
||||||
|
assert.ok(modal);
|
||||||
|
assert.equal(modal.getAttribute('aria-modal'), 'true');
|
||||||
|
assert.match(modal.querySelector('.confirm-modal__message').textContent, /Abandonner/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirmAction: Confirmer appelle onConfirm puis ferme', () => {
|
||||||
|
let called = 0;
|
||||||
|
confirmAction({ message: 'x', onConfirm: () => { called++; } });
|
||||||
|
document.querySelector('.confirm-modal__confirm').click();
|
||||||
|
assert.equal(called, 1);
|
||||||
|
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirmAction: Annuler ferme sans appeler onConfirm', () => {
|
||||||
|
let called = 0;
|
||||||
|
confirmAction({ message: 'x', onConfirm: () => { called++; } });
|
||||||
|
document.querySelector('.confirm-modal__cancel').click();
|
||||||
|
assert.equal(called, 0);
|
||||||
|
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirmAction: Echap ferme sans appeler onConfirm', () => {
|
||||||
|
let called = 0;
|
||||||
|
confirmAction({ message: 'x', onConfirm: () => { called++; } });
|
||||||
|
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
assert.equal(called, 0);
|
||||||
|
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirmAction: clic sur le fond ferme sans appeler onConfirm', () => {
|
||||||
|
let called = 0;
|
||||||
|
confirmAction({ message: 'x', onConfirm: () => { called++; } });
|
||||||
|
const overlay = document.querySelector('.confirm-overlay');
|
||||||
|
overlay.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
||||||
|
assert.equal(called, 0);
|
||||||
|
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirmAction: le message est echappe (anti-XSS)', () => {
|
||||||
|
confirmAction({ message: '<img src=x onerror=alert(1)>', onConfirm: () => {} });
|
||||||
|
assert.equal(document.querySelectorAll('img[onerror]').length, 0);
|
||||||
|
});
|
||||||
|
|
@ -22,6 +22,7 @@ before(async () => {
|
||||||
global.window = dom.window;
|
global.window = dom.window;
|
||||||
global.document = dom.window.document;
|
global.document = dom.window.document;
|
||||||
global.localStorage = dom.window.localStorage;
|
global.localStorage = dom.window.localStorage;
|
||||||
|
global.requestAnimationFrame = (cb) => cb();
|
||||||
({ lineCents, compositionLabels, buildPanelModel, renderOrderPanel } =
|
({ lineCents, compositionLabels, buildPanelModel, renderOrderPanel } =
|
||||||
await import('../../src/public/borne/assets/js/order-panel.js'));
|
await import('../../src/public/borne/assets/js/order-panel.js'));
|
||||||
});
|
});
|
||||||
|
|
@ -156,6 +157,20 @@ test('renderOrderPanel: stepper - a quantite 1 retire la ligne', () => {
|
||||||
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
|
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renderOrderPanel: Abandon demande confirmation avant d effacer', () => {
|
||||||
|
localStorage.setItem('wakdo_cart', JSON.stringify([simple()]));
|
||||||
|
const el = document.createElement('aside');
|
||||||
|
renderOrderPanel(el);
|
||||||
|
el.querySelector('.order-panel__abandon').click();
|
||||||
|
// Une modale de confirmation apparait ; le panier n'est PAS encore efface.
|
||||||
|
assert.ok(document.querySelector('.confirm-overlay'));
|
||||||
|
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
|
||||||
|
// Annuler conserve le panier et ferme la modale.
|
||||||
|
document.querySelector('.confirm-modal__cancel').click();
|
||||||
|
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||||
|
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('renderOrderPanel: libelle de ligne echappe (anti-XSS RG-T15)', () => {
|
test('renderOrderPanel: libelle de ligne echappe (anti-XSS RG-T15)', () => {
|
||||||
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ libelle: '<img src=x onerror=alert(1)>' })]));
|
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ libelle: '<img src=x onerror=alert(1)>' })]));
|
||||||
const el = document.createElement('aside');
|
const el = document.createElement('aside');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue