Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## unreleased

- Modals can set a header `color`, and `visible` opens that dialog once when the page loads. The page address stays unchanged, and closing it does not open it again when later content loads.

- Now a tab with a `color` is underlined only when it is active. ([#791](https://github.com/sqlpage/SQLPage/issues/791))

- Striped table rows are visible in dark mode again. Tabler was updated to [v1.7.3](https://github.com/tabler/tabler/releases/tag/%40tabler%2Fcore%401.7.3) to fix this. ([#957](https://github.com/sqlpage/SQLPage/issues/957))
Expand Down
13 changes: 12 additions & 1 deletion examples/official-site/sqlpage/migrations/63_modal.sql
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
('height','Height of the embedded content.','INTEGER',TRUE,TRUE),
('allow','For embedded content, this attribute specifies the features or permissions that can be used.','TEXT',TRUE,TRUE),
('sandbox','For embedded content, this attribute specifies the security restrictions on the loaded content.','TEXT',TRUE,TRUE),
('style','Applies CSS styles to the embedded content.','TEXT',TRUE,TRUE)
('style','Applies CSS styles to the embedded content.','TEXT',TRUE,TRUE),
('color','Optional Tabler color for the modal header. The header uses that background and the same contrasting foreground as a colored toast, with white mapped to the light foreground. The body and footer stay as they are.','COLOR',TRUE,TRUE),
('visible','Opens this modal once on load when true, without an inline script and without changing the URL hash. If several modals set this, only the last one opens. A URL hash that names a different modal takes precedence.','BOOLEAN',TRUE,TRUE)
) x;

INSERT INTO example(component, description, properties) VALUES
Expand Down Expand Up @@ -78,6 +80,15 @@ INSERT INTO example(component, description, properties) VALUES
},
{"component": "text", "contents_md": "Open a [modal with a video](#my_embed_video_modal)"}
]')
),
('modal',
'A modal with a colored header, opened by a button. The header uses the color and a contrasting foreground; the body stays in the normal text color.',
json('[
{"component": "modal","id": "colored_modal","title": "Saved","color": "green","close": "Close"},
{"contents_md":"The header uses the chosen color."},
{"component": "button"},
{"title":"Open a colored modal","link":"#colored_modal"}
]')
);

INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'button', * FROM (VALUES
Expand Down
34 changes: 30 additions & 4 deletions frontend/src/sqlpage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,12 +520,38 @@ function sqlpage_modal(root: InitRoot) {
// .page instead of the viewport. The modal then scrolls with the page
// content and ends up behind its own backdrop, so its buttons cannot be
// clicked. Moving modals to <body> keeps them viewport-fixed.
for (const modal of select_all(root, "body .page .modal", HTMLElement)) {
document.body.appendChild(modal);
// The modal leaves the original root; announce its subtree separately so
// every initializer, including later-loaded bundles, can still see it.
const modals = select_all(root, "body .page .modal", HTMLElement);
for (const modal of modals) document.body.appendChild(modal);
// The modals left the original root; announce each subtree separately so
// every initializer, including later-loaded bundles, can still see it. The
// event runs this function again, so move them all first: a dialog opened
// below must not be moved afterwards.
for (const modal of modals) {
modal.dispatchEvent(new CustomEvent("fragment-loaded", { bubbles: true }));
}

// `visible` opens the last such dialog once. A hash that already names a
// different modal keeps that dialog. Clearing every marker stops a later
// pass from opening an earlier dialog or reopening one the user closed.
const visible_modals = document.querySelectorAll<HTMLElement>(
".modal[data-modal-visible]",
);
const modal = visible_modals[visible_modals.length - 1];
if (!modal) return;
const hash = window.location.hash.substring(1);
const hashed = hash ? document.getElementById(hash) : null;
const hash_names_other_modal =
hashed?.classList.contains("modal") === true && hashed !== modal;
if (!hash_names_other_modal) {
const Modal = page_bootstrap().Modal;
const bootstrap_modal = Modal.getOrCreateInstance(modal);
if (!(bootstrap_modal instanceof Modal))
throw new Error("Invalid modal instance");
bootstrap_modal.show();
}
for (const visible of visible_modals) {
visible.removeAttribute("data-modal-visible");
}
}

add_init_fn(sqlpage_table);
Expand Down
6 changes: 3 additions & 3 deletions sqlpage/templates/modal.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
id="{{id}}"
tabindex="-1"
aria-hidden="false"
aria-labelledby="{{id}}-title">
aria-labelledby="{{id}}-title"{{~#if visible}} data-modal-visible{{/if}}>
<div role="document" class="modal-dialog {{#if small}} modal-sm{{/if}}{{#if large}} modal-lg{{/if}}{{#if scrollable}} modal-dialog-scrollable{{/if}}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="{{id}}-title">{{title}}</h5>
<div class="modal-header{{~#if color}} bg-{{color}} text-{{#if (eq color 'white')}}light{{else}}{{color}}{{/if}}-fg{{/if}}">
<h5 class="modal-title{{~#if color}} text-reset{{/if}}" id="{{id}}-title">{{title}}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{default (default close_label close) "close"}}"></button>
</div>
<div class="modal-body">
Expand Down
98 changes: 98 additions & 0 deletions tests/end-to-end/fixtures/modal-visible/index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
SELECT 'shell' AS component;

-- Opens once on load: green header, markdown body, footer close label.
SELECT 'modal' AS component,
'notice' AS id,
'Saved' AS title,
'green' AS color,
TRUE AS visible,
'Close' AS close
WHERE COALESCE($scenario, 'open') = 'open';

SELECT 'It works !' AS contents_md
WHERE COALESCE($scenario, 'open') = 'open';

-- Stays closed until #notice. `visible` is omitted, or false when requested.
SELECT 'modal' AS component,
'notice' AS id,
'Saved' AS title,
'green' AS color
WHERE $scenario = 'closed' AND $visible IS NULL;

SELECT 'modal' AS component,
'notice' AS id,
'Saved' AS title,
'green' AS color,
FALSE AS visible
WHERE $scenario = 'closed' AND $visible IS NOT NULL;

SELECT 'It works !' AS contents_md
WHERE $scenario = 'closed';

SELECT 'text' AS component
WHERE $scenario = 'closed';

SELECT 'Open notice' AS contents, '#notice' AS link
WHERE $scenario = 'closed';

-- White maps to the light foreground. An empty or omitted color adds none.
SELECT 'modal' AS component, 'omitted' AS id, 'Omitted' AS title
WHERE $scenario = 'colors';

SELECT 'Plain' AS contents
WHERE $scenario = 'colors';

SELECT 'modal' AS component, 'empty' AS id, 'Empty' AS title, '' AS color
WHERE $scenario = 'colors';

SELECT 'Plain' AS contents
WHERE $scenario = 'colors';

SELECT 'modal' AS component,
'white' AS id,
'White' AS title,
'white' AS color,
TRUE AS visible
WHERE $scenario = 'colors';

SELECT 'Plain' AS contents
WHERE $scenario = 'colors';

-- Two modals ask to open on load; only the later one does.
SELECT 'modal' AS component, 'first' AS id, 'First' AS title, TRUE AS visible
WHERE $scenario = 'two';

SELECT 'Earlier' AS contents
WHERE $scenario = 'two';

SELECT 'modal' AS component, 'second' AS id, 'Second' AS title, TRUE AS visible
WHERE $scenario = 'two';

SELECT 'Later' AS contents
WHERE $scenario = 'two';

-- A hash that names a different modal wins over `visible`.
SELECT 'modal' AS component, 'notice' AS id, 'Saved' AS title, TRUE AS visible
WHERE $scenario = 'hash-modal';

SELECT 'Visible' AS contents
WHERE $scenario = 'hash-modal';

SELECT 'modal' AS component, 'other' AS id, 'Other' AS title
WHERE $scenario = 'hash-modal';

SELECT 'Hashed' AS contents
WHERE $scenario = 'hash-modal';

-- A hash that names a non-modal anchor still allows `visible` to open.
SELECT 'text' AS component, 'section' AS id
WHERE $scenario = 'hash-anchor';

SELECT 'In the page' AS contents
WHERE $scenario = 'hash-anchor';

SELECT 'modal' AS component, 'notice' AS id, 'Saved' AS title, TRUE AS visible
WHERE $scenario = 'hash-anchor';

SELECT 'It works !' AS contents_md
WHERE $scenario = 'hash-anchor';
108 changes: 108 additions & 0 deletions tests/end-to-end/fixtures/modal-visible/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { expect, test } from "../../fixture.ts";

test("opens a colored modal once on load", async ({ page }) => {
const modal = page.locator("#notice");
const header = modal.locator(".modal-header");
await expect(modal).toBeVisible();
await expect(modal.getByText("It works !")).toBeVisible();
await expect(header).toHaveAttribute("class", /bg-green/);
await expect(header).toHaveAttribute("class", /text-green-fg/);
await expect(modal.locator(".modal-body")).toHaveAttribute(
"class",
"modal-body",
);
await expect(modal.locator(".modal-footer")).toHaveAttribute(
"class",
"modal-footer",
);
await expect(
modal.locator(".modal-footer").getByRole("button", { name: "Close" }),
).toBeVisible();
await expect(modal.locator("script")).toHaveCount(0);
await expect(modal).not.toHaveAttribute("data-modal-visible");

const headerColor = await header.evaluate(
(element) => getComputedStyle(element).color,
);
await expect(header.locator(".btn-close")).toHaveCSS("color", headerColor);

await page.keyboard.press("Escape");
await expect(modal).toBeHidden();
await page.evaluate(() => {
document.dispatchEvent(new CustomEvent("fragment-loaded"));
});
await expect(modal).toBeHidden();
});

test("header close button dismisses a modal opened on load", async ({
page,
}) => {
const modal = page.locator("#notice");
await expect(modal).toBeVisible();
await modal.locator(".modal-header .btn-close").click();
await expect(modal).toBeHidden();
});

test("stays closed until a hash link opens it", async ({ page }) => {
for (const url of [
"/modal-visible/?scenario=closed",
"/modal-visible/?scenario=closed&visible=0",
]) {
await page.goto(url);
const modal = page.locator("#notice");
await expect(modal).toBeHidden();
await expect(modal).not.toHaveAttribute("data-modal-visible");
}

await page.goto("/modal-visible/?scenario=closed");
await page.getByRole("link", { name: "Open notice" }).click();
const modal = page.locator("#notice");
await expect(modal).toBeVisible();
await expect(page).toHaveURL(/#notice$/);
await page.keyboard.press("Escape");
await expect(modal).toBeHidden();
await expect(page).toHaveURL(/#$/);
});

test("maps white to the light foreground and ignores an empty color", async ({
page,
}) => {
await page.goto("/modal-visible/?scenario=colors");
await expect(page.locator("#white")).toBeVisible();
await expect(page.locator("#white .modal-header")).toHaveAttribute(
"class",
/bg-white/,
);
await expect(page.locator("#white .modal-header")).toHaveAttribute(
"class",
/text-light-fg/,
);
await expect(page.locator("#omitted .modal-header")).toHaveAttribute(
"class",
"modal-header",
);
await expect(page.locator("#empty .modal-header")).toHaveAttribute(
"class",
"modal-header",
);
});

test("opens only the later visible modal", async ({ page }) => {
await page.goto("/modal-visible/?scenario=two");
await expect(page.locator("#second")).toBeVisible();
await expect(page.locator("#first")).toBeHidden();
});

test("keeps a hashed modal instead of the visible one", async ({ page }) => {
await page.goto("/modal-visible/?scenario=hash-modal#other");
await expect(page.locator("#other")).toBeVisible();
await expect(page.locator("#notice")).toBeHidden();
});

test("opens the visible modal when the hash is not a modal", async ({
page,
}) => {
await page.goto("/modal-visible/?scenario=hash-anchor#section");
await expect(page.locator("#notice")).toBeVisible();
await expect(page).toHaveURL(/#section$/);
});