diff --git a/src/components/ui/navigation-menu/navigation-menu.astro b/src/components/ui/navigation-menu/navigation-menu.astro
index 4cbf89d1d..49c6d40cd 100644
--- a/src/components/ui/navigation-menu/navigation-menu.astro
+++ b/src/components/ui/navigation-menu/navigation-menu.astro
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"
import NavigationMenuPositioner from "./navigation-menu-positioner.astro"
type Props = HTMLAttributes<"nav"> & {
+ media?: string
mountStrategy?: "lazy" | "eager"
delayOpen?: number
delayClose?: number
@@ -20,6 +21,7 @@ type Props = HTMLAttributes<"nav"> & {
}
const {
+ media,
mountStrategy,
class: className = "",
delayOpen = 0,
@@ -41,6 +43,7 @@ const {
{...props}
aria-label={ariaLabel}
data-slot="navigation-menu"
+ data-media={media}
data-mount-strategy={mountStrategy}
data-delay-open={delayOpen}
data-delay-close={delayClose}
@@ -62,14 +65,36 @@ const {
diff --git a/src/components/ui/sheet/sheet.astro b/src/components/ui/sheet/sheet.astro
index 84b8d63ab..549b6a8cc 100644
--- a/src/components/ui/sheet/sheet.astro
+++ b/src/components/ui/sheet/sheet.astro
@@ -2,6 +2,7 @@
import type { HTMLAttributes } from "astro/types"
type Props = HTMLAttributes<"div"> & {
+ media?: string
defaultOpen?: boolean
closeOnClickOutside?: boolean
closeOnEscape?: boolean
@@ -9,6 +10,7 @@ type Props = HTMLAttributes<"div"> & {
}
const {
+ media,
class: className,
defaultOpen,
closeOnClickOutside,
@@ -19,6 +21,7 @@ const {
---
diff --git a/src/content/pages/components/dialog.mdx b/src/content/pages/components/dialog.mdx
index ef5cfc7c2..b8459f233 100644
--- a/src/content/pages/components/dialog.mdx
+++ b/src/content/pages/components/dialog.mdx
@@ -87,6 +87,18 @@ Long content can scroll while the header stays in view.
+## Responsive activation
+
+Pass a `media` query to `Dialog` to import and initialize its controller only
+while that query matches. Leaving the query destroys the controller and releases
+its portals and scroll lock. Returning initializes it again. A trigger click
+during the first download is performed once loading finishes, provided the query
+still matches.
+
+The default has no breakpoint restriction. Keep the query and CSS visibility in
+the owning header block or site. Header blocks use `fold="lg"` by default and
+accept `fold="xl"`; that choice controls both visibility and activation.
+
## API Reference
Source: [`src/components/ui/dialog`](https://github.com/fulldotdev/ui/tree/main/src/components/ui/dialog)
diff --git a/src/content/pages/components/navigation-menu.mdx b/src/content/pages/components/navigation-menu.mdx
index 84c23e1e6..404f36c81 100644
--- a/src/content/pages/components/navigation-menu.mdx
+++ b/src/content/pages/components/navigation-menu.mdx
@@ -86,6 +86,18 @@ Use `mountStrategy="eager"` when an integration needs to query closed content
through the document. Both strategies include content in server-rendered HTML;
neither adds a no-JavaScript fallback.
+## Responsive activation
+
+Pass a `media` query to `NavigationMenu` to import and initialize its controller only
+while that query matches. Leaving the query destroys the controller and releases
+its portals and scroll lock. Returning initializes it again. A trigger click
+during the first download is performed once loading finishes, provided the query
+still matches.
+
+The default has no breakpoint restriction. Keep the query and CSS visibility in
+the owning header block or site. Header blocks use `fold="lg"` by default and
+accept `fold="xl"`; that choice controls both visibility and activation.
+
## API Reference
Source: [`src/components/ui/navigation-menu`](https://github.com/fulldotdev/ui/tree/main/src/components/ui/navigation-menu)
diff --git a/src/content/pages/components/sheet.mdx b/src/content/pages/components/sheet.mdx
index e367b6404..08d564b10 100644
--- a/src/content/pages/components/sheet.mdx
+++ b/src/content/pages/components/sheet.mdx
@@ -72,6 +72,18 @@ Use `showCloseButton={false}` on `SheetContent` to hide the close button.
+## Responsive activation
+
+Pass a `media` query to `Sheet` to import and initialize its controller only
+while that query matches. Leaving the query destroys the controller and releases
+its portals and scroll lock. Returning initializes it again. A trigger click
+during the first download is performed once loading finishes, provided the query
+still matches.
+
+The default has no breakpoint restriction. Keep the query and CSS visibility in
+the owning header block or site. Header blocks use `fold="lg"` by default and
+accept `fold="xl"`; that choice controls both visibility and activation.
+
## API Reference
Source: [`src/components/ui/sheet`](https://github.com/fulldotdev/ui/tree/main/src/components/ui/sheet)
diff --git a/src/lib/dialog.ts b/src/lib/dialog.ts
new file mode 100644
index 000000000..9f2fdc815
--- /dev/null
+++ b/src/lib/dialog.ts
@@ -0,0 +1,29 @@
+import { onMedia } from "./on-media"
+
+const roots = new Map
void>()
+
+export function initializeDialogs() {
+ for (const root of document.querySelectorAll(
+ '[data-slot="dialog"]'
+ )) {
+ if (roots.has(root)) continue
+ roots.set(
+ root,
+ onMedia(
+ root.dataset.media ?? "all",
+ () => import("@data-slot/dialog"),
+ ({ createDialog }) => {
+ const controller = createDialog(root)
+ return () => controller.destroy()
+ },
+ { root, selector: '[data-slot="dialog-trigger"]', event: "dialog:set" }
+ )
+ )
+ }
+}
+
+document.addEventListener("astro:page-load", initializeDialogs)
+document.addEventListener("astro:before-swap", () => {
+ for (const dispose of roots.values()) dispose()
+ roots.clear()
+})
diff --git a/src/lib/on-media.ts b/src/lib/on-media.ts
new file mode 100644
index 000000000..8baf71cf2
--- /dev/null
+++ b/src/lib/on-media.ts
@@ -0,0 +1,89 @@
+type Trigger = { root: Element; selector: string; event?: string }
+
+/** Import and mount only while the query matches; dispose on exit. */
+export function onMedia(
+ query: string,
+ load: () => Promise,
+ mount: (module: T) => () => void,
+ trigger?: Trigger
+) {
+ const media = window.matchMedia(query)
+ let disposed = false
+ let epoch = 0
+ let cleanup: (() => void) | undefined
+ let pendingClick: HTMLElement | undefined
+ let pendingEvent: CustomEvent | undefined
+
+ const replayClick = () => {
+ const target = pendingClick
+ const event = pendingEvent
+ pendingClick = undefined
+ pendingEvent = undefined
+ if (event && trigger?.root.isConnected)
+ trigger.root.dispatchEvent(
+ new CustomEvent(event.type, { detail: event.detail })
+ )
+ else if (target?.isConnected)
+ target.dispatchEvent(new MouseEvent("click", { cancelable: true }))
+ }
+
+ const update = async () => {
+ const current = ++epoch
+ pendingClick = undefined
+ pendingEvent = undefined
+ cleanup?.()
+ cleanup = undefined
+ if (!media.matches || disposed) return
+ try {
+ const module = await load()
+ if (disposed || !media.matches || current !== epoch) return
+ cleanup = mount(module)
+ replayClick()
+ } catch (error) {
+ if (!disposed && current === epoch) {
+ pendingClick = undefined
+ pendingEvent = undefined
+ console.error(error)
+ }
+ }
+ }
+ const click = (event: Event) => {
+ if (
+ !trigger ||
+ cleanup ||
+ disposed ||
+ !media.matches ||
+ event.defaultPrevented
+ )
+ return
+ if (!(event.target instanceof Element)) return
+ const target = event.target.closest(trigger.selector)
+ if (!target || !trigger.root.contains(target)) return
+ pendingClick ??= target
+ pendingEvent = undefined
+ event.preventDefault()
+ }
+ const request = (event: Event) => {
+ if (cleanup || disposed || !media.matches || event.target !== trigger?.root)
+ return
+ if (!(event instanceof CustomEvent)) return
+ pendingEvent = event
+ pendingClick = undefined
+ }
+ const change = () => void update()
+ media.addEventListener("change", change)
+ trigger?.root.addEventListener("click", click, true)
+ if (trigger?.event) trigger.root.addEventListener(trigger.event, request)
+ change()
+ return () => {
+ disposed = true
+ epoch++
+ pendingClick = undefined
+ pendingEvent = undefined
+ media.removeEventListener("change", change)
+ trigger?.root.removeEventListener("click", click, true)
+ if (trigger?.event) trigger.root.removeEventListener(trigger.event, request)
+ cleanup?.()
+ cleanup = undefined
+ }
+}
diff --git a/tests/browser/docs-search.test.mjs b/tests/browser/docs-search.test.mjs
index 2adc4ed67..228f18a69 100644
--- a/tests/browser/docs-search.test.mjs
+++ b/tests/browser/docs-search.test.mjs
@@ -7,7 +7,7 @@ import { createReadStream, existsSync, statSync } from "node:fs"
import { createServer } from "node:http"
import { extname, join, resolve } from "node:path"
import { after, before, test } from "node:test"
-import { chromium } from "playwright"
+import { chromium, firefox, webkit } from "playwright"
const root = resolve(process.env.DIST ?? "dist")
const types = {
@@ -74,3 +74,96 @@ test("the docs search lists every page and opens a result", async (t) => {
await page.keyboard.press("Enter")
await page.waitForURL(`${origin}/components/accordion/`)
})
+
+for (const type of [chromium, firefox, webkit])
+ test(`${type.name()} search shortcut survives a pending dialog import`, async (t) => {
+ const browser = await type.launch()
+ t.after(() => browser.close())
+ const page = await browser.newPage()
+ const delay = await delayDialog(page)
+ await page.goto(`${origin}/docs/theming/`, {
+ waitUntil: "domcontentloaded",
+ })
+ await delay.started
+ await page.keyboard.press(
+ await page.evaluate(() =>
+ /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent)
+ ? "Meta+k"
+ : "Control+k"
+ )
+ )
+ delay.release()
+ await page.locator("[data-slot=command-input]:visible").waitFor()
+ assert.equal(
+ await page
+ .locator("[data-slot=command-input]:visible")
+ .evaluate((input) => document.activeElement === input),
+ true
+ )
+ })
+
+for (const type of [chromium, webkit])
+ test(`${type.name()} original search tap holds keyboard focus while loading`, async (t) => {
+ const browser = await type.launch()
+ t.after(() => browser.close())
+ const page = await browser.newPage({
+ viewport: { width: 390, height: 844 },
+ hasTouch: true,
+ isMobile: true,
+ })
+ const delay = await delayDialog(page)
+ await page.goto(`${origin}/docs/theming/`, {
+ waitUntil: "domcontentloaded",
+ })
+ await delay.started
+ await page.evaluate(() => {
+ window.__searchGestures = []
+ document.addEventListener("click", (event) => {
+ if (
+ event.target.closest(
+ "[data-search-dialog] [data-slot=dialog-trigger]"
+ )
+ )
+ window.__searchGestures.push({
+ trusted: event.isTrusted,
+ holder: document.activeElement?.matches(
+ 'input[aria-hidden="true"]'
+ ),
+ })
+ })
+ })
+ await page.getByRole("button", { name: "Search documentation" }).tap()
+ assert.deepEqual(await page.evaluate(() => window.__searchGestures), [
+ { trusted: true, holder: true },
+ ])
+ delay.release()
+ const input = page.locator("[data-slot=command-input]:visible")
+ await input.waitFor()
+ await page.waitForFunction(() =>
+ document.activeElement?.matches("[data-slot=command-input]")
+ )
+ assert.deepEqual(await page.evaluate(() => window.__searchGestures), [
+ { trusted: true, holder: true },
+ ])
+ })
+
+async function delayDialog(page) {
+ let release
+ const pending = new Promise((done) => {
+ release = done
+ })
+ let intercepted
+ const started = new Promise((done) => {
+ intercepted = done
+ })
+ await page.route("**/*.js", async (route) => {
+ const response = await route.fetch()
+ const body = await response.text()
+ if (body.includes("Dialog requires dialog-content slot")) {
+ intercepted()
+ await pending
+ }
+ await route.fulfill({ response, body })
+ })
+ return { started, release }
+}
diff --git a/tests/browser/responsive-menu.test.mjs b/tests/browser/responsive-menu.test.mjs
new file mode 100644
index 000000000..a5eda8274
--- /dev/null
+++ b/tests/browser/responsive-menu.test.mjs
@@ -0,0 +1,409 @@
+import assert from "node:assert/strict"
+import { mkdirSync, rmSync, writeFileSync } from "node:fs"
+import { join, resolve } from "node:path"
+import { after, before, test } from "node:test"
+import { build, preview } from "astro"
+import tailwindcss from "@tailwindcss/vite"
+import { chromium, firefox, webkit } from "playwright"
+
+const src = resolve("src")
+const root = resolve("node_modules/.cache/responsive-menu-site")
+const page = (dialog, router = false) => `---
+import { ClientRouter } from "astro:transitions"
+import { Sheet, SheetTrigger, SheetContent, SheetTitle } from "${src}/components/ui/sheet"
+import { Dialog, DialogTrigger, DialogContent, DialogTitle } from "${src}/components/ui/dialog"
+import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent } from "${src}/components/ui/navigation-menu"
+import { SidebarProvider, Sidebar, SidebarTrigger } from "${src}/components/ui/sidebar"
+---
+Responsive menus${router ? "" : ""}
+
+
+MenuMobile navigationInside sheet
+ProductsProduct one
+${dialog ? "" : ""}
+Sidebar link
+Next pageDestination
`
+const headerPage = (number, fold) => `---
+import Header from "${src}/components/blocks/header-${number}.astro"
+import "../header.css"
+---
+Header ${number} ${fold}
+
+Destination`
+let server
+let origin
+before(async () => {
+ rmSync(root, { recursive: true, force: true })
+ mkdirSync(join(root, "src/pages"), { recursive: true })
+ writeFileSync(join(root, "src/pages/index.astro"), page(true))
+ writeFileSync(join(root, "src/pages/pending.astro"), page(false))
+ writeFileSync(join(root, "src/pages/router-one.astro"), page(true, true))
+ writeFileSync(join(root, "src/pages/router-two.astro"), page(true, true))
+ writeFileSync(
+ join(root, "src/header.css"),
+ `@import "tailwindcss"; @source "${src}/components"; body {min-height:150vh} button {min-width:32px;min-height:32px} [data-slot="dialog-content"] {position:fixed;top:0;right:0;background:white;padding:24px}`
+ )
+ for (let number = 1; number <= 5; number++)
+ for (const fold of ["lg", "xl"])
+ writeFileSync(
+ join(root, `src/pages/header-${number}-${fold}.astro`),
+ headerPage(number, fold)
+ )
+ const config = {
+ root,
+ configFile: false,
+ logLevel: "error",
+ vite: { plugins: [tailwindcss()], resolve: { alias: { "@": src } } },
+ }
+ await build(config)
+ server = await preview({ ...config, server: { port: 0 } })
+ origin = `http://localhost:${server.port}`
+})
+after(async () => {
+ await server?.stop()
+ rmSync(root, { recursive: true, force: true })
+})
+
+for (const type of [chromium, firefox, webkit]) {
+ test(`${type.name()} actual headers use the selected fold for visibility and lifecycle`, async (t) => {
+ const browser = await type.launch()
+ t.after(() => browser.close())
+ const page = await browser.newPage()
+ const errors = []
+ page.on("pageerror", (error) => errors.push(error.message))
+ for (let number = 1; number <= 5; number++)
+ for (const fold of ["lg", "xl"]) {
+ const boundary = fold === "lg" ? 1024 : 1280
+ await page.setViewportSize({ width: boundary - 1, height: 900 })
+ await page.goto(`${origin}/header-${number}-${fold}/`)
+ const navigation = page.locator('[data-slot="navigation-menu"]')
+ const sheet = page.locator('[data-slot="dialog"][data-media]')
+ const trigger = page.getByRole("button", { name: "Menu", exact: true })
+ assert.equal(
+ await navigation.isVisible(),
+ false,
+ `header ${number} ${fold}: mobile navigation`
+ )
+ assert.equal(await trigger.isVisible(), true)
+ assert.equal(
+ await sheet.getAttribute("data-media"),
+ `(width < ${fold === "lg" ? 64 : 80}rem)`
+ )
+ await trigger.click()
+ await page
+ .getByRole("dialog", { name: "Mobile navigation", exact: true })
+ .waitFor()
+ await page.setViewportSize({ width: boundary, height: 900 })
+ await page
+ .getByRole("dialog", { name: "Mobile navigation", exact: true })
+ .waitFor({ state: "hidden" })
+ assert.equal(await trigger.isVisible(), false)
+ assert.equal(await navigation.isVisible(), true)
+ assert.equal(
+ await page.evaluate(() => document.body.style.overflow),
+ ""
+ )
+ if (number >= 4) {
+ await navigation
+ .getByRole("button", { name: "Products", exact: true })
+ .click()
+ await page
+ .getByRole("link", { name: "Product one", exact: true })
+ .waitFor()
+ }
+ await page.setViewportSize({ width: boundary - 1, height: 900 })
+ if (number >= 4)
+ await page
+ .getByRole("link", { name: "Product one", exact: true })
+ .waitFor({ state: "hidden" })
+ assert.equal(await navigation.isVisible(), false)
+ await trigger.click()
+ await page
+ .getByRole("dialog", { name: "Mobile navigation", exact: true })
+ .waitFor()
+ await page.keyboard.press("Escape")
+ await page
+ .getByRole("dialog", { name: "Mobile navigation", exact: true })
+ .waitFor({ state: "hidden" })
+ }
+ assert.deepEqual(errors, [])
+ })
+
+ test(`${type.name()} disposes and recreates after ClientRouter navigation`, async (t) => {
+ const browser = await type.launch()
+ t.after(() => browser.close())
+ const page = await browser.newPage({
+ viewport: { width: 390, height: 900 },
+ })
+ await page.goto(origin + "/router-one/")
+ await page.getByRole("button", { name: "Menu", exact: true }).click()
+ await page.getByRole("dialog", { name: "Mobile navigation" }).waitFor()
+ await page.evaluate(() => {
+ window.__menuSwap = false
+ document.addEventListener(
+ "astro:page-load",
+ () => {
+ window.__menuSwap = true
+ },
+ { once: true }
+ )
+ document.querySelector('a[href="/router-two/"]').click()
+ })
+ await page.waitForFunction(() => window.__menuSwap)
+ assert.equal(await page.evaluate(() => document.body.style.overflow), "")
+ await page.getByRole("button", { name: "Menu", exact: true }).click()
+ await page.getByRole("dialog", { name: "Mobile navigation" }).waitFor()
+ await page.keyboard.press("Escape")
+ await page.getByRole("button", { name: "Search", exact: true }).click()
+ await page.getByRole("dialog", { name: "Search dialog" }).waitFor()
+ })
+
+ test(`${type.name()} responsive controllers and portal cleanup`, async (t) => {
+ const browser = await type.launch()
+ t.after(() => browser.close())
+ const page = await browser.newPage({
+ viewport: { width: 1440, height: 900 },
+ })
+ const errors = []
+ page.on("pageerror", (e) => errors.push(e.message))
+ await page.goto(origin)
+ const sheet = page.locator('[data-slot="dialog"][data-media]')
+ assert.equal(await sheet.getAttribute("data-state"), null)
+ await page.getByRole("button", { name: "Search", exact: true }).click()
+ await page
+ .getByRole("dialog", { name: "Search dialog" })
+ .waitFor({ state: "visible" })
+ await page.keyboard.press("Escape")
+ await page.setViewportSize({ width: 390, height: 900 })
+ await page.getByRole("button", { name: "Menu", exact: true }).click()
+ await page
+ .getByRole("dialog", { name: "Mobile navigation" })
+ .waitFor({ state: "visible" })
+ await page.setViewportSize({ width: 1440, height: 900 })
+ await page
+ .getByRole("dialog", { name: "Mobile navigation" })
+ .waitFor({ state: "hidden" })
+ assert.equal(await page.evaluate(() => document.body.style.overflow), "")
+ await page.getByRole("button", { name: "Products", exact: true }).click()
+ await page
+ .getByRole("link", { name: "Product one" })
+ .waitFor({ state: "visible" })
+ await page.setViewportSize({ width: 390, height: 900 })
+ await page
+ .getByRole("link", { name: "Product one" })
+ .waitFor({ state: "hidden" })
+ await page.getByRole("button", { name: "Menu", exact: true }).click()
+ await page
+ .getByRole("dialog", { name: "Mobile navigation" })
+ .waitFor({ state: "visible" })
+ await page.keyboard.press("Escape")
+ await page
+ .getByRole("dialog", { name: "Mobile navigation" })
+ .waitFor({ state: "hidden" })
+ assert.deepEqual(errors, [])
+ })
+
+ for (const cancel of [false, true])
+ test(`${type.name()} first click during import, exit=${cancel}`, async (t) => {
+ const browser = await type.launch()
+ t.after(() => browser.close())
+ const page = await browser.newPage({
+ viewport: { width: 390, height: 900 },
+ })
+ let release
+ const delayed = new Promise((resolve) => {
+ release = resolve
+ })
+ let intercepted
+ const started = new Promise((resolve) => {
+ intercepted = resolve
+ })
+ await page.route("**/*.js", async (route) => {
+ const response = await route.fetch()
+ const body = await response.text()
+ if (body.includes("Dialog requires dialog-content slot")) {
+ intercepted()
+ await delayed
+ }
+ await route.fulfill({ response, body })
+ })
+ await page.goto(origin + "/pending/", { waitUntil: "domcontentloaded" })
+ await started
+ await page.evaluate(() => {
+ window.__menuClicks = []
+ document.addEventListener("click", (event) => {
+ if (event.target.closest('[data-slot="dialog-trigger"]'))
+ window.__menuClicks.push(event.isTrusted)
+ })
+ })
+ await page.getByRole("button", { name: "Menu", exact: true }).click()
+ assert.deepEqual(await page.evaluate(() => window.__menuClicks), [true])
+ if (cancel) await page.setViewportSize({ width: 1440, height: 900 })
+ release()
+ if (cancel) {
+ await page.waitForLoadState("networkidle")
+ assert.equal(
+ await page
+ .locator('[data-slot="dialog-content"]:not([hidden])')
+ .count(),
+ 0
+ )
+ await page.setViewportSize({ width: 390, height: 900 })
+ await page.getByRole("button", { name: "Menu", exact: true }).click()
+ }
+ await page
+ .getByRole("dialog", { name: "Mobile navigation" })
+ .waitFor({ state: "visible" })
+ assert.ok((await page.evaluate(() => window.__menuClicks)).every(Boolean))
+ })
+
+ for (const action of ["open", "click", "latest-close", "exit", "swap"])
+ test(`${type.name()} pending programmatic navigation request: ${action}`, async (t) => {
+ const browser = await type.launch()
+ t.after(() => browser.close())
+ const page = await browser.newPage({
+ viewport: { width: 1440, height: 900 },
+ })
+ let release
+ const pending = new Promise((done) => {
+ release = done
+ })
+ let intercepted
+ const started = new Promise((done) => {
+ intercepted = done
+ })
+ await page.route("**/*.js", async (route) => {
+ const response = await route.fetch()
+ const body = await response.text()
+ if (body.includes("NavigationMenu requires")) {
+ intercepted()
+ await pending
+ }
+ await route.fulfill({ response, body })
+ })
+ await page.goto(origin + "/pending/", { waitUntil: "domcontentloaded" })
+ await started
+ if (action === "click")
+ await page
+ .getByRole("button", { name: "Products", exact: true })
+ .click()
+ else
+ await page.evaluate((close) => {
+ document.addEventListener(
+ "astro:page-load",
+ () => {
+ const root = document.querySelector(
+ '[data-slot="navigation-menu"]'
+ )
+ root.dispatchEvent(
+ new CustomEvent("navigation-menu:set", {
+ detail: { value: "products" },
+ })
+ )
+ if (close)
+ root.dispatchEvent(
+ new CustomEvent("navigation-menu:set", {
+ detail: { value: null },
+ })
+ )
+ },
+ { once: true }
+ )
+ document.dispatchEvent(new Event("astro:page-load"))
+ }, action === "latest-close")
+ if (action === "exit")
+ await page.setViewportSize({ width: 390, height: 900 })
+ if (action === "swap")
+ await page.evaluate(() =>
+ document.dispatchEvent(new Event("astro:before-swap"))
+ )
+ release()
+ const link = page.getByRole("link", { name: "Product one", exact: true })
+ if (action === "open" || action === "click") await link.waitFor()
+ else {
+ await page.waitForLoadState("networkidle")
+ if (action === "exit")
+ await page.setViewportSize({ width: 1440, height: 900 })
+ if (action === "swap")
+ await page.evaluate(() =>
+ document.dispatchEvent(new Event("astro:page-load"))
+ )
+ await page.waitForLoadState("networkidle")
+ assert.equal(await link.isVisible(), false)
+ await page
+ .getByRole("button", { name: "Products", exact: true })
+ .click()
+ await link.waitFor()
+ }
+ })
+
+ for (const action of ["sidebar", "page-load", "exit"])
+ test(`${type.name()} pending programmatic dialog request: ${action}`, async (t) => {
+ const browser = await type.launch()
+ t.after(() => browser.close())
+ const page = await browser.newPage({
+ viewport: { width: 390, height: 900 },
+ })
+ let release
+ const delayed = new Promise((resolve) => {
+ release = resolve
+ })
+ let intercepted
+ const started = new Promise((resolve) => {
+ intercepted = resolve
+ })
+ await page.route("**/*.js", async (route) => {
+ const response = await route.fetch()
+ const body = await response.text()
+ if (body.includes("Dialog requires dialog-content slot")) {
+ intercepted()
+ await delayed
+ }
+ await route.fulfill({ response, body })
+ })
+ await page.goto(origin + "/pending/", { waitUntil: "domcontentloaded" })
+ await started
+ if (action === "sidebar")
+ await page
+ .getByRole("button", { name: "Toggle Sidebar", exact: true })
+ .click()
+ else
+ await page.evaluate(() => {
+ document.addEventListener(
+ "astro:page-load",
+ () => {
+ document
+ .querySelector('[data-slot="dialog"][data-media]')
+ .dispatchEvent(
+ new CustomEvent("dialog:set", { detail: { open: true } })
+ )
+ },
+ { once: true }
+ )
+ document.dispatchEvent(new Event("astro:page-load"))
+ })
+ if (action === "exit")
+ await page.setViewportSize({ width: 1440, height: 900 })
+ release()
+ if (action === "exit") {
+ await page.waitForLoadState("networkidle")
+ await page.setViewportSize({ width: 390, height: 900 })
+ await page.waitForLoadState("networkidle")
+ assert.equal(
+ await page.getByRole("dialog", { name: "Mobile navigation" }).count(),
+ 0
+ )
+ } else {
+ const name = action === "sidebar" ? "Sidebar" : "Mobile navigation"
+ await page.getByRole("dialog", { name, exact: true }).waitFor()
+ await page.keyboard.press("Escape")
+ if (action === "sidebar")
+ assert.equal(
+ await page
+ .locator('[data-slot="sidebar-wrapper"]')
+ .getAttribute("data-open-mobile"),
+ "false"
+ )
+ }
+ })
+}
diff --git a/tests/on-media.test.mjs b/tests/on-media.test.mjs
new file mode 100644
index 000000000..358a37671
--- /dev/null
+++ b/tests/on-media.test.mjs
@@ -0,0 +1,82 @@
+import assert from "node:assert/strict"
+import { test } from "node:test"
+
+const { onMedia } = await import(
+ new URL("../src/lib/on-media.ts", import.meta.url)
+)
+
+test("media loading cancels stale imports and recreates on return", async () => {
+ const media = new EventTarget()
+ media.matches = false
+ const previous = globalThis.window
+ globalThis.window = { matchMedia: () => media }
+ let loads = 0
+ let mounts = 0
+ let destroys = 0
+ const resolve = []
+ const dispose = onMedia(
+ "test",
+ () => {
+ loads++
+ return new Promise((done) => resolve.push(done))
+ },
+ () => {
+ mounts++
+ return () => destroys++
+ }
+ )
+ const change = (matches) => {
+ media.matches = matches
+ media.dispatchEvent(new Event("change"))
+ }
+ try {
+ assert.equal(loads, 0)
+ change(true)
+ change(false)
+ resolve.shift()({})
+ await Promise.resolve()
+ assert.equal(mounts, 0)
+ change(true)
+ resolve.shift()({})
+ await Promise.resolve()
+ assert.equal(mounts, 1)
+ change(false)
+ assert.equal(destroys, 1)
+ change(true)
+ resolve.shift()({})
+ await Promise.resolve()
+ assert.equal(mounts, 2)
+ dispose()
+ assert.equal(destroys, 2)
+ change(true)
+ assert.equal(loads, 3)
+ } finally {
+ dispose()
+ globalThis.window = previous
+ }
+})
+
+test("disposing during import prevents a later mount", async () => {
+ const previous = globalThis.window
+ const media = new EventTarget()
+ media.matches = true
+ globalThis.window = { matchMedia: () => media }
+ let done
+ let mounts = 0
+ const dispose = onMedia(
+ "all",
+ () =>
+ new Promise((resolve) => {
+ done = resolve
+ }),
+ () => {
+ mounts++
+ return () => {}
+ }
+ )
+ dispose()
+ done({})
+ await Promise.resolve()
+ assert.equal(mounts, 0)
+ globalThis.window = previous
+})