From 87a6482c23990fccab4318fdbea5cea2d1b69764 Mon Sep 17 00:00:00 2001 From: 81reap Date: Fri, 30 Oct 2026 20:40:35 -0400 Subject: [PATCH] feat(button) :: add confirmation dialog Buttons now aceppt `confirm` to show a confirmation dialog. Fixes #831 Assisted-By :: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../sqlpage/migrations/18_button.sql | 8 +++- frontend/src/sqlpage.ts | 48 +++++++++++++++++++ sqlpage/templates/button.handlebars | 1 + .../fixtures/button/confirm/archived.sql | 1 + .../fixtures/button/confirm/deleted.sql | 1 + .../fixtures/button/confirm/index.sql | 6 +++ .../fixtures/button/confirm/test.ts | 27 +++++++++++ 8 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/end-to-end/fixtures/button/confirm/archived.sql create mode 100644 tests/end-to-end/fixtures/button/confirm/deleted.sql create mode 100644 tests/end-to-end/fixtures/button/confirm/index.sql create mode 100644 tests/end-to-end/fixtures/button/confirm/test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e850bcee..8c734043d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ - Each item of the tracking component accepts a `link`. ([#900](https://github.com/sqlpage/SQLPage/issues/900)) +- Each button of the button component accepts a `confirm` question, such as `'Delete this movie?' AS confirm`. ([#831](https://github.com/sqlpage/SQLPage/issues/831)) + - Custom components can call inline partials inside `each_row` and after it, when the partials are defined before `each_row`. ([#846](https://github.com/sqlpage/SQLPage/issues/846)) ## v0.47.0 (2026-10-15) diff --git a/examples/official-site/sqlpage/migrations/18_button.sql b/examples/official-site/sqlpage/migrations/18_button.sql index 6ddb39a48..ba0723f72 100644 --- a/examples/official-site/sqlpage/migrations/18_button.sql +++ b/examples/official-site/sqlpage/migrations/18_button.sql @@ -26,7 +26,8 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('rel', '"nofollow" when the contents of the target link are not endorsed, "noopener" when the target is not trusted, and "noreferrer" to hide where the user came from when they open the link.', 'TEXT', FALSE, TRUE), ('target', '"_blank" to open the link in a new tab, "_self" to open it in the same tab, "_parent" to open it in the parent frame, or "_top" to open it in the full body of the window.', 'TEXT', FALSE, TRUE), ('download', 'If defined, the link will download the target instead of navigating to it. Set the value to the desired name of the downloaded file.', 'TEXT', FALSE, TRUE), - ('id', 'HTML Identifier to add to the button element.', 'TEXT', FALSE, TRUE) + ('id', 'HTML Identifier to add to the button element.', 'TEXT', FALSE, TRUE), + ('confirm', 'A question to ask in a dialog before the button acts, such as "Delete this movie?".', 'TEXT', FALSE, TRUE) ) x; -- Inserting example information for the button component @@ -35,6 +36,11 @@ INSERT INTO example(component, description, properties) VALUES json('[{"component":"button"}, {"link":"/documentation.sql", "title":"Enabled"}, {"link":"#", "title":"Disabled", "disabled":true}]')) ; +INSERT INTO example(component, description, properties) VALUES + ('button', 'A button that asks for confirmation before it acts.', + json('[{"component":"button"}, {"link":"#", "title":"Delete", "color":"red", "icon":"trash", "confirm":"Delete this movie?"}]')) +; + INSERT INTO example(component, description, properties) VALUES ('button', 'A button with a custom shape, size, and outline color', diff --git a/frontend/src/sqlpage.ts b/frontend/src/sqlpage.ts index 4bacfc753..c2252a894 100644 --- a/frontend/src/sqlpage.ts +++ b/frontend/src/sqlpage.ts @@ -578,3 +578,51 @@ function open_modal_for_hash() { window.addEventListener("hashchange", open_modal_for_hash); window.addEventListener("DOMContentLoaded", open_modal_for_hash); + +function ask_confirmation(action: HTMLElement, question: string) { + const dialog = document.createElement("div"); + dialog.className = "modal"; + dialog.tabIndex = -1; + dialog.setAttribute("aria-label", question); + dialog.innerHTML = ``; + const text = dialog.querySelector("p"); + const footer = dialog.querySelector(".modal-footer"); + if (!text || !footer) throw new Error("Invalid confirmation dialog"); + text.textContent = question; + const confirmed_action = action.cloneNode(true); + if (!(confirmed_action instanceof HTMLElement)) + throw new Error("Invalid confirmed action"); + for (const attribute of [ + "id", + "data-confirm", + "data-bs-toggle", + "data-bs-target", + ]) + confirmed_action.removeAttribute(attribute); + confirmed_action.classList.remove("me-auto"); + footer.appendChild(confirmed_action); + document.body.appendChild(dialog); + const modal = new (page_bootstrap().Modal)(dialog); + // Hiding removes the dialog at once, and a removed button no longer submits + // its form, so the dialog closes only after the click has done its work. + confirmed_action.addEventListener("click", () => + setTimeout(() => modal.hide()), + ); + dialog.addEventListener("hidden.bs.modal", () => dialog.remove()); + modal.show(); +} + +document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const action = event.target.closest("[data-confirm]"); + const question = action?.dataset.confirm; + if (!action || !question) return; + event.preventDefault(); + ask_confirmation(action, question); +}); diff --git a/sqlpage/templates/button.handlebars b/sqlpage/templates/button.handlebars index 5a5ddfe52..71aba21fa 100644 --- a/sqlpage/templates/button.handlebars +++ b/sqlpage/templates/button.handlebars @@ -18,6 +18,7 @@ {{~#if rel}} rel="{{rel}}"{{/if}} {{~#if tooltip}} data-bs-toggle="tooltip" data-bs-placement="top" title="{{tooltip}}"{{/if}} {{~#if modal}} data-bs-toggle="modal" data-bs-target="#{{modal}}"{{/if}} + {{~#if confirm}} data-confirm="{{confirm}}"{{/if}} role="button"> {{~#if image~}} diff --git a/tests/end-to-end/fixtures/button/confirm/archived.sql b/tests/end-to-end/fixtures/button/confirm/archived.sql new file mode 100644 index 000000000..0e04a8935 --- /dev/null +++ b/tests/end-to-end/fixtures/button/confirm/archived.sql @@ -0,0 +1 @@ +SELECT 'text' AS component, 'Archived ' || :movie AS contents; diff --git a/tests/end-to-end/fixtures/button/confirm/deleted.sql b/tests/end-to-end/fixtures/button/confirm/deleted.sql new file mode 100644 index 000000000..3ce98519e --- /dev/null +++ b/tests/end-to-end/fixtures/button/confirm/deleted.sql @@ -0,0 +1 @@ +SELECT 'text' AS component, 'Deleted' AS contents; diff --git a/tests/end-to-end/fixtures/button/confirm/index.sql b/tests/end-to-end/fixtures/button/confirm/index.sql new file mode 100644 index 000000000..6c2178efc --- /dev/null +++ b/tests/end-to-end/fixtures/button/confirm/index.sql @@ -0,0 +1,6 @@ +SELECT 'form' AS component, 'movie' AS id, '' AS validate; +SELECT 'hidden' AS type, 'movie' AS name, 'Alien' AS value; + +SELECT 'button' AS component; +SELECT 'Delete' AS title, 'deleted.sql' AS link, 'Delete this movie?' AS confirm; +SELECT 'Archive' AS title, 'movie' AS form, 'archived.sql' AS link, 'Archive this movie?' AS confirm; diff --git a/tests/end-to-end/fixtures/button/confirm/test.ts b/tests/end-to-end/fixtures/button/confirm/test.ts new file mode 100644 index 000000000..aa5ccbaa3 --- /dev/null +++ b/tests/end-to-end/fixtures/button/confirm/test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "../../../fixture.ts"; + +test("cancelling a confirmation stays on the page", async ({ page }) => { + await page.getByRole("button", { name: "Delete" }).click(); + const dialog = page.getByRole("dialog", { name: "Delete this movie?" }); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).toBeHidden(); + await expect(page).toHaveURL(/\/button\/confirm\/$/); +}); + +test("confirming follows the button's link", async ({ page }) => { + await page.getByRole("button", { name: "Delete" }).click(); + await page + .getByRole("dialog", { name: "Delete this movie?" }) + .getByRole("button", { name: "Delete" }) + .click(); + await expect(page.getByText("Deleted")).toBeVisible(); +}); + +test("confirming submits the button's form", async ({ page }) => { + await page.getByRole("button", { name: "Archive" }).click(); + await page + .getByRole("dialog", { name: "Archive this movie?" }) + .getByRole("button", { name: "Archive" }) + .click(); + await expect(page.getByText("Archived Alien")).toBeVisible(); +});