From ba660e7d5a9b552b4cbfc996668cc515ce37f270 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Wed, 24 Jun 2026 10:23:10 +0000 Subject: [PATCH] fix(borne): confirmation avant Abandon de la commande (geste destructeur) Le bouton Abandon effacait toute la commande au moindre tap. Ajout d'une modale de confirmation reutilisable (confirm-modal.js, CSP-safe : role=dialog, aria-modal, focus piege, Echap et clic-fond = annuler, focus rendu au declencheur, focus initial sur Annuler). Abandon passe par cette confirmation avant clearCart + retour accueil. La suppression d'une ligne reste immediate (re-ajoutable, et le stepper offre le decrement doux) ; les cibles tactiles 44px ont ete posees au lot panier-unique. Tests : confirm-modal.test.js (6 cas : affichage, confirmer, annuler, Echap, clic-fond, echappement) + order-panel (Abandon demande confirmation). JS 118 verts. --- src/public/borne/assets/css/style.css | 36 ++++++++++ src/public/borne/assets/js/confirm-modal.js | 73 +++++++++++++++++++++ src/public/borne/assets/js/order-panel.js | 14 +++- tests/js/confirm-modal.test.js | 67 +++++++++++++++++++ tests/js/order-panel.test.js | 15 +++++ 5 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 src/public/borne/assets/js/confirm-modal.js create mode 100644 tests/js/confirm-modal.test.js 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 = ` + + `; + + 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'); -- 2.45.3