diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css
index 0f7c3d3..47793b7 100644
--- a/src/public/borne/assets/css/style.css
+++ b/src/public/borne/assets/css/style.css
@@ -989,6 +989,42 @@ button {
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 {
from { opacity: 0; }
to { opacity: 1; }
diff --git a/src/public/borne/assets/js/confirm-modal.js b/src/public/borne/assets/js/confirm-modal.js
new file mode 100644
index 0000000..e7a834c
--- /dev/null
+++ b/src/public/borne/assets/js/confirm-modal.js
@@ -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 = `
+
+
${escHtml(message)}
+
+
+
+
+
+ `;
+
+ 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 };
+}
diff --git a/src/public/borne/assets/js/order-panel.js b/src/public/borne/assets/js/order-panel.js
index 9869ba0..2575853 100644
--- a/src/public/borne/assets/js/order-panel.js
+++ b/src/public/borne/assets/js/order-panel.js
@@ -20,6 +20,7 @@ import {
getMode,
} from './state.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 ;
@@ -206,9 +207,18 @@ export function renderOrderPanel(container) {
const abandon = container.querySelector('.order-panel__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', () => {
- clearCart();
- window.location.href = 'index.html';
+ confirmAction({
+ message: 'Abandonner toute la commande ? Votre selection sera perdue.',
+ confirmLabel: 'Oui, abandonner',
+ cancelLabel: 'Continuer ma commande',
+ onConfirm: () => {
+ clearCart();
+ window.location.href = 'index.html';
+ },
+ });
});
}
diff --git a/tests/js/confirm-modal.test.js b/tests/js/confirm-modal.test.js
new file mode 100644
index 0000000..3e514f2
--- /dev/null
+++ b/tests/js/confirm-modal.test.js
@@ -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('', { 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: '
', onConfirm: () => {} });
+ assert.equal(document.querySelectorAll('img[onerror]').length, 0);
+});
diff --git a/tests/js/order-panel.test.js b/tests/js/order-panel.test.js
index a71178c..a288963 100644
--- a/tests/js/order-panel.test.js
+++ b/tests/js/order-panel.test.js
@@ -22,6 +22,7 @@ before(async () => {
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
+ global.requestAnimationFrame = (cb) => cb();
({ lineCents, compositionLabels, buildPanelModel, renderOrderPanel } =
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);
});
+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)', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ libelle: '
' })]));
const el = document.createElement('aside');