diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json
index b7ac6f43..f41f339d 100644
--- a/docs/ui-visual-contract-0.8.json
+++ b/docs/ui-visual-contract-0.8.json
@@ -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",
diff --git a/packages/csr/src/outputs.ts b/packages/csr/src/outputs.ts
index aec6cc3d..da93c152 100644
--- a/packages/csr/src/outputs.ts
+++ b/packages/csr/src/outputs.ts
@@ -18,10 +18,46 @@ export function registerOutputHandler(
};
}
+/**
+ * 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(host: OutputHost, name: string, payload?: T): Promise {
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.
diff --git a/packages/ui/COMPONENTS.md b/packages/ui/COMPONENTS.md
index d91b3fb7..73e51ed8 100644
--- a/packages/ui/COMPONENTS.md
+++ b/packages/ui/COMPONENTS.md
@@ -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 })`
diff --git a/packages/ui/component-reference.json b/packages/ui/component-reference.json
index 9fa6cdf1..1dbaad30 100644
--- a/packages/ui/component-reference.json
+++ b/packages/ui/component-reference.json
@@ -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",
diff --git a/packages/ui/components/Pagination.wrn b/packages/ui/components/Pagination.wrn
index dd4b80fd..3bff948c 100644
--- a/packages/ui/components/Pagination.wrn
+++ b/packages/ui/components/Pagination.wrn
@@ -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 {
@@ -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);
diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts
index 238afa04..74a10a2b 100644
--- a/packages/ui/test/ui.test.ts
+++ b/packages/ui/test/ui.test.ts
@@ -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 });