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:
2026-08-13 15:46:00 +05:30
co-authored by Claude Opus 5
parent 2e42be7b31
commit 5afb2d1875
6 changed files with 210 additions and 35 deletions
+37 -1
View File
@@ -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.
+2 -2
View File
@@ -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 })`
+21
View File
@@ -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",
+88 -28
View File
@@ -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">&#8249;</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">&#8249;</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">&#8249;</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">&#8250;</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">&#8250;</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">&#8250;</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);
+58
View File
@@ -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 });