diff --git a/docs/latest/advanced/partials.md b/docs/latest/advanced/partials.md index ff9c608846a..62a21e4a765 100644 --- a/docs/latest/advanced/partials.md +++ b/docs/latest/advanced/partials.md @@ -208,6 +208,66 @@ Partial updates can be animated using the browser's alongside `f-client-nav` to enable smooth animated transitions between pages with zero JavaScript animation code. +## Loading indicators + +When a partial request is in flight, you may want to show a loading spinner or +disable a button. Fresh supports this through the `_freshIndicator` property. + +Attach an object with a `value` property to any element that triggers a partial +navigation. Fresh will set `value` to `true` when the request starts and back to +`false` when it completes (or fails). + +```tsx +import { useSignal } from "@preact/signals"; + +function NavLink() { + const loading = useSignal(false); + + return ( + { + if (el) el._freshIndicator = loading; + }} + > + {loading.value ? "Loading..." : "Go"} + + ); +} +``` + +This works for links, forms, and submit buttons. For form submissions, Fresh +checks the submitter element (e.g. the clicked button) first, then falls back to +the form element itself. This lets you show per-button indicators when a form +has multiple submit buttons. + +```tsx +import { useSignal } from "@preact/signals"; + +function MyForm() { + const saving = useSignal(false); + + return ( +
+ {/* indicator is on the button, not the form */} + +
+ ); +} +``` + +> [info]: Any object with a mutable `value` property works — Preact signals are +> the most convenient choice because they automatically re-render the component +> when the value changes. + ## Bypassing or disabling Partials If you want to exempt a particular element from triggering a partial request diff --git a/packages/fresh/src/runtime/client/partials.ts b/packages/fresh/src/runtime/client/partials.ts index bf8cf78aa92..77d3c3431b3 100644 --- a/packages/fresh/src/runtime/client/partials.ts +++ b/packages/fresh/src/runtime/client/partials.ts @@ -278,6 +278,14 @@ document.addEventListener("submit", async (e) => { // this check, every form inside f-client-nav would be intercepted // because el.action is always non-empty (defaults to the current URL). if (hasExplicitPartial && rawPartialUrl !== "") { + // deno-lint-ignore no-explicit-any + const indicator = ((e.submitter as any)?._freshIndicator ?? + // deno-lint-ignore no-explicit-any + (el as any)._freshIndicator) as { value: boolean } | undefined; + if (indicator !== undefined) { + indicator.value = true; + } + e.preventDefault(); const partialUrl = new URL(rawPartialUrl, location.href); @@ -295,9 +303,15 @@ document.addEventListener("submit", async (e) => { init = { body: new FormData(el, e.submitter), method: lowerMethod }; } - await withViewTransition(async () => { - await fetchPartials(actionUrl, partialUrl, true, init); - }); + try { + await withViewTransition(async () => { + await fetchPartials(actionUrl, partialUrl, true, init); + }); + } finally { + if (indicator !== undefined) { + indicator.value = false; + } + } } } }); diff --git a/packages/fresh/tests/partials_test.tsx b/packages/fresh/tests/partials_test.tsx index 965cc134c05..1ef3a78f5a8 100644 --- a/packages/fresh/tests/partials_test.tsx +++ b/packages/fresh/tests/partials_test.tsx @@ -3281,3 +3281,220 @@ Deno.test({ }); }, }); + +Deno.test({ + name: "partials - submit form indicator on button", + fn: async () => { + const app = testApp() + .get("/partial", (ctx) => { + return ctx.render( + + +

done

+
+
, + ); + }) + .get("/", (ctx) => { + return ctx.render( + +
+
+ +

init

+
+ +
+
+
, + ); + }); + + await withBrowserApp(app, async (page, address) => { + await page.goto(address, { waitUntil: "load" }); + await page.locator(".init").wait(); + + // Attach a tracking indicator to the submit button + await page.evaluate(() => { + const btn = document.querySelector(".update")!; + const indicator = { _wasTrue: false, _value: false }; + Object.defineProperty(indicator, "value", { + get() { + return this._value; + }, + set(v: boolean) { + this._value = v; + if (v) this._wasTrue = true; + }, + }); + // deno-lint-ignore no-explicit-any + (btn as any)._freshIndicator = indicator; + // deno-lint-ignore no-explicit-any + (window as any).__indicator = indicator; + }); + + await page.locator(".update").click(); + await page.locator(".done").wait(); + + const result = await page.evaluate(() => { + // deno-lint-ignore no-explicit-any + const ind = (window as any).__indicator; + return { wasTrue: ind._wasTrue, currentValue: ind.value }; + }); + expect(result.wasTrue).toBe(true); + expect(result.currentValue).toBe(false); + }); + }, +}); + +Deno.test({ + name: "partials - submit form indicator on form element", + fn: async () => { + const app = testApp() + .get("/partial", (ctx) => { + return ctx.render( + + +

done

+
+
, + ); + }) + .get("/", (ctx) => { + return ctx.render( + +
+
+ +

init

+
+ +
+
+
, + ); + }); + + await withBrowserApp(app, async (page, address) => { + await page.goto(address, { waitUntil: "load" }); + await page.locator(".init").wait(); + + // Attach indicator to the form element (not the button) + await page.evaluate(() => { + const form = document.querySelector(".myform")!; + const indicator = { _wasTrue: false, _value: false }; + Object.defineProperty(indicator, "value", { + get() { + return this._value; + }, + set(v: boolean) { + this._value = v; + if (v) this._wasTrue = true; + }, + }); + // deno-lint-ignore no-explicit-any + (form as any)._freshIndicator = indicator; + // deno-lint-ignore no-explicit-any + (window as any).__indicator = indicator; + }); + + await page.locator(".update").click(); + await page.locator(".done").wait(); + + const result = await page.evaluate(() => { + // deno-lint-ignore no-explicit-any + const ind = (window as any).__indicator; + return { wasTrue: ind._wasTrue, currentValue: ind.value }; + }); + expect(result.wasTrue).toBe(true); + expect(result.currentValue).toBe(false); + }); + }, +}); + +Deno.test({ + name: "partials - submit form indicator prefers submitter over form", + fn: async () => { + const app = testApp() + .get("/partial", (ctx) => { + return ctx.render( + + +

done

+
+
, + ); + }) + .get("/", (ctx) => { + return ctx.render( + +
+
+ +

init

+
+ +
+
+
, + ); + }); + + await withBrowserApp(app, async (page, address) => { + await page.goto(address, { waitUntil: "load" }); + await page.locator(".init").wait(); + + // Attach indicators to both form and button + await page.evaluate(() => { + function makeIndicator(name: string) { + const indicator = { _wasTrue: false, _value: false }; + Object.defineProperty(indicator, "value", { + get() { + return this._value; + }, + set(v: boolean) { + this._value = v; + if (v) this._wasTrue = true; + }, + }); + // deno-lint-ignore no-explicit-any + (window as any)[name] = indicator; + return indicator; + } + + const btn = document.querySelector(".update")!; + const form = document.querySelector(".myform")!; + // deno-lint-ignore no-explicit-any + (btn as any)._freshIndicator = makeIndicator("__btnIndicator"); + // deno-lint-ignore no-explicit-any + (form as any)._freshIndicator = makeIndicator("__formIndicator"); + }); + + await page.locator(".update").click(); + await page.locator(".done").wait(); + + const result = await page.evaluate(() => { + // deno-lint-ignore no-explicit-any + const w = window as any; + return { + btnWasTrue: w.__btnIndicator._wasTrue, + btnCurrent: w.__btnIndicator.value, + formWasTrue: w.__formIndicator._wasTrue, + formCurrent: w.__formIndicator.value, + }; + }); + // Submitter indicator should have been toggled + expect(result.btnWasTrue).toBe(true); + expect(result.btnCurrent).toBe(false); + // Form indicator should NOT have been used + expect(result.formWasTrue).toBe(false); + expect(result.formCurrent).toBe(false); + }); + }, +});