fix(borne): confirmation avant Abandon de la commande (#102)
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / php-lint (push) Successful in 47s
CI / static-tests (push) Successful in 1m8s
CI / js-tests (push) Successful in 34s

This commit is contained in:
Corentin JOGUET 2026-06-24 12:29:32 +02:00
parent 6bf3597b5e
commit 3c53908952
5 changed files with 203 additions and 2 deletions

View file

@ -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; }

View 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 };
}

View file

@ -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';
},
});
}); });
} }

View 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);
});

View file

@ -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');