fix(ui): make Pagination usable server-rendered, surface output errors
Pagination reported the requested page only through its change output, so on a server-rendered page the controls did nothing: the parent had to own client state to react, and the emitted page never became a URL. An optional hrefTemplate now renders the steps and page numbers as anchors, which work before hydration and without JavaScript and give each page a crawlable URL. Buttons remain the default for client-owned lists. End steps are clamped and marked disabled rather than linking past the first or last page. Output handler errors are no longer swallowed. Nothing awaits invokeOutput, so a handler that threw became an unhandled rejection that never reached the console and presented as a control that silently does nothing. Handler errors are now reported as WRN-DEV-OUTPUT-HANDLER-ERROR. Regenerates the component reference and the UI visual contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@
|
||||
"packages/ui/components/FeatureIconCard.wrn": "3b4f3fa62c6729e686886a5b848e26c255766684829cec2b715967b4e73d6f07",
|
||||
"packages/ui/components/FileInput.wrn": "866a292a3280527bf429893469e7c50f7cf38965d8adb37b45a35ede1606d5b8",
|
||||
"packages/ui/components/FileUploadProgress.wrn": "e5da29c562a521cdd6bb50b8f4d217aa1ab981d7b2a2432af47391472f0a8034",
|
||||
"packages/ui/components/Footer.wrn": "6694302f63f44dda3527d97b1bcc3031d51362601835dbe842cb04ba70088758",
|
||||
"packages/ui/components/Footer.wrn": "c498820a160c1286331a423a4498054e7852d2f1a9eb6e81eb5b008b1693efc4",
|
||||
"packages/ui/components/Grid.wrn": "83d4f5f2656d539538723f5791ba3c238901432e9d8c55c74f068d3eb5a74517",
|
||||
"packages/ui/components/Hero.wrn": "345478b212701817ff57060f87982a987baf01a7179c979768e1cd4218b50900",
|
||||
"packages/ui/components/HeroActions.wrn": "67cd31400ccb6abdbbf16219267dee946b44b064b79f25276d20ceb7d6e8a790",
|
||||
@@ -62,11 +62,11 @@
|
||||
"packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a",
|
||||
"packages/ui/components/Navbar.wrn": "ed3bb1974b93d52c480af468bdb1c0b253cb9196f3a00602614d3ce1e271a669",
|
||||
"packages/ui/components/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54",
|
||||
"packages/ui/components/Pagination.wrn": "4c7ac4bbda38c791af503f7923a511a958adc01637d286bcaaaa0f00ae8bb0fd",
|
||||
"packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39",
|
||||
"packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602",
|
||||
"packages/ui/components/Popover.wrn": "1246b99b4236bc7109a5d1427d013bb07bbea7a2da4f821517d2545ee0b5c019",
|
||||
"packages/ui/components/PortalDashboard.wrn": "f04171eb1f8cabef06de3c20e4694b766def3407e1e20d2c9aaebc9bb7da03ee",
|
||||
"packages/ui/components/PreferenceSwitcher.wrn": "71fc4b2fd8897dbd2e0795a6a824d76a57a4ce8756a889662f26df28b73c411a",
|
||||
"packages/ui/components/PreferenceSwitcher.wrn": "77229e15449182563ebf49159507139928eb74e799e4438e00e9aa81fd60f982",
|
||||
"packages/ui/components/PublicPageShell.wrn": "3d3f25c03d48f3d3d7e3c567e79cb137c7fce1867e054fd6d440fa1d2e961077",
|
||||
"packages/ui/components/RangeSlider.wrn": "bbf646c5d5a89a62986290db811d77b8f635ebcaea9ca2d334b569aa5edc785b",
|
||||
"packages/ui/components/Rating.wrn": "902b55c64e3798f3097f09b1558d1a2372f66d4f760662de085783108ee04587",
|
||||
@@ -88,7 +88,7 @@
|
||||
"packages/ui/components/ToggleCount.wrn": "e577195ee05ddb43cceec47145f6b90c0149f2f17055733d58911fcd02de2c7f",
|
||||
"packages/ui/components/TogglePassword.wrn": "b6176453cb2c586fe88c3db6bdd734ae2b4c95db46d57e0de298dfba3d0dd0c2",
|
||||
"packages/ui/components/TreeView.wrn": "f54d8495730203b923629bcfb167d5ca41236af0101ea2723d33354413555b4b",
|
||||
"packages/ui/components/Typography.wrn": "a3de0febe0ba8640660e7f22babe8ca0e75e1b2882ba7be42f0728add7e05740",
|
||||
"packages/ui/components/Typography.wrn": "f9c1a379febb050f33f8dbfdf2fa483e686921fa762dd50505d2b673bf2c22e1",
|
||||
"packages/ui/components/WysiwygEditor.wrn": "3f9d1765184581e3c1f335769a9f19969917450258bc95ba076980d99f5610b5",
|
||||
"packages/ui/components/alert.wrn": "d42287e41b918b1e19962e9462ef203002eb5480acac984f115b711e9dbe663d",
|
||||
"packages/ui/components/avatar.wrn": "8bc705e459b13c7f063c57e1506ce540e50fa8ef9020ae85b6e8b6a3579fc5dc",
|
||||
|
||||
@@ -18,10 +18,46 @@ export function registerOutputHandler<T>(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a throwing output handler.
|
||||
*
|
||||
* Callers of `invokeOutput` do not await it — an output call is fire-and-forget
|
||||
* from the component's point of view. A handler that throws therefore becomes an
|
||||
* unhandled rejection that never reaches the console, and the symptom is a
|
||||
* control that silently does nothing. Reporting here turns that into a named
|
||||
* diagnostic instead of a dead button.
|
||||
*/
|
||||
function reportOutputError(host: OutputHost, name: string, error: unknown): void {
|
||||
const code = "WRN-DEV-OUTPUT-HANDLER-ERROR";
|
||||
const message = `The handler bound to output '${name}' threw. The control will appear to do nothing.`;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${code}] ${message}`, error);
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("wrnexus:diagnostic", {
|
||||
detail: {
|
||||
code,
|
||||
message,
|
||||
hydrationId: host.getAttribute ? host.getAttribute("data-wrn-hydration") : null,
|
||||
detail: { output: name, error: String(error) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// CustomEvent can be unavailable in minimal DOM test environments.
|
||||
}
|
||||
}
|
||||
|
||||
export async function invokeOutput<T>(host: OutputHost, name: string, payload?: T): Promise<void> {
|
||||
const handlers = host.__wrnexusOutputHandlers?.get(name);
|
||||
if (handlers?.size) {
|
||||
for (const handler of handlers) await handler(payload);
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
await handler(payload);
|
||||
} catch (error) {
|
||||
reportOutputError(host, name, error);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Compatibility path for legacy listeners outside a hydrated WRN parent.
|
||||
|
||||
@@ -63,7 +63,7 @@ Theme-aware, responsive toggle count component.
|
||||
Accessible password field with optional show and hide controls.
|
||||
|
||||
- Mount: `data-component="TogglePassword"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Password"`, `name: string = "password"`, `value: string = ""`, `placeholder: string = "Enter your password"`, `autocomplete: string = "current-password"`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = "(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}"`, `fields: unknown[] = []`, `visible: boolean = false`, `toggleable: boolean = true`, `toggleMode: string = "button"`, `checkboxLabel: string = "Show password"`, `showLabel: string = "Show password"`, `hideLabel: string = "Hide password"`, `disabled: boolean = false`, `readonly: boolean = false`, `required: boolean = false`, `invalid: boolean = false`, `helpText: string = ""`, `validationMessage: string = ""`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Password"`, `name: string = "password"`, `value: string = ""`, `placeholder: string = "Enter your password"`, `autocomplete: string = "current-password"`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = "(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}"`, `fields: unknown[] = []`, `visible: boolean = false`, `toggleable: boolean = true`, `toggleMode: string = "button"`, `checkboxLabel: string = "Show password"`, `showLabel: string = "Show password"`, `hideLabel: string = "Hide password"`, `disabled: boolean = false`, `readonly: boolean = false`, `required: boolean = false`, `invalid: boolean = false`, `helpText: string = ""`, `validationMessage: string = ""`, `cornerHint: string = ""`, `cornerHref: string = ""`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ name?: string; value: string | number | boolean | null | object; visible: boolean })`, `change({ name?: string; value: string | number | boolean | null | object; visible: boolean })`, `toggle({ visible: boolean })`
|
||||
|
||||
@@ -835,7 +835,7 @@ Theme-aware, responsive navbar component.
|
||||
Theme-aware, responsive pagination component.
|
||||
|
||||
- Mount: `data-component="Pagination"`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `page: number = 1`, `pageSize: number = 10`, `total: number = 0`, `variant: string = "compact"`, `siblingCount: number = 1`, `showSummary: boolean = true`, `label: string = "Pagination"`, `previousLabel: string = "Previous"`, `nextLabel: string = "Next"`, `class: string = ""`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `page: number = 1`, `pageSize: number = 10`, `total: number = 0`, `variant: string = "compact"`, `siblingCount: number = 1`, `showSummary: boolean = true`, `label: string = "Pagination"`, `previousLabel: string = "Previous"`, `nextLabel: string = "Next"`, `hrefTemplate: string = ""`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `change({ page: number; pageSize: number })`, `previous({ page: number })`, `next({ page: number })`
|
||||
|
||||
|
||||
@@ -9203,6 +9203,13 @@
|
||||
"default": "\"Next\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "hrefTemplate",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
@@ -13262,6 +13269,20 @@
|
||||
"default": "\"\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "cornerHint",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "cornerHref",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
|
||||
@@ -26,6 +26,12 @@ component Pagination {
|
||||
label: string = "Pagination"
|
||||
previousLabel: string = "Previous"
|
||||
nextLabel: string = "Next"
|
||||
// Render links instead of buttons. `{page}` is replaced with the target
|
||||
// page number, e.g. hrefTemplate="/media/news?page={page}". Server-rendered
|
||||
// pages should prefer this: the controls then work before hydration and
|
||||
// without JavaScript, and each page is a real, crawlable URL. Buttons plus
|
||||
// the change output remain the default for client-owned lists.
|
||||
hrefTemplate: string = ""
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
@@ -46,6 +52,11 @@ component Pagination {
|
||||
return Math.min(value, lastPage())
|
||||
}
|
||||
|
||||
shared function pageHref(target) {
|
||||
var clamped = Math.min(Math.max(1, Number(target) || 1), lastPage())
|
||||
return String(hrefTemplate).split("{page}").join(String(clamped))
|
||||
}
|
||||
|
||||
shared function firstShown() {
|
||||
if (Number(total) < 1) {
|
||||
return 0
|
||||
@@ -116,30 +127,55 @@ component Pagination {
|
||||
</p>
|
||||
|
||||
<div class="wrn-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-pagination__step"
|
||||
aria-label='{previousLabel}'
|
||||
@click='goPrevious()'
|
||||
>
|
||||
<span class="wrn-pagination__step-icon" aria-hidden="true">‹</span>
|
||||
<span class="wrn-pagination__step-label">{previousLabel}</span>
|
||||
</button>
|
||||
{#if hrefTemplate}
|
||||
<a
|
||||
class="wrn-pagination__step"
|
||||
aria-label='{previousLabel}'
|
||||
href='{pageHref(currentPage() - 1)}'
|
||||
aria-disabled='{currentPage() <= 1}'
|
||||
data-disabled='{currentPage() <= 1}'
|
||||
>
|
||||
<span class="wrn-pagination__step-icon" aria-hidden="true">‹</span>
|
||||
<span class="wrn-pagination__step-label">{previousLabel}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-pagination__step"
|
||||
aria-label='{previousLabel}'
|
||||
@click='goPrevious()'
|
||||
>
|
||||
<span class="wrn-pagination__step-icon" aria-hidden="true">‹</span>
|
||||
<span class="wrn-pagination__step-label">{previousLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<ol class="wrn-pagination__pages" data-show="variant === 'numbered'">
|
||||
{#each pageNumbers() as entry}
|
||||
<li class="wrn-pagination__slot">
|
||||
<span class="wrn-pagination__gap" data-show="entry.gap">{entry.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-pagination__page"
|
||||
data-show="!entry.gap"
|
||||
data-active='{entry.value === currentPage()}'
|
||||
aria-current='{entry.value === currentPage() ? "page" : "false"}'
|
||||
@click='goToPage(entry.value)'
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
{#if hrefTemplate}
|
||||
<a
|
||||
class="wrn-pagination__page"
|
||||
data-show="!entry.gap"
|
||||
data-active='{entry.value === currentPage()}'
|
||||
aria-current='{entry.value === currentPage() ? "page" : "false"}'
|
||||
href='{pageHref(entry.value)}'
|
||||
>
|
||||
{entry.label}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-pagination__page"
|
||||
data-show="!entry.gap"
|
||||
data-active='{entry.value === currentPage()}'
|
||||
aria-current='{entry.value === currentPage() ? "page" : "false"}'
|
||||
@click='goToPage(entry.value)'
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -148,15 +184,28 @@ component Pagination {
|
||||
{currentPage()} / {lastPage()}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-pagination__step"
|
||||
aria-label='{nextLabel}'
|
||||
@click='goNext()'
|
||||
>
|
||||
<span class="wrn-pagination__step-label">{nextLabel}</span>
|
||||
<span class="wrn-pagination__step-icon" aria-hidden="true">›</span>
|
||||
</button>
|
||||
{#if hrefTemplate}
|
||||
<a
|
||||
class="wrn-pagination__step"
|
||||
aria-label='{nextLabel}'
|
||||
href='{pageHref(currentPage() + 1)}'
|
||||
aria-disabled='{currentPage() >= lastPage()}'
|
||||
data-disabled='{currentPage() >= lastPage()}'
|
||||
>
|
||||
<span class="wrn-pagination__step-label">{nextLabel}</span>
|
||||
<span class="wrn-pagination__step-icon" aria-hidden="true">›</span>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-pagination__step"
|
||||
aria-label='{nextLabel}'
|
||||
@click='goNext()'
|
||||
>
|
||||
<span class="wrn-pagination__step-label">{nextLabel}</span>
|
||||
<span class="wrn-pagination__step-icon" aria-hidden="true">›</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
@@ -240,6 +289,7 @@ component Pagination {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrn-pagination__page:hover,
|
||||
@@ -247,6 +297,16 @@ component Pagination {
|
||||
background: var(--wrn-color-surface-soft);
|
||||
}
|
||||
|
||||
/* The href variant renders anchors, which cannot be disabled the way a
|
||||
button can. At the first or last page the control is marked disabled and
|
||||
made inert so it neither responds nor takes focus. */
|
||||
.wrn-pagination__page[data-disabled="true"],
|
||||
.wrn-pagination__step[data-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wrn-pagination__page:focus-visible,
|
||||
.wrn-pagination__step:focus-visible {
|
||||
outline: 2px solid var(--pagination-accent);
|
||||
|
||||
@@ -2558,6 +2558,64 @@ test("pagination clamps an out-of-range page instead of rendering nothing", asyn
|
||||
expect(current!.textContent).toContain("3");
|
||||
});
|
||||
|
||||
test("pagination renders links when given an href template", async () => {
|
||||
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
total: 50,
|
||||
variant: "numbered",
|
||||
hrefTemplate: "/news?page={page}",
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
|
||||
// Server-rendered navigation must be anchors, so the controls work before
|
||||
// hydration and without JavaScript.
|
||||
expect(dom.querySelector(".wrn-pagination__step button")).toBeNull();
|
||||
const steps = [...dom.querySelectorAll("a.wrn-pagination__step")] as HTMLElement[];
|
||||
expect(steps.length).toBe(2);
|
||||
expect(steps[0]!.getAttribute("href")).toBe("/news?page=1");
|
||||
expect(steps[1]!.getAttribute("href")).toBe("/news?page=3");
|
||||
|
||||
const pages = [...dom.querySelectorAll("a.wrn-pagination__page")] as HTMLElement[];
|
||||
expect(pages.some((page) => page.getAttribute("href") === "/news?page=5")).toBe(true);
|
||||
});
|
||||
|
||||
test("pagination href steps are disabled and clamped at both ends", async () => {
|
||||
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
|
||||
const first = mountHtml(
|
||||
await renderComponent(source, {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 30,
|
||||
hrefTemplate: "/news?page={page}",
|
||||
}),
|
||||
);
|
||||
const previous = first.querySelector("a.wrn-pagination__step") as HTMLElement;
|
||||
expect(previous.getAttribute("data-disabled")).toBe("true");
|
||||
// Clamped rather than linking to page 0.
|
||||
expect(previous.getAttribute("href")).toBe("/news?page=1");
|
||||
|
||||
const last = mountHtml(
|
||||
await renderComponent(source, {
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
total: 30,
|
||||
hrefTemplate: "/news?page={page}",
|
||||
}),
|
||||
);
|
||||
const steps = [...last.querySelectorAll("a.wrn-pagination__step")] as HTMLElement[];
|
||||
expect(steps[1]!.getAttribute("data-disabled")).toBe("true");
|
||||
expect(steps[1]!.getAttribute("href")).toBe("/news?page=3");
|
||||
});
|
||||
|
||||
test("pagination still renders buttons without an href template", async () => {
|
||||
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
|
||||
const dom = mountHtml(await renderComponent(source, { page: 2, pageSize: 10, total: 50 }));
|
||||
expect(dom.querySelectorAll("button.wrn-pagination__step").length).toBe(2);
|
||||
expect(dom.querySelector("a.wrn-pagination__step")).toBeNull();
|
||||
});
|
||||
|
||||
test("pagination survives an empty dataset", async () => {
|
||||
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
|
||||
const html = await renderComponent(source, { page: 1, pageSize: 10, total: 0 });
|
||||
|
||||
Reference in New Issue
Block a user