Refresh UI components and showcase; release updated framework packages

This commit is contained in:
Clintchiz committed 2026-09-20 18:13:24 +05:30
1 parent d800228585
commit c77f5e1cec
293 files changed
+22544 -35778

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.8.32",
"version": "0.8.33",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
+3 -1
View File
@@ -2081,7 +2081,9 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return verification;
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
const deviceCheck = await newDeviceRequirement(user, {
const providerVerifiesDevice = verifiedEmail &&
options.verifiedOAuthDeviceProviders?.some((name) => normalizedOAuthProvider(name) === providerName);
const deviceCheck = providerVerifiesDevice ? undefined : await newDeviceRequirement(user, {
...sessionInput,
oauthProvider: providerName,
});
+1
View File
@@ -28,6 +28,7 @@ export {
} from "./http/index.ts";
export {
authPlugin,
configureAuthRuntime,
authComponentsDir,
validateProductionAuthConfig,
type AuthConfig,
+21 -21
View File
@@ -207,6 +207,26 @@ function resolveConfig(
};
}
/** Restore auth state in a production server using its runtime configuration. */
export function configureAuthRuntime(config: AuthConfig): void {
applyRuntimeConfig(resolveConfig({ auth: config }, {}));
}
function applyRuntimeConfig(value: ResolvedAuthConfig): void {
if (value.enabled && value.engine) setDefaultAuthEngine(value.engine);
else clearDefaultAuthEngine();
setDefaultAuthSchemas(value.schemas);
setDefaultAuthRouteOptions({
baseUrl: value.baseUrl,
csrf: value.csrf,
passkey: value.passkey,
sessionCookieName: value.sessionCookieName,
onSessionVerification: value.onSessionVerification,
oauth: value.enabled ? value.oauth : {},
oauthMfaPath: value.oauthMfaPath,
});
}
function fallbackConfig(options: AuthPluginOptions): ResolvedAuthConfig {
return {
enabled: true,
@@ -432,27 +452,7 @@ export function authPlugin(options: AuthPluginOptions = {}) {
schemas: value.schemas,
};
if (value.engine) {
setDefaultAuthEngine(value.engine);
} else {
clearDefaultAuthEngine();
}
setDefaultAuthSchemas(value.schemas);
setDefaultAuthRouteOptions({
baseUrl: value.baseUrl,
csrf: value.csrf,
passkey: value.passkey,
sessionCookieName: value.sessionCookieName,
onSessionVerification: value.onSessionVerification,
oauth: value.oauth,
oauthMfaPath: value.oauthMfaPath,
});
applyRuntimeConfig(value);
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
+2
View File
@@ -440,6 +440,8 @@ export interface AuthEngineOptions {
trustedDeviceTtlMs?: number;
/** Require verified email/SMS confirmation before a new browser receives a session. */
requireTrustedDeviceVerification?: boolean;
/** Verified email from these OAuth providers satisfies the additional new-device check. Account MFA still applies. */
verifiedOAuthDeviceProviders?: string[];
/** Remember the registration browser after required account verification succeeds. */
trustSignupDevice?: boolean;
tokenTtlMs?: Partial<Record<AuthTokenPurpose, number>>;
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test";
import { createAuthEngine } from "../src/engine.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
const profile = { id: "google-user", email: "google@example.test", raw: { email_verified: true } };
function setup(providers?: string[]) {
const store = new MemoryAuthStore();
const engine = createAuthEngine({ store, secret: "oauth-device-policy-test-at-least-32-characters",
requireVerifiedEmail: true, requireTrustedDeviceVerification: true,
verifiedOAuthDeviceProviders: providers,
isOAuthEmailVerified: (_provider, value) => value.raw.email_verified === true,
});
return { engine, store };
}
test("explicit verified Google policy satisfies the device check without trusting future password logins", async () => {
const { engine } = setup(["google"]);
const result = await engine.loginWithOAuth("google", profile, undefined, { fingerprint: "new-browser" });
expect(result.ok).toBe(true);
expect(result.session).toBeDefined();
expect(await engine.listTrustedDevices(result.user!.id)).toHaveLength(0);
});
test("device verification remains the default and applies to other providers", async () => {
expect((await setup().engine.loginWithOAuth("google", profile)).code).toBe("mfa-required");
expect((await setup(["google"]).engine.loginWithOAuth("other", profile)).code).toBe("mfa-required");
});
test("unverified provider emails cannot satisfy verification", async () => {
const { engine } = setup(["google"]);
const result = await engine.loginWithOAuth("google", { ...profile, raw: { email_verified: false } });
expect(result.ok).toBe(false);
expect(result.session).toBeUndefined();
});
test("explicit account MFA remains required after verified Google sign-in", async () => {
const { engine } = setup(["google"]);
const first = await engine.loginWithOAuth("google", profile);
await engine.generateRecoveryCodes(first.user!.id);
const result = await engine.loginWithOAuth("google", profile);
expect(result.code).toBe("mfa-required");
expect(result.session).toBeUndefined();
expect(result.requires?.mfa).toContain("recovery-code");
});
@@ -0,0 +1,43 @@
import { afterEach, expect, test } from "bun:test";
import { createContext } from "@wrnexus/core";
import { createAuthEngine } from "../src/engine.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import { configureAuthRuntime } from "../src/plugin.ts";
import { clearDefaultAuthEngine, getDefaultAuthRouteOptions, setDefaultAuthRouteOptions } from "../src/runtime.ts";
import { invokeAuthHandler } from "../src/routes/api.ts";
afterEach(() => { clearDefaultAuthEngine(); setDefaultAuthRouteOptions({}); });
test("production startup restores runtime-only providers and passkey settings for generated routes", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(), secret: "production-runtime-test-secret-at-least-32-characters",
passkeys: {
async authenticationOptions({ rpId }) { return { challenge: "test-challenge", rpId, timeout: 60000, userVerification: "required" }; },
async registrationOptions() { throw new Error("not used"); },
async verifyRegistration() { return { verified: false }; },
async verifyAuthentication() { return { verified: false }; },
},
});
// Build-time credentials absent, followed by server-startup runtime secrets.
configureAuthRuntime({ engine, oauth: {} });
const ctx = createContext(new Request("https://sso.example/api/auth/oauth/google"), new URL("https://sso.example/api/auth/oauth/google"));
ctx.params = { provider: "google" };
expect((await invokeAuthHandler("startOAuth", ctx)).status).toBe(404);
configureAuthRuntime({ engine, baseUrl: "https://sso.example", sessionCookieName: "custom_session",
oauth: { google: { clientId: "runtime-client", clientSecret: "runtime-secret" } },
passkey: { rpId: "sso.example", origin: "https://sso.example" }, oauthMfaPath: "/mfa" });
const response = await invokeAuthHandler("startOAuth", ctx);
expect(response.status).toBe(302);
const location = new URL(response.headers.get("location")!);
expect(location.hostname).toBe("accounts.google.com");
expect(location.searchParams.get("client_id")).toBe("runtime-client");
expect(location.searchParams.get("redirect_uri")).toBe("https://sso.example/api/auth/oauth/google/callback");
expect(getDefaultAuthRouteOptions().sessionCookieName).toBe("custom_session");
const request = new Request("https://sso.example/api/auth/passkeys/login/options", { method: "POST",
headers: { "content-type": "application/json", origin: "https://sso.example", cookie: "wrn-csrf=runtime-test-csrf", "x-csrf-token": "runtime-test-csrf" }, body: "{}" });
const result = await invokeAuthHandler("passkeyAuthenticationOptions", createContext(request, new URL(request.url)));
expect(result.status).toBe(200);
expect((await result.json()).options.rpId).toBe("sso.example");
configureAuthRuntime({ enabled: false, engine });
expect((await invokeAuthHandler("startOAuth", ctx)).status).toBe(503);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.70",
"version": "0.8.71",
"type": "module",
"main": "src/index.ts",
"exports": {
+14 -3
View File
@@ -60,6 +60,7 @@ import { loadLocales, resolveI18n } from "@wrnexus/i18n";
import { pathToFileURL } from "node:url";
import { checkPerformanceBudgets } from "@wrnexus/core";
import { currentCliVersion } from "./update-notifier.ts";
import { shouldInlineCss } from "./inline-css.ts";
import {
createPluginRunner,
discoverPlugins,
@@ -73,7 +74,6 @@ import {
// the generated entry resolves whether @wrnexus/dev-server is a workspace or an
// installed dependency. Bun.build bundles it into a self-contained server.js.
const PROD_MODULE = "@wrnexus/dev-server";
const INLINE_CSS_LIMIT_BYTES = 4096;
const fwd = (p: string) => p.replace(/\\/g, "/");
@@ -648,7 +648,7 @@ export async function runBuild(appRoot: string): Promise<void> {
// theme stylesheet is selected from the request cookie and loaded first.
writeFileSync(join(distDir, "styles.css"), combinedCss, "utf8");
hasStyles = true;
if (Buffer.byteLength(combinedCss, "utf8") <= INLINE_CSS_LIMIT_BYTES) {
if (shouldInlineCss(combinedCss, config.styles?.inlineMaxBytes)) {
inlineStyles = combinedCss;
}
console.log(`✓ Styles: ${join(distDir, "styles.css")}`);
@@ -709,7 +709,16 @@ applyAuthzManifestEarly([${authzSetupEntries}]);
const runtimeConfigPath = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"]
.map((name) => resolve(appRoot, name))
.find(existsSync);
if (runtimeConfigPath) imports.push(`import ${JSON.stringify(fwd(runtimeConfigPath))};`);
if (runtimeConfigPath && config.auth) {
// Importing config creates the engine, but does not run plugin.configure.
// Initialize route options before route/middleware modules are evaluated.
// Credentials are read by the config at server startup, never serialized here.
writeFileSync(join(distDir, ".auth-runtime.ts"),
`import config from ${JSON.stringify(fwd(runtimeConfigPath))};\n` +
`import { configureAuthRuntime } from "@wrnexus/auth/plugin";\n` +
`configureAuthRuntime(config.auth);\n`, "utf8");
imports.push(`import "./.auth-runtime.ts";`);
} else if (runtimeConfigPath) imports.push(`import ${JSON.stringify(fwd(runtimeConfigPath))};`);
const manifestRoutes = (routes: Route[]): string => {
const parts = routes.map((r) => {
@@ -796,6 +805,7 @@ applyAuthzManifestEarly([${authzSetupEntries}]);
const entry = `// AUTO-GENERATED production server entry — do not edit.
import { join } from "node:path";
import { createProductionServer } from ${JSON.stringify(PROD_MODULE)};
import { productionConnections } from "@wrnexus/cli/runtime-connections";
${imports.join("\n")}
await createProductionServer(
@@ -855,6 +865,7 @@ await createProductionServer(
tenancy: ${JSON.stringify(config.tenancy ?? {})},
navigation: ${JSON.stringify(config.navigation ?? {})},
developmentRuntime: process.env.WRNEXUS_PRODUCTION_DEV === "1",
...productionConnections(),
},
);
`;
+14
View File
@@ -0,0 +1,14 @@
/** Keep the default conservative; sites can opt in for a measured first-load improvement. */
export function shouldInlineCss(css: string, configuredLimit: number | undefined): boolean {
const limit = configuredLimit ?? 4096;
if (!Number.isSafeInteger(limit) || limit < 0) {
throw new Error("styles.inlineMaxBytes must be a non-negative safe integer");
}
if (!limit || Buffer.byteLength(css, "utf8") > limit) return false;
// Moving CSS into HTML must not change the base of relative asset URLs.
for (const match of css.matchAll(/url\(\s*([^)]+)\s*\)/gi)) {
const url = match[1]!.trim().replace(/^["']|["']$/g, "");
if (!/^(?:\/|https?:|data:)/i.test(url)) return false;
}
return true;
}
+14
View File
@@ -0,0 +1,14 @@
/** Deployment-time connection overrides for immutable production images. */
export function productionConnections(value = process.env.WRNEXUS_RUNTIME_CONNECTIONS) {
if (!value) return {};
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error("WRNEXUS_RUNTIME_CONNECTIONS must be a JSON object");
const allowed = new Set(["db", "databases", "realtime", "queue", "storage"]);
for (const key of Object.keys(parsed)) {
if (!allowed.has(key)) throw new Error(`Unsupported runtime connection: ${key}`);
if (!parsed[key] || typeof parsed[key] !== "object" || Array.isArray(parsed[key]))
throw new Error(`Runtime connection ${key} must be an object`);
}
return parsed;
}
+20
View File
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test";
import { shouldInlineCss } from "../src/inline-css.ts";
test("production CSS inlining uses a configurable UTF-8 byte budget", () => {
expect(shouldInlineCss("x".repeat(4096), undefined)).toBe(true);
expect(shouldInlineCss("x".repeat(4097), undefined)).toBe(false);
expect(shouldInlineCss("x".repeat(5000), 6000)).toBe(true);
expect(shouldInlineCss("é".repeat(10), 10)).toBe(false);
expect(shouldInlineCss("", 0)).toBe(false);
for (const value of [-1, NaN, Infinity, 1.5]) expect(() => shouldInlineCss("", value)).toThrow();
});
test("relative asset URLs stay external so their base does not change", () => {
for (const url of ['./font.woff2', '../font.woff2', 'font.woff2', '#mask']) {
expect(shouldInlineCss(`a{background:url("${url}")}`, undefined)).toBe(false);
}
for (const url of ['/font.woff2', 'https://cdn.example/font.woff2', 'data:image/svg+xml;base64,AAAA']) {
expect(shouldInlineCss(`a{background:url('${url}')}`, undefined)).toBe(true);
}
});
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test";
import { productionConnections } from "../src/runtime-connections.ts";
test("deployment connections override build placeholders without accepting server settings", () => {
const db = { driver: "postgres", url: "postgres://runtime:secret@db/app" };
expect(productionConnections(JSON.stringify({ db, realtime: { scale: true } }))).toEqual({ db, realtime: { scale: true } });
expect(productionConnections("")).toEqual({});
for (const value of ["null", "[]", "invalid", '{"port":80}', '{"db":null}', '{"db":[]}'])
expect(() => productionConnections(value)).toThrow();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.8.29",
"version": "0.8.30",
"type": "module",
"main": "src/index.ts",
"exports": {
@@ -211,9 +211,7 @@ function __wrnRaw(v: unknown): string {
return String(v == null ? "" : v);
}
function __wrnexusEncodeControl(value: unknown): string {
return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64");
}
import { encodeControlTemplate as __wrnexusEncodeControl } from "@wrnexus/core";
function __wrnexusSerializeScopeValue(value: unknown): string {
if (value === undefined) {
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test";
import { compile } from "../src/index.ts";
import { runWithRequestContext } from "@wrnexus/core";
import { renderDocument } from "@wrnexus/ssr";
test("deep conditional components render each branch once with bounded document size", async () => {
let view = "<p>Leaf</p>";
for (let i = 0; i < 12; i++) view = `{#if open}${view}{:else}<i>Closed</i>{/if}`;
const source = `component Nested {\n state open = true\n view { ${view} }\n }`;
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(compile(source, "Nested.wrn").code);
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
runWithRequestContext({} as never, () => {
const body = mod.render({});
const document = renderDocument({ meta: {}, body });
expect(body).toContain("<p>Leaf</p>");
expect(document.length).toBeLessThan(30000);
const registry = JSON.parse(document.match(/data-wrn-controls[^>]*>(.*?)<\/script>/s)![1]!);
expect(registry).toHaveLength(12);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.8.15",
"version": "0.8.16",
"type": "module",
"main": "src/index.ts",
"exports": {
+3
View File
@@ -120,6 +120,9 @@ export function createContext<Params extends Record<string, string> = Record<str
req: Request,
url: URL,
): Context<Params> {
// The caller supplies the validated public URL (including trusted proxies).
// Request-based handlers must see the same origin as ctx.url.
if (req.url !== url.href) req = new Request(url, req);
const cookies = createCookieStore(req);
return {
req,
+26
View File
@@ -0,0 +1,26 @@
import { getRequestContext } from "./request-context.ts";
import { Buffer } from "node:buffer";
type Registry = { id: string; definitions: unknown[] };
const registries = new WeakMap<object, Registry>();
/** Hoist nested templates out of each other for linear-size hydration data. */
export function encodeControlTemplate(value: unknown): string {
const ctx = getRequestContext();
if (!ctx) return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
let registry = registries.get(ctx);
if (!registry) {
registry = { id: `wrn-controls-${crypto.randomUUID()}`, definitions: [] };
registries.set(ctx, registry);
}
const index = registry.definitions.push(value) - 1;
return `@${registry.id}:${index}`;
}
export function renderControlTemplates(): string {
const ctx = getRequestContext();
const registry = ctx && registries.get(ctx);
if (!registry) return "";
const json = JSON.stringify(registry.definitions).replace(/</g, "\\u003c");
return `<script type="application/json" data-wrn-controls id="${registry.id}">${json}</script>`;
}
+1
View File
@@ -237,3 +237,4 @@ export type {
IdempotencyRecord,
IdempotencyStore,
} from "./platform.ts";
export { encodeControlTemplate, renderControlTemplates } from "./control-templates.ts";
@@ -0,0 +1,43 @@
import { expect, test } from "bun:test";
import { encodeControlTemplate, renderControlTemplates } from "../src/control-templates.ts";
import { runWithRequestContext } from "../src/request-context.ts";
import { createContext } from "../src/context.ts";
import { resolveRequestUrl } from "../src/headers.ts";
test("nested controls use short references and HTML-safe isolated registries", async () => {
const results = await Promise.all(["first", "second"].map((name) =>
runWithRequestContext({} as never, async () => {
let body = `</script><p>${name}</p>`;
for (let i = 0; i < 30; i++) {
body = `<template data-wrn-if="${encodeControlTemplate([{cond: null, body}])}"></template>`;
}
await Promise.resolve();
const html = renderControlTemplates();
expect(html.length).toBeLessThan(7000);
expect(html.match(/<\/script>/g)).toHaveLength(1);
return html;
}),
));
expect(results[0]).toContain("first");
expect(results[0]).not.toContain("second");
expect(results[1]).not.toContain("first");
expect(renderControlTemplates()).toBe("");
});
test("standalone rendering keeps the base64 format", () => {
expect(JSON.parse(Buffer.from(encodeControlTemplate([1]), "base64").toString())).toEqual([1]);
});
test("trusted proxy request URL and body agree with context; untrusted headers are ignored", async () => {
for (const trust of [true, false]) {
const request = new Request("http://internal:3000/api/captcha/challenge", {
method: "POST", body: '{"test":1}',
headers: { "x-forwarded-proto": "https", "x-forwarded-host": "wrnexus.com", origin: "https://evil.example" },
});
const context = createContext(request, resolveRequestUrl(request, trust));
expect(context.req.url).toBe(context.url.href);
expect(context.url.origin).toBe(trust ? "https://wrnexus.com" : "http://internal:3000");
expect(context.req.headers.get("origin")).toBe("https://evil.example");
expect(await context.req.json()).toEqual({ test: 1 });
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.40",
"version": "0.8.41",
"type": "module",
"main": "src/index.ts",
"exports": {
+5
View File
@@ -557,6 +557,11 @@ export const NAV_RUNTIME = String.raw`
syncWrnStyles(doc);
document.querySelectorAll('script[data-wrn-controls]').forEach(function (node) { node.remove(); });
doc.querySelectorAll('script[data-wrn-controls]').forEach(function (node) {
document.body.appendChild(document.importNode(node, true));
});
var importedNodes = [];
for (
+22 -7
View File
@@ -875,6 +875,15 @@ export const REACTIVE_RUNTIME = String.raw`
}
function setupScope(el) {
// Capture the document's registry nodes for retained scopes across navigation.
var controlRegistries = Array.prototype.slice.call(document.querySelectorAll('script[data-wrn-controls]'));
for (var registryParent = el.parentElement; registryParent; registryParent = registryParent.parentElement) {
if (registryParent.__wrnControlRegistries) {
controlRegistries = controlRegistries.concat(registryParent.__wrnControlRegistries);
break;
}
}
el.__wrnControlRegistries = controlRegistries;
if (el.__wrnexusScope) {
el.removeAttribute("data-scope");
el.removeAttribute("data-wrn-scope");
@@ -1989,6 +1998,14 @@ export const REACTIVE_RUNTIME = String.raw`
// materialize the appropriate branch/rows and hydrate their bindings.
function decodeControlDefinition(value) {
try {
if (value && value.charAt(0) === "@") {
var split = value.lastIndexOf(":");
var registryId = value.slice(1, split);
var registry = controlRegistries.find(function (node) { return node.id === registryId; });
if (!registry) throw new Error("Missing control template registry");
if (!registry.__wrnDefinitions) registry.__wrnDefinitions = JSON.parse(registry.textContent);
return registry.__wrnDefinitions[Number(value.slice(split + 1))];
}
var binary = window.atob(value || "");
var bytes = new Uint8Array(binary.length);
for (var index = 0; index < binary.length; index++) {
@@ -2389,16 +2406,14 @@ export const REACTIVE_RUNTIME = String.raw`
}
});
var keyedFragment = document.createDocumentFragment();
// Leave already-positioned keyed nodes attached. Detaching every row
// restarts CSS animations (notably existing toast progress timers).
var keyedCursor = marker.nextSibling;
orderedNodes.forEach(function (node) {
keyedFragment.appendChild(node);
if (node !== keyedCursor) parent.insertBefore(node, keyedCursor);
keyedCursor = node.nextSibling;
});
parent.insertBefore(
keyedFragment,
marker.nextSibling,
);
keyedRecords = nextRecords;
clones = orderedNodes;
});
+9
View File
@@ -71,6 +71,15 @@ test("intercepts an internal link click and swaps #app in place", async () => {
expect(win.document.title).toBe("About");
});
test("navigation replaces document control registries before hydration", async () => {
install('<div id="app"><a href="/next" id="lnk">Next</a></div><script type="application/json" data-wrn-controls id="old">[]</script>');
nextHtml = '<html><body><div id="app">Next</div><script type="application/json" data-wrn-controls id="new">[[{"cond":null,"body":"Ready"}]]</script></body></html>';
win.document.getElementById("lnk").click();
await flush();
expect(win.document.getElementById("old")).toBeNull();
expect(win.document.getElementById("new")?.textContent).toContain("Ready");
});
test("prefetches focused routes once and reuses the document during navigation", async () => {
install(`<div id="app"><a href="/about" id="prefetch">About</a></div>`);
nextHtml =
+28
View File
@@ -149,6 +149,21 @@ test("compiled if blocks switch branches after hydration", () => {
expect(win.document.querySelector(".open")?.textContent).toBe("Open 3");
});
test("registry controls retain nested markup and work after the registry leaves the document", () => {
const definitions = [
[{ cond: "open", body: '<template data-wrn-if="@registry:1"></template><b>Nested</b><template data-wrn-control-end></template>' }, { cond: null, body: '<i>Closed</i>' }],
[{ cond: null, body: '<b>Nested</b>' }],
];
const win = mount(`<script type="application/json" data-wrn-controls id="registry">${JSON.stringify(definitions)}</script>` +
'<div data-scope="open: false"><button data-on-click="open = !open">Toggle</button><template data-wrn-if="@registry:0"></template><i>Closed</i><template data-wrn-control-end></template></div>');
win.document.getElementById("registry")!.remove();
win.document.querySelector("button")!.click();
expect(win.document.querySelector("b")?.textContent).toBe("Nested");
expect(win.document.querySelector("i")).toBeNull();
win.document.querySelector("button")!.click();
expect(win.document.querySelector("i")?.textContent).toBe("Closed");
});
test("compiled each blocks rerender rows and their empty branch", () => {
const definition = Buffer.from(
JSON.stringify({
@@ -1890,3 +1905,16 @@ test("a @change binding still fires for a bubbling event inside a component", ()
expect(win.document.querySelector("#out")?.textContent).toBe("changed");
});
test("appending keyed notifications does not detach existing rows", () => {
const win = mount(`<div data-scope="rows: [{id: 1, name: 'first'}]"><ul><li data-for="row in rows key row.id">{row.name}</li></ul><button data-on-click="rows = [...rows, {id: 2, name: 'second'}]">add</button></div>`);
const list = win.document.querySelector("ul")!;
const first = list.querySelector("li");
const observer = new win.MutationObserver(() => {});
observer.observe(list, {childList: true});
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(list.querySelectorAll("li").length).toBe(2);
expect(observer.takeRecords().flatMap(record => Array.from(record.removedNodes))).not.toContain(first);
observer.disconnect();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.8.65",
"version": "0.8.66",
"type": "module",
"main": "src/index.ts",
"exports": {
+4 -1
View File
@@ -64,7 +64,10 @@ export async function readPluginAsset(asset: ServedPluginAsset): Promise<string
if (asset.source instanceof Uint8Array) return toBody(asset.source);
if (!asset.entry) throw new Error(`Plugin asset '${asset.id}' has no source.`);
const shouldBundle = asset.runtime && (asset.bundle ?? /\.[cm]?tsx?$/.test(asset.entry));
// Published runtimes are JavaScript too, often with relative split chunks.
// Serve a self-contained browser entry unless the plugin explicitly opts out
// (for example, prebuilt pdf.js modules whose worker protocol needs raw ESM).
const shouldBundle = asset.runtime && (asset.bundle ?? /\.[cm]?[jt]sx?$/.test(asset.entry));
if (!shouldBundle) return toBody(await readFile(asset.entry));
const result = await Bun.build({
+41 -35
View File
@@ -896,7 +896,7 @@ function forbiddenOrigin(): Response {
}
export interface UpgradeServer {
upgrade(req: Request, opts: { data: WsData }): boolean;
upgrade(req: Request, opts: { data: WsData; headers?: Headers }): boolean;
/** Bun's per-request socket peer address (used for the non-spoofable client IP). */
requestIP?(req: Request): { address: string } | null;
}
@@ -1258,44 +1258,50 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
}
// --- Realtime: try to upgrade BEFORE the normal HTTP pipeline. ---
// Upgrade only after the same authentication/authorization middleware as HTTP.
const rt = router.matchRealtime(url.pathname);
if (rt) {
// Reject cross-site WebSocket handshakes (CSWSH) before doing any work.
if (!isWebSocketOriginAllowed(req, deps.security)) return secure(forbiddenOrigin());
const mod = await loadModule(rt.route.file);
let data: WsData;
if (isRoomDefinition(mod.default)) {
// Identify the connection: an authenticated session user id, else ?user=.
const wsCtx = createContext(req, url);
const sessionUser = wsCtx.session.get<{ id?: unknown }>("user");
const user =
sessionUser && typeof sessionUser === "object" && sessionUser.id != null
? String(sessionUser.id)
: (url.searchParams.get("user") ?? undefined);
const query = Object.fromEntries(url.searchParams);
// Room-level gate (e.g. require auth) — reject before accepting the socket.
const allowed = await mod.default.handlers.authorize?.({
user,
query,
headers: req.headers,
});
if (allowed === false) {
return secure(new Response("Forbidden", { status: 403 }));
const wsCtx = createContext(req, url);
return runWithRequestContext(wsCtx, async () => {
try {
initializeRequestCache(wsCtx);
wsCtx.ip = server.requestIP?.(req)?.address ?? undefined;
wsCtx.locals.cspNonce = nonce;
let data: WsData | undefined;
const permit = crypto.randomUUID();
const response = await runMiddleware(await resolveMiddleware(), wsCtx, async () => {
const mod = await loadModule(rt.route.file);
if (isRoomDefinition(mod.default)) {
const sessionUser = wsCtx.session.get<{ id?: unknown }>("user");
const authenticatedUser = wsCtx.user as { id?: unknown } | null;
const id = authenticatedUser?.id ?? sessionUser?.id;
// Query parameters are untrusted and must never supply an identity.
const user = id == null ? undefined : String(id);
const query = Object.fromEntries(url.searchParams);
if (mod.default.handlers.security?.requireUser && !user)
return new Response("Forbidden", { status: 403 });
const allowed = await mod.default.handlers.authorize?.({ user, query, headers: req.headers });
if (allowed === false) return new Response("Forbidden", { status: 403 });
data = { kind: "room", meta: { room: url.pathname, def: mod.default, query, user } };
} else if (mod.websocket && typeof mod.websocket === "object") {
data = { kind: "realtime", handler: mod.websocket as WsHandler };
} else {
return new Response("Realtime route needs a default defineRoom() or a `websocket` export", { status: 500 });
}
// A response lets post-processing middleware complete before upgrading.
return new Response(null, { status: 204, headers: { "x-wrnexus-upgrade-permit": permit } });
});
const allowed = data && response.status === 204 && response.headers.get("x-wrnexus-upgrade-permit") === permit;
response.headers.delete("x-wrnexus-upgrade-permit");
const result = secure(withContextHeaders(wsCtx, response));
if (!allowed) return result;
if (server.upgrade(req, { data: data!, headers: result.headers })) return undefined;
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
} catch {
return secure(new Response("Realtime connection failed", { status: 500 }));
}
data = { kind: "room", meta: { room: url.pathname, def: mod.default, query, user } };
} else if (mod.websocket && typeof mod.websocket === "object") {
data = { kind: "realtime", handler: mod.websocket as WsHandler };
} else {
return secure(
new Response("Realtime route needs a default defineRoom() or a `websocket` export", {
status: 500,
}),
);
}
const upgraded = server.upgrade(req, { data });
if (upgraded) return undefined; // Bun takes over the socket.
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
});
}
const ctx = createContext(req, url);
@@ -0,0 +1 @@
export const label = "published-runtime-chunk-loaded";
@@ -0,0 +1,2 @@
import { label } from "./chunk.js";
globalThis.__pluginFixture = label;
@@ -0,0 +1,2 @@
import { label } from "./chunk.js";
export { label };
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test";
import { fileURLToPath } from "node:url";
import {
mergePluginAssets,
runtimeIdsFromMarkup,
@@ -35,6 +36,23 @@ test("serves package assets with a safe content type", async () => {
expect(await response?.text()).toContain("window.captcha");
});
test("published JavaScript runtimes bundle relative chunks by default", async () => {
const entry=fileURLToPath(new URL("./fixtures/plugin-runtime/entry.js",import.meta.url));
const assets=mergePluginAssets([{id:"published",entry}],[]);
const response=await servePluginAsset(assets,"/__wrnexus/assets/published.js","development");
expect(response?.status).toBe(200);
const source=await response!.text();
expect(source).toContain("published-runtime-chunk-loaded");
expect(source).not.toMatch(/from\s+["']\.\/chunk\.js["']/);
});
test("explicit bundle false preserves prebuilt runtime modules", async () => {
const entry=fileURLToPath(new URL("./fixtures/plugin-runtime/entry.js",import.meta.url));
const assets=mergePluginAssets([{id:"raw",entry,bundle:false}],[]);
const response=await servePluginAsset(assets,"/__wrnexus/assets/raw.js","development");
expect(await response!.text()).toContain('from "./chunk.js"');
});
test("resolves inline package migrations for the requested database", () => {
const migrations = resolvePackageMigrations(
[
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import { defineRoom, type Middleware } from "@wrnexus/core";
import { createProductionHandlers } from "../src/prod.ts";
const room = defineRoom({ security: { requireUser: true }, authorize: info => info.user === "signed-in-user" });
const auth: Middleware = async (ctx, next) => {
if (ctx.req.headers.get("cookie") === "test-session=valid") ctx.user = { id: "signed-in-user" };
return next();
};
function handlers(middleware: Middleware[] = [auth]) {
return createProductionHandlers({ pages: [], api: [], realtime: [{raw:"/realtime/updates",mod:{default:room}}], middleware, components:[], layouts:[] }, {});
}
function request(query = "", cookie = "test-session=valid", origin = "https://account.example.test") {
return new Request("https://account.example.test/realtime/updates"+query, { headers: {host:"account.example.test", origin, cookie, upgrade:"websocket"} });
}
test("room upgrade receives middleware identity after post-processing completes", async () => {
let finished = false;
const post: Middleware = async (_, next) => { const res = await next(); finished = true; return new Response(res.body, { status:res.status, headers:res.headers }); };
let upgraded = false;
const response = await handlers([auth,post]).fetch(request(), { upgrade: (_, options) => {
expect(finished).toBe(true);
expect((options.data as any).meta.user).toBe("signed-in-user");
expect(options.headers?.has("x-wrnexus-upgrade-permit")).toBe(false);
upgraded = true; return true;
} });
expect(response).toBeUndefined(); expect(upgraded).toBe(true);
});
test("query user cannot impersonate an authenticated room member", async () => {
const response = await handlers().fetch(request("?user=signed-in-user", ""), {upgrade:()=>{throw Error("must not upgrade")}});
expect(response?.status).toBe(403);
});
test("middleware rejection and cross-site origins never upgrade", async () => {
const server = {upgrade:()=>{throw Error("must not upgrade")}};
expect((await handlers([()=>new Response("denied",{status:401})]).fetch(request(),server))?.status).toBe(401);
expect((await handlers().fetch(request("", "test-session=valid", "https://evil.example"),server))?.status).toBe(403);
});
test("post-processing rejection overrides room authorization", async () => {
const deny: Middleware = async (_,next)=>{await next();return new Response("denied",{status:403})};
expect((await handlers([auth,deny]).fetch(request(),{upgrade:()=>{throw Error("must not upgrade")}}))?.status).toBe(403);
});
test("Bun accepts a real socket with the middleware-authenticated identity", async () => {
const app = handlers();
const server = Bun.serve({ port:0, fetch:app.fetch, websocket:app.websocket });
try {
await new Promise<void>((resolve,reject)=>{
const socket = new WebSocket(`ws://localhost:${server.port}/realtime/updates`, {headers:{cookie:"test-session=valid",origin:`http://localhost:${server.port}`}});
const timer=setTimeout(()=>{socket.close();reject(Error("upgrade timed out"))},3000);
socket.onopen=()=>{clearTimeout(timer);socket.close();resolve()};
socket.onerror=()=>{clearTimeout(timer);reject(Error("upgrade failed"))};
});
} finally { server.stop(true); await app.shutdown(); }
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/payment",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -60,7 +60,7 @@ export function paymentPlugin() {
if (!context.metadata.get(key)) return [];
const apiEnabled = context.metadata.get("@wrnexus/payment:api") !== false;
return [
{ kind: "api" as const, path: "/api/payments/webhook/:gateway", entry: webhookEntry },
{ kind: "api" as const, path: "/api/payments/webhook/[gateway]", entry: webhookEntry },
...(apiEnabled
? apiRoutes.map((path) => ({ kind: "api" as const, path, entry: apiEntry }))
: []),
@@ -3,6 +3,7 @@ import { closeDatabases, setDb } from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import { createPluginRunner } from "@wrnexus/plugin";
import { parse } from "@wrnexus/syntax";
import { compileRoutePattern } from "../../router/src/match.ts";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { databasePaymentStore, paymentPlugin } from "../src/index.ts";
@@ -25,7 +26,10 @@ test("payment plugin contributes its schema only when configured", async () => {
"wrnexus-payment-002-event-order",
]);
expect(contributions.componentDirs).toHaveLength(1);
expect(contributions.routes.map((row) => row.path)).toEqual(["/api/payments/webhook/:gateway"]);
expect(contributions.routes.map((row) => row.path)).toEqual(["/api/payments/webhook/[gateway]"]);
const webhookPattern = compileRoutePattern(contributions.routes[0]!.path);
expect(webhookPattern.regex.exec("/api/payments/webhook/razorpay")?.[1]).toBe("razorpay");
expect(webhookPattern.paramNames).toEqual(["gateway"]);
const off = createPluginRunner(paymentPlugin(), { ...context, metadata: new Map() });
await off.configure({} as any);
expect((await off.contributions()).migrations).toHaveLength(0);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pwa",
"version": "0.8.11",
"version": "0.8.12",
"type": "module",
"description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.",
"main": "./src/index.ts",
+54 -3
View File
@@ -48,13 +48,64 @@ export function createWebManifest(options: WebManifestOptions) {
};
}
export function generateServiceWorker(options: ServiceWorkerOptions = {}): string {
const offline = options.offlineUrl ?? options.startUrl ?? "/";
const urls = [...new Set([offline, ...(options.cacheUrls ?? [])])];
// Only explicitly supplied offline documents are safe to precache. The start
// URL may redirect to sign-in or contain personalized account information.
const offline = options.offlineUrl ?? null;
const urls = [...new Set([...(offline ? [offline] : []), ...(options.cacheUrls ?? [])])];
const rules = options.runtimeCaching ?? [
{ pattern: "^https?://", strategy: "network-first" as const, methods: ["GET"] },
];
const sameOriginOnly = options.runtimeCaching === undefined;
return `const CACHE=${JSON.stringify(options.cacheName ?? "wrnexus-pwa-v1")};const OFFLINE=${JSON.stringify(offline)};const PRECACHE=${JSON.stringify(urls)};const RULES=${JSON.stringify(rules)};const SAME_ORIGIN_ONLY=${JSON.stringify(sameOriginOnly)};self.addEventListener("install",event=>{event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(PRECACHE)));self.skipWaiting()});self.addEventListener("activate",event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key.startsWith("wrnexus-pwa-")&&key!==CACHE).map(key=>caches.delete(key)))).then(()=>self.clients.claim())));const networkFirst=async request=>{try{const response=await fetch(request);if(response.ok){const cache=await caches.open(CACHE);await cache.put(request,response.clone())}return response}catch{return(await caches.match(request))||(request.mode==="navigate"?await caches.match(OFFLINE):Response.error())}};const cacheFirst=async request=>(await caches.match(request))||networkFirst(request);const stale=async request=>{const hit=await caches.match(request);const update=networkFirst(request);return hit||(await update)};self.addEventListener("fetch",event=>{if(SAME_ORIGIN_ONLY&&new URL(event.request.url).origin!==self.location.origin)return;const rule=RULES.find(item=>(item.methods||["GET"]).includes(event.request.method)&&new RegExp(item.pattern).test(event.request.url));if(!rule)return;event.respondWith(rule.strategy==="cache-first"?cacheFirst(event.request):rule.strategy==="stale-while-revalidate"?stale(event.request):networkFirst(event.request))});self.addEventListener("sync",event=>{if(event.tag===${JSON.stringify(options.backgroundSyncTag ?? "wrnexus-offline-sync")})event.waitUntil(self.clients.matchAll().then(clients=>clients.forEach(client=>client.postMessage({type:"wrnexus:background-sync"}))))});self.addEventListener("push",event=>{const data=event.data?.json?.()||{};event.waitUntil(self.registration.showNotification(data.title||"Notification",{body:data.body,icon:data.icon,data:{url:data.url||"/"}}))});self.addEventListener("notificationclick",event=>{event.notification.close();event.waitUntil(clients.openWindow(event.notification.data?.url||"/"))});`;
return `const CACHE=${JSON.stringify((options.cacheName ?? "wrnexus-pwa") + "-v2")};
const OFFLINE=${JSON.stringify(offline)};
const PRECACHE=${JSON.stringify(urls)};
const RULES=${JSON.stringify(rules)};
const SAME_ORIGIN_ONLY=${JSON.stringify(sameOriginOnly)};
const sensitive=request=>{
const url=new URL(request.url,self.location.origin);
return request.method!=="GET"||request.headers.has("authorization")||
request.cache==="no-store"||["sso_code","code","token","access_token"].some(key=>url.searchParams.has(key))||
/^\\/(?:api|realtime)(?:\\/|$)/.test(url.pathname)||
/^\\/(?:login|logout|callback)(?:\\/|$)/.test(url.pathname);
};
const cacheable=response=>response.ok&&!response.redirected&&response.type!=="opaque"&&
!/(?:no-store|private|no-cache)/i.test(response.headers.get("cache-control")||"")&&
!response.headers.has("set-cookie");
const store=async(request,response)=>{if(cacheable(response)){try{await(await caches.open(CACHE)).put(request,response.clone())}catch{}}};
self.addEventListener("install",event=>{
event.waitUntil(Promise.all(PRECACHE.map(async url=>{
const request=new Request(new URL(url,self.location.origin),{credentials:"omit"});
if(sensitive(request))return;
try{await store(request,await fetch(request))}catch{}
})));
self.skipWaiting();
});
self.addEventListener("activate",event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key.startsWith("wrnexus-pwa-")&&key!==CACHE).map(key=>caches.delete(key)))).then(()=>self.clients.claim())));
const hit=async request=>{try{return await(await caches.open(CACHE)).match(request)}catch{return undefined}};
const networkFirst=async request=>{
try{
const response=await fetch(request);
// Never save navigations: cookies are not readable from a service worker.
if(request.mode!=="navigate")await store(request,response);
return response;
}catch{
const cached=request.mode==="navigate"?(OFFLINE?await hit(OFFLINE):undefined):await hit(request);
return cached||new Response("You are offline. Please reconnect and try again.",{status:503,headers:{"content-type":"text/plain","cache-control":"no-store"}});
}
};
const cacheFirst=async request=>(await hit(request))||networkFirst(request);
const stale=async(request,event)=>{const cached=await hit(request);const update=networkFirst(request);event.waitUntil(update.then(()=>{}));return cached||(await update)};
self.addEventListener("fetch",event=>{
if(SAME_ORIGIN_ONLY&&new URL(event.request.url).origin!==self.location.origin)return;
if(sensitive(event.request))return;
const rule=RULES.find(item=>(item.methods||["GET"]).includes(event.request.method)&&new RegExp(item.pattern).test(event.request.url));
if(!rule)return;
event.respondWith(event.request.mode==="navigate"?networkFirst(event.request):rule.strategy==="cache-first"?cacheFirst(event.request):rule.strategy==="stale-while-revalidate"?stale(event.request,event):networkFirst(event.request));
});
self.addEventListener("sync",event=>{if(event.tag===${JSON.stringify(options.backgroundSyncTag ?? "wrnexus-offline-sync")})event.waitUntil(self.clients.matchAll().then(clients=>clients.forEach(client=>client.postMessage({type:"wrnexus:background-sync"}))))});
self.addEventListener("push",event=>{const data=event.data?.json?.()||{};event.waitUntil(self.registration.showNotification(data.title||"Notification",{body:data.body,icon:data.icon,data:{url:data.url||"/"}}))});
self.addEventListener("notificationclick",event=>{event.notification.close();event.waitUntil(clients.openWindow(event.notification.data?.url||"/"))});`;
}
export interface OfflineMutation<T = unknown> {
id: string;
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test";
import { generateServiceWorker } from "../src/index.ts";
function worker(fetcher: (r:any)=>Promise<Response>, failStore = false) {
const listeners: Record<string,Function> = {};
const saved: string[] = [];
const cache = {match:async()=>undefined,put:async(r:any)=>{if(failStore)throw Error("quota");saved.push(r.url)}};
const self = {location:{origin:"https://account.example.test"},addEventListener:(name:string,fn:Function)=>listeners[name]=fn,skipWaiting(){},clients:{claim:async()=>{}}};
new Function("self","caches","fetch",generateServiceWorker())(self,{open:async()=>cache,keys:async()=>[],delete:async()=>true},fetcher);
return {saved, async fetch(path:string, mode="cors") {
let result:Promise<Response>|undefined;
const req = new Request(new URL(path,self.location.origin));
Object.defineProperty(req,"mode",{value:mode});
listeners.fetch({request:req,respondWith:(r:Promise<Response>)=>result=r,waitUntil:()=>{}});
return result;
}};
}
test("handoffs and API calls bypass service worker interception", async()=>{
const w=worker(async()=>{throw Error("must not intercept")});
expect(await w.fetch("/organization-add?sso_code=one-time")).toBeUndefined();
expect(await w.fetch("/api/account")).toBeUndefined();
});
test("private responses and navigations never enter cache", async()=>{
const w=worker(async()=>new Response("private",{headers:{"cache-control":"private, no-store"}}));
expect(await (await w.fetch("/profile"))?.text()).toBe("private");
expect(w.saved).toEqual([]);
const nav=worker(async()=>new Response("personal page"));
await nav.fetch("/profile","navigate"); expect(nav.saved).toEqual([]);
});
test("cache quota errors do not turn successful requests into network errors", async()=>{
const w=worker(async()=>new Response("asset"),true);
expect(await (await w.fetch("/app.js"))?.text()).toBe("asset");
});
test("offline without a cached fallback always returns a Response", async()=>{
const w=worker(async()=>{throw Error("offline")});
expect((await w.fetch("/profile","navigate"))?.status).toBe(503);
});
test("public static assets remain cacheable", async()=>{
const w=worker(async()=>new Response("asset",{headers:{"cache-control":"public, max-age=3600"}}));
await w.fetch("/app.js"); expect(w.saved).toHaveLength(1);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.8.11",
"version": "0.8.12",
"type": "module",
"main": "src/index.ts",
"exports": {
+5 -3
View File
@@ -7,7 +7,7 @@
* the browser, which keeps "server-only code" genuinely server-only.
*/
import { escapeHtml, type PageMeta, type SeoConfig } from "@wrnexus/core";
import { escapeHtml, renderControlTemplates, type PageMeta, type SeoConfig } from "@wrnexus/core";
export interface ScriptAsset {
src: string;
@@ -120,7 +120,7 @@ export function renderDocument(opts: RenderOptions): string {
const globalHead = opts.extraHead ? `\n ${opts.extraHead}` : "";
const localStyles = extracted.styles ? `\n ${extracted.styles}` : "";
const extraBody = opts.extraBody ? `\n ${opts.extraBody}` : "";
const extraBody = renderControlTemplates() + (opts.extraBody ? `\n ${opts.extraBody}` : "");
const htmlAttrs = /(?:^|\s)lang\s*=/.test(opts.htmlAttrs ?? "")
? (opts.htmlAttrs ?? "")
@@ -445,7 +445,9 @@ export function renderDocumentStream(opts: StreamRenderOptions): ReadableStream<
try {
controller.enqueue(encoder.encode(prefix ?? ""));
for await (const chunk of bodyChunks(opts.body)) controller.enqueue(encoder.encode(chunk));
controller.enqueue(encoder.encode(suffix ?? ""));
// Async body rendering can register templates after the shell is built.
const tail = (suffix ?? "").replace(/<script type="application\/json" data-wrn-controls[^>]*>[\s\S]*?<\/script>/g, "");
controller.enqueue(encoder.encode(renderControlTemplates() + tail));
controller.close();
} catch (error) {
controller.error(error);
+15
View File
@@ -1,6 +1,21 @@
import { expect, test } from "bun:test";
import { renderDocument, renderDocumentStream, renderStoreHydration } from "../src/index.ts";
import type { StoreContainer } from "@wrnexus/store";
import { runWithRequestContext, encodeControlTemplate } from "@wrnexus/core";
test("streamed documents include templates registered by late body chunks", async () => {
await runWithRequestContext({} as never, async () => {
async function* body() {
yield "<p>Shell</p>";
await Promise.resolve();
yield `<template data-wrn-if="${encodeControlTemplate([{ cond: null, body: "Late" }])}"></template>`;
}
const html = await new Response(renderDocumentStream({ meta: {}, body: body() })).text();
expect(html).toContain('data-wrn-controls');
expect(html).toContain('"body":"Late"');
expect(html.match(/data-wrn-controls/g)).toHaveLength(1);
});
});
test("store hydration is HTML-safe, bounded, redacted, and JSON-compatible", () => {
const container = {
+10
View File
@@ -27,6 +27,16 @@ Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web fr
## Overview
### Production CSS inlining
`styles.inlineMaxBytes` controls the maximum uncompressed size of the combined
production stylesheet to embed in the document. The default is 4096 bytes; `0`
disables inlining. A measured first-visit optimization can use, for example,
`styles: { inlineMaxBytes: 128 * 1024 }`. Larger limits remove a blocking stylesheet
request but repeat the CSS in each HTML response. Stylesheets containing relative
asset URLs stay external so their URL base does not change. Theme switching and
the separately served stylesheet remain available.
This package owns three server-side concerns that shape every page a WrNexus app renders:
1. **Global stylesheet pipeline** — finds `app/styles/global.css` (or aggregates `app/styles/*.css`), bundles it with Bun's CSS bundler (which resolves `@import`, including from `node_modules`), and produces one stylesheet that is `<link>`ed into every page's `<head>`. Because it is a plain global sheet, it styles server-rendered markup and hydrated client islands identically. A custom `process` hook lets you swap in Tailwind / PostCSS / Sass.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.8.22",
"version": "0.8.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+2
View File
@@ -39,6 +39,8 @@ export interface StylesConfig {
entry?: string;
/** Include the complete @wrnexus/ui component catalog stylesheet. Default true. */
includeUi?: boolean;
/** Maximum uncompressed production CSS bytes to inline in the document. Default 4096; 0 disables inlining. Larger limits trade repeat HTML bytes for one fewer blocking request. */
inlineMaxBytes?: number;
/**
* Optional custom processor. Return the final CSS string. Use this to run
* Tailwind, PostCSS, Sass, etc. When omitted, the built-in Bun CSS bundler is
+55 -39
View File
@@ -39,6 +39,13 @@ export type ThemeToken =
| `color-on-${"primary" | "secondary"}`
| "radius"
| "radius-sm"
| "radius-md"
| "radius-lg"
| "control-height-sm"
| "control-height"
| "control-height-lg"
| "space-panel"
| "font-size-ui"
| "shadow-1"
| "shadow-2"
| "shadow-3"
@@ -452,16 +459,16 @@ function resolveDefaultAccent(
export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
light: {
"color-scheme": "light",
"color-bg": "#ffffff",
"color-background": "#ffffff",
"color-foreground": "#0b1020",
"color-bg": "#f7f8fa",
"color-background": "#f7f8fa",
"color-foreground": "#20232b",
"color-surface-raised": "#ffffff",
"color-surface-muted": "#eceff6",
"color-surface-soft": "#f1f4f9",
"color-text-muted": "#5a6178",
"color-text-subtle": "#737b91",
"color-border-strong": "#c7cedd",
"color-code-background": "#0b1020",
"color-surface-muted": "#eef0f4",
"color-surface-soft": "#f6f7f9",
"color-text-muted": "#606776",
"color-text-subtle": "#6b7280",
"color-border-strong": "#b9c0cc",
"color-code-background": "#20232b",
"color-code-surface": "#141a30",
"color-code-text": "#e7ecff",
"color-code-muted": "#9aa6d0",
@@ -472,34 +479,41 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"space-section": "5rem",
"space-section-sm": "3.5rem",
"container-max": "80rem",
"color-surface": "#f6f7fb",
"color-surface-2": "#eceff6",
"color-text": "#0b1020",
"color-muted": "#5a6178",
"color-border": "#e2e6f0",
"color-surface": "#ffffff",
"color-surface-2": "#eef0f4",
"color-text": "#20232b",
"color-muted": "#606776",
"color-border": "#e1e4ea",
...paletteTokens(THEME_PALETTES.blue, "light"),
radius: "8px",
"radius-sm": "5px",
radius: "10px",
"radius-sm": "6px",
"radius-md": "10px",
"radius-lg": "12px",
"control-height-sm": "2rem",
"control-height": "2.5rem",
"control-height-lg": "2.75rem",
"space-panel": "1.25rem",
"font-size-ui": "0.875rem",
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
"shadow-1": "0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.1)",
"shadow-1": "0 1px 3px rgba(16,24,40,0.05)",
"shadow-2": "0 8px 24px rgba(16,24,40,0.10)",
"shadow-3": "0 20px 50px rgba(16,24,40,0.14)",
},
dark: {
"color-scheme": "dark",
"color-bg": "#000000",
"color-background": "#000000",
"color-foreground": "#f5f5f5",
"color-surface-raised": "#111111",
"color-surface-muted": "#181818",
"color-surface-soft": "#141414",
"color-text-muted": "#b3b3b3",
"color-text-subtle": "#8a8a8a",
"color-border-strong": "#ffffff38",
"color-bg": "#101114",
"color-background": "#101114",
"color-foreground": "#f0f1f4",
"color-surface-raised": "#22252d",
"color-surface-muted": "#242730",
"color-surface-soft": "#1c1f26",
"color-text-muted": "#b5bac5",
"color-text-subtle": "#959cab",
"color-border-strong": "#555d6c",
"color-code-background": "#050505",
"color-code-surface": "#101010",
"color-code-text": "#f5f5f5",
"color-code-muted": "#a3a3a3",
"color-code-text": "#f0f1f4",
"color-code-muted": "#a7aeba",
"color-code-border": "#ffffff24",
"shadow-sm": "0 1px 2px rgba(0,0,0,0.28)",
"shadow-md": "0 10px 30px rgba(0,0,0,0.35)",
@@ -507,16 +521,23 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"space-section": "5rem",
"space-section-sm": "3.5rem",
"container-max": "80rem",
"color-surface": "#0a0a0a",
"color-surface-2": "#171717",
"color-text": "#f5f5f5",
"color-muted": "#a3a3a3",
"color-border": "#ffffff1f",
"color-surface": "#181a20",
"color-surface-2": "#20232b",
"color-text": "#f0f1f4",
"color-muted": "#a7aeba",
"color-border": "#30343e",
...paletteTokens(THEME_PALETTES.blue, "dark"),
radius: "10px",
"radius-sm": "6px",
"radius-md": "10px",
"radius-lg": "12px",
"control-height-sm": "2rem",
"control-height": "2.5rem",
"control-height-lg": "2.75rem",
"space-panel": "1.25rem",
"font-size-ui": "0.875rem",
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
"shadow-1": "0 1px 2px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.35)",
"shadow-1": "0 1px 3px rgba(0,0,0,0.18)",
"shadow-2": "0 10px 30px rgba(0,0,0,0.35)",
"shadow-3": "0 24px 64px rgba(0,0,0,0.45)",
},
@@ -599,11 +620,6 @@ function selectorValue(value: string): string {
function globalThemeCss(): string {
return (
'\nhtml,body{font-family:var(--wrn-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n' +
":root{--wrn-radius-sm:0.55rem;--wrn-radius-md:0.9rem;--wrn-radius-lg:1.35rem;" +
"--wrn-shadow-1:0 1px 2px color-mix(in srgb, black 22%, transparent)," +
"0 10px 30px color-mix(in srgb, black 14%, transparent);" +
"--wrn-shadow-2:0 10px 30px color-mix(in srgb, black 18%, transparent);" +
"--wrn-shadow-3:0 24px 64px color-mix(in srgb, black 24%, transparent);}\n" +
"wrn-slot{display:contents;}\n" +
"[data-for]{display:none !important;}\n"
);
+17 -4
View File
@@ -56,15 +56,15 @@ test("resolveThemeConfig merges user tokens over built-in light/dark", () => {
expect(t.themes.dark["color-bg"]).toBeDefined(); // built-in kept
});
test("built-in themes use Plus Jakarta Sans and neutral black dark surfaces", () => {
test("built-in themes use Plus Jakarta Sans and layered charcoal dark surfaces", () => {
const t = resolveThemeConfig();
expect(t.themes.light["font-sans"]).toBe(
'"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
);
expect(t.themes.dark["font-sans"]).toBe('"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif');
expect(t.themes.dark["color-bg"]).toBe("#000000");
expect(t.themes.dark["color-surface"]).toBe("#0a0a0a");
expect(t.themes.dark["color-surface-2"]).toBe("#171717");
expect(t.themes.dark["color-bg"]).toBe("#101114");
expect(t.themes.dark["color-surface"]).toBe("#181a20");
expect(t.themes.dark["color-surface-2"]).toBe("#20232b");
});
test("resolveThemeName validates against configured names", () => {
@@ -105,3 +105,16 @@ test("renderThemeRuntime bakes the theme names for cycling", () => {
expect(js).toContain(JSON.stringify(t.names));
expect(js).toContain('addEventListener("wrnexus:navigated"');
});
test("active stylesheets preserve configured geometry and elevation without a later reset", () => {
const theme = resolveThemeConfig({
themes: { dark: { "radius-sm": "3px", "radius-lg": "9px", "shadow-1": "none" } },
});
const css = renderActiveThemeCss(theme, "dark");
expect(css.match(/--wrn-radius-sm:/g)).toHaveLength(1);
expect(css.match(/--wrn-radius-lg:/g)).toHaveLength(1);
expect(css.match(/--wrn-shadow-1:/g)).toHaveLength(1);
expect(css).toContain("--wrn-radius-sm:3px");
expect(css).toContain("--wrn-radius-lg:9px");
expect(css).toContain("--wrn-shadow-1:none");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.8.15",
"version": "0.8.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+7 -1
View File
@@ -61,7 +61,13 @@ export async function renderComponent(
const dir = join(tmpdir(), "wrnexus-test");
mkdirSync(dir, { recursive: true });
const file = join(dir, `c${seq++}.ts`);
writeFileSync(file, compileWrnFile(source, file), "utf8");
// Temp files otherwise resolve bare imports against the OS temp directory,
// potentially picking up an unrelated global framework installation.
const code = compileWrnFile(source, file).replace(
/from (["'])@wrnexus\/core\1/g,
`from ${JSON.stringify(import.meta.resolve("@wrnexus/core"))}`,
);
writeFileSync(file, code, "utf8");
const mod = (await import(pathToFileURL(file).href)) as {
render?: (props: Record<string, unknown>) => string;
};
+9
View File
@@ -7,6 +7,15 @@ import {
createFetchMock,
createTestContext,
} from "../src/index.ts";
import { runWithRequestContext, renderControlTemplates } from "@wrnexus/core";
test("temporary component modules use the same core request context as the caller", async () => {
await runWithRequestContext({} as never, async () => {
const html = await renderComponent(`component Conditional {\n state open = true\n view { {#if open}<p>Visible</p>{/if} }\n }`);
expect(html).toContain('data-wrn-if="@wrn-controls-');
expect(renderControlTemplates()).toContain("Visible");
});
});
const COUNTER = `component Counter {
props {
+21 -21
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 = ""`, `cornerHint: string = ""`, `cornerHref: string = ""`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Password"`, `icon: string = ""`, `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 })`
@@ -110,7 +110,7 @@ Theme-aware, responsive avatar group component.
Theme-aware, responsive badge component.
- Mount: `data-component="Badge"`
- Props: `label: string = "Badge"`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "solid"`, `shape: string = "pill"`, `class: string = ""`, `icon: string = ""`, `iconPosition: string = "start"`, `dot: boolean = false`, `dotOnly: boolean = false`, `dotLabel: string = "Status"`, `animated: boolean = false`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `dismissible: boolean = false`, `dismissLabel: string = "Remove badge"`, `truncate: boolean = false`, `maxWidth: string = "12rem"`, `anchorLabel: string = ""`, `anchorIcon: string = ""`, `placement: string = "inline"`, `anchorLabelText: string = "Badge anchor"`
- Props: `label: string = "Badge"`, `humanize: boolean = true`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "solid"`, `shape: string = "pill"`, `class: string = ""`, `icon: string = ""`, `iconPosition: string = "start"`, `dot: boolean = false`, `dotOnly: boolean = false`, `dotLabel: string = "Status"`, `animated: boolean = false`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `dismissible: boolean = false`, `dismissLabel: string = "Remove badge"`, `truncate: boolean = false`, `maxWidth: string = "12rem"`, `anchorLabel: string = ""`, `anchorIcon: string = ""`, `placement: string = "inline"`, `anchorLabelText: string = "Badge anchor"`
- Slots: None
- Outputs: `dismiss({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
@@ -128,7 +128,7 @@ Theme-aware, responsive blockquote component.
Theme-aware, responsive button component.
- Mount: `data-component="Button"`
- Props: `label: string = "Button"`, `loadingLabel: string = "Loading…"`, `description: string = ""`, `as: string = ""`, `href: string = ""`, `target: string = ""`, `rel: string = ""`, `type: string = "button"`, `variant: string = "default"`, `color: string = "primary"`, `size: string = "default"`, `disabled: boolean = false`, `loading: boolean = false`, `pill: boolean = false`, `fullWidth: boolean = false`, `icon: string = ""`, `iconPosition: string = "start"`, `ariaLabel: string = ""`, `ariaPressed: string = ""`, `ariaExpanded: string = ""`, `ariaControls: string = ""`, `title: string = ""`, `autofocus: boolean = false`, `controlClass: string = ""`, `class: string = ""`
- Props: `label: string = "Button"`, `loadingLabel: string = "Loading…"`, `description: string = ""`, `as: string = ""`, `href: string = ""`, `target: string = ""`, `rel: string = ""`, `type: string = "button"`, `variant: string = "default"`, `color: string = "primary"`, `size: string = "default"`, `disabled: boolean = false`, `loading: boolean = false`, `pill: boolean = false`, `fullWidth: boolean = false`, `icon: string = ""`, `iconPosition: string = "start"`, `ariaLabel: string = ""`, `ariaPressed: string = ""`, `ariaExpanded: string = ""`, `ariaControls: string = ""`, `title: string = ""`, `tooltipPlacement: string = "top"`, `autofocus: boolean = false`, `controlClass: string = ""`, `class: string = ""`
- Slots: `default`
- Outputs: `click({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
@@ -146,8 +146,8 @@ Theme-aware, responsive button group component.
Group related content in a responsive themed surface with title, description, content, and supporting slots.
- Mount: `data-component="Card"`
- Props: `title: string = ""`, `icon: string = ""`, `titleKey: string = ""`, `subtitle: string = ""`, `subtitleKey: string = ""`, `description: string = ""`, `descriptionKey: string = ""`, `header: string = ""`, `footer: string = ""`, `imageSrc: string = ""`, `imageAlt: string = ""`, `imagePosition: string = "top"`, `actionLabel: string = ""`, `actionLabelKey: string = ""`, `actionHref: string = ""`, `headerActions: unknown[] = []`, `navigation: unknown[] = []`, `activeNav: string = ""`, `mobileNavigation: boolean = false`, `alertTitle: string = ""`, `alertDescription: string = ""`, `empty: boolean = false`, `emptyTitle: string = "No data to show"`, `emptyIcon: string = "icon-[lucide--inbox]"`, `items: unknown[] = []`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `layout: string = "vertical"`, `align: string = "left"`, `hover: string = "none"`, `radius: string = "default"`, `shadow: string = "default"`, `equalHeight: boolean = false`, `scrollable: boolean = false`, `maxHeight: string = "18rem"`, `dismissible: boolean = false`, `ariaLabel: string = ""`, `class: string = ""`
- Slots: `default`
- Props: `title: string = ""`, `icon: string = ""`, `titleKey: string = ""`, `subtitle: string = ""`, `subtitleKey: string = ""`, `description: string = ""`, `descriptionKey: string = ""`, `showHeader: boolean = true`, `showFooter: boolean = true`, `header: string = ""`, `footer: string = ""`, `imageSrc: string = ""`, `imageAlt: string = ""`, `imagePosition: string = "top"`, `actionLabel: string = ""`, `actionLabelKey: string = ""`, `actionHref: string = ""`, `headerActions: unknown[] = []`, `navigation: unknown[] = []`, `activeNav: string = ""`, `mobileNavigation: boolean = false`, `alertTitle: string = ""`, `alertDescription: string = ""`, `empty: boolean = false`, `emptyTitle: string = "No data to show"`, `emptyIcon: string = "icon-[lucide--inbox]"`, `items: unknown[] = []`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `layout: string = "vertical"`, `align: string = "left"`, `hover: string = "none"`, `radius: string = "default"`, `shadow: string = "default"`, `equalHeight: boolean = false`, `scrollable: boolean = false`, `maxHeight: string = "18rem"`, `dismissible: boolean = false`, `ariaLabel: string = ""`, `class: string = ""`
- Slots: `header`, `default`, `footer`
- Outputs: `click({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `action({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `navigate({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `dismiss({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `load({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `error({ error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)`
### Carousel
@@ -155,7 +155,7 @@ Group related content in a responsive themed surface with title, description, co
Theme-aware, responsive carousel component.
- Mount: `data-component="Carousel"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = ""`, `description: string = ""`, `items: unknown[] = []`, `activeIndex: number = 0`, `slidesPerView: number = 1`, `gap: string = "0.75rem"`, `showPagination: boolean = false`, `isAutoPlay: boolean = false`, `autoplayInterval: number = 4000`, `isInfiniteLoop: boolean = false`, `isRTL: boolean = false`, `isCentered: boolean = false`, `isDraggable: boolean = false`, `isAutoHeight: boolean = false`, `isSnap: boolean = false`, `showCounter: boolean = false`, `thumbnails: string = "none"`, `ariaLabel: string = "Content carousel"`, `variant: string = "default"`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = ""`, `description: string = ""`, `items: unknown[] = []`, `activeIndex: number = 0`, `slidesPerView: number = 1`, `gap: string = "0.75rem"`, `showPagination: boolean = false`, `isAutoPlay: boolean = false`, `autoplayInterval: number = 4000`, `isInfiniteLoop: boolean = false`, `isRTL: boolean = false`, `isCentered: boolean = false`, `isDraggable: boolean = false`, `isAutoHeight: boolean = false`, `isSnap: boolean = false`, `showCounter: boolean = false`, `thumbnails: string = "none"`, `ariaLabel: string = "Content carousel"`, `framed: boolean = false`, `variant: string = "default"`, `class: string = ""`
- Slots: `default`
- Outputs: `initialize({ index: number; count: number })`, `change({ index: number; previousIndex: number; item: string | number | boolean | null | object; reason: string | boolean })`, `previous({ index: number; previousIndex: number; item: string | number | boolean | null | object })`, `next({ index: number; previousIndex: number; item: string | number | boolean | null | object })`, `play({ index: number; interval: number })`, `pause({ index: number })`, `reachStart({ index: number })`, `reachEnd({ index: number })`, `dragStart({ index: number; x: null })`, `dragEnd({ index: number; distance: number })`
@@ -182,7 +182,7 @@ Theme-aware, responsive collapse component.
Theme-aware, responsive date picker component.
- Mount: `data-component="DatePicker"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Date Picker"`, `id: string = ""`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "date"`, `locale: string = "en-US"`, `firstDayOfWeek: number = 0`, `months: string = [{ label: "Jan", value: "01" }, { label: "Feb", value: "02" }, { label: "Mar", value: "03" }, { label: "Apr", value: "04" }, { label: "May", value: "05" }, { label: "Jun", value: "06" }, { label: "Jul", value: "07" }, { label: "Aug", value: "08" }, { label: "Sep", value: "09" }, { label: "Oct", value: "10" }, { label: "Nov", value: "11" }, { label: "Dec", value: "12" }]`, `days: string = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]`, `years: string = [2024, 2025, 2026, 2027, 2028, 2029, 2030]`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `variant: string = "normal"`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Date Picker"`, `id: string = ""`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "date"`, `locale: string = "en-US"`, `firstDayOfWeek: number = 0`, `months: { label: string; value: string }[] = [{ label: "Jan", value: "01" }, { label: "Feb", value: "02" }, { label: "Mar", value: "03" }, { label: "Apr", value: "04" }, { label: "May", value: "05" }, { label: "Jun", value: "06" }, { label: "Jul", value: "07" }, { label: "Aug", value: "08" }, { label: "Sep", value: "09" }, { label: "Oct", value: "10" }, { label: "Nov", value: "11" }, { label: "Dec", value: "12" }]`, `days: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]`, `years: number[] = [2024, 2025, 2026, 2027, 2028, 2029, 2030]`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `variant: string = "normal"`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
- Slots: None
- Outputs: `input({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `change({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `open({ value: string; name: string; sourceEvent: Event })`, `close({ value: string; name: string; sourceEvent: Event })`, `focus({ value: string; name: string; sourceEvent: Event })`, `blur({ value: string; name: string; sourceEvent: Event })`, `invalid({ name: string; message: string; sourceEvent: Event })`
@@ -200,7 +200,7 @@ Theme-aware, responsive device frame component.
Theme-aware, responsive file upload progress component.
- Mount: `data-component="FileUploadProgress"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Progress"`, `value: number = 50`, `max: number = 100`, `showValue: boolean = true`, `fileName: string = ""`, `fileSize: string = ""`, `uploadedSize: string = ""`, `status: string = "uploading"`, `cancelLabel: string = "Cancel upload"`, `retryLabel: string = "Retry upload"`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Progress"`, `value: number = 50`, `max: number = 100`, `showValue: boolean = true`, `fileName: string = ""`, `fileSize: string = ""`, `uploadedSize: string = ""`, `status: string = "uploading"`, `cancelLabel: string = "Cancel upload"`, `retryLabel: string = "Retry upload"`, `showCompleteAction: boolean = false`, `class: string = ""`
- Slots: None
- Outputs: `cancel({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `retry({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `complete({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
@@ -227,7 +227,7 @@ Present structured responsive linked or status items with icons, descriptions, a
Theme-aware, responsive list group component.
- Mount: `data-component="ListGroup"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List Group"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List Group"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `value: string = ""`, `class: string = ""`
- Slots: `default`
- Outputs: `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
@@ -254,7 +254,7 @@ Theme-aware, responsive progress component.
Theme-aware, responsive rating component.
- Mount: `data-component="Rating"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Rating"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Rating"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `value: number = 0`, `max: number = 5`, `step: number = 1`, `readonly: boolean = false`, `disabled: boolean = false`, `class: string = ""`
- Slots: `default`
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
@@ -281,8 +281,8 @@ Theme-aware, responsive spinner component.
Theme-aware, responsive styled icon component.
- Mount: `data-component="StyledIcon"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Styled Icon"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Styled Icon"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `icon: string = "icon-[lucide--sparkles]"`, `label: string = ""`, `class: string = ""`
- Slots: None
- Outputs: None
### Timeline
@@ -357,7 +357,7 @@ Reusable toaster component.
Display one operational metric with value, suffix, description, icon, trend, progress, and optional action.
- Mount: `data-component="MetricCard"`
- Props: `label: string = "Metric"`, `value: string = "0"`, `description: string = ""`, `icon: string = ""`, `iconStyle: string = "soft"`, `prefix: string = ""`, `suffix: string = ""`, `badge: string = ""`, `trend: string = ""`, `trendLabel: string = ""`, `trendDirection: string = "neutral"`, `progress: number = -1`, `progressLabel: string = ""`, `href: string = ""`, `target: string = ""`, `rel: string = ""`, `external: boolean = false`, `actionLabel: string = ""`, `actionIcon: string = ""`, `showArrow: boolean = true`, `selectable: boolean = false`, `disabled: boolean = false`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `hover: string = "lift"`, `align: string = "left"`, `class: string = ""`
- Props: `label: string = "Metric"`, `value: string = "0"`, `description: string = ""`, `icon: string = ""`, `iconStyle: string = "soft"`, `prefix: string = ""`, `suffix: string = ""`, `badge: string = ""`, `trend: string = ""`, `trendLabel: string = ""`, `trendDirection: string = "neutral"`, `progress: number = -1`, `progressLabel: string = ""`, `href: string = ""`, `target: string = ""`, `rel: string = ""`, `external: boolean = false`, `actionLabel: string = ""`, `actionIcon: string = ""`, `showArrow: boolean = true`, `selectable: boolean = false`, `disabled: boolean = false`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `hover: string = "none"`, `align: string = "left"`, `class: string = ""`
- Slots: `default`, `footer`
- Outputs: `select({ label: string; value: string; href: string; sourceEvent: Event })`, `action({ label: string; value: string; href: string; sourceEvent: Event })`
@@ -496,7 +496,7 @@ Theme-aware, responsive time picker component.
Theme-aware, responsive chart component.
- Mount: `data-component="Chart"`
- Props: `title: string = "Chart"`, `description: string = ""`, `items: unknown[] = []`, `valueKey: string = "value"`, `labelKey: string = "label"`, `height: number = 240`, `showLegend: boolean = true`, `showValues: boolean = true`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "bar"`, `class: string = ""`
- Props: `title: string = "Chart"`, `description: string = ""`, `items: unknown[] = []`, `data: unknown[] = []`, `type: string = ""`, `valueKey: string = "value"`, `labelKey: string = "label"`, `height: number = 240`, `showLegend: boolean = true`, `showValues: boolean = true`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "bar"`, `class: string = ""`
- Slots: `default`
- Outputs: `select({ item: object; index: number; sourceEvent?: Event })`, `dataPointClick({ item: object; index: number; sourceEvent?: Event })`, `legendToggle({ item: object; index: number; hidden: boolean; sourceEvent?: Event })`
@@ -505,7 +505,7 @@ Theme-aware, responsive chart component.
Theme-aware, responsive clipboard component.
- Mount: `data-component="Clipboard"`
- Props: `value: string = ""`, `label: string = "Copy"`, `copiedLabel: string = "Copied"`, `errorLabel: string = "Copy failed"`, `title: string = "Clipboard"`, `description: string = ""`, `code: boolean = true`, `size: string = "default"`, `color: string = "primary"`, `disabled: boolean = false`, `class: string = ""`
- Props: `value: string = ""`, `label: string = "Copy"`, `copiedLabel: string = "Copied"`, `errorLabel: string = "Copy failed"`, `title: string = "Clipboard"`, `description: string = ""`, `iconOnly: boolean = false`, `code: boolean = true`, `size: string = "default"`, `color: string = "primary"`, `disabled: boolean = false`, `class: string = ""`
- Slots: `default`
- Outputs: `copy({ value: string; sourceEvent?: Event })`, `success({ value: string; sourceEvent?: Event })`, `error({ error: Error | string; value: string; sourceEvent?: Event })`
@@ -514,7 +514,7 @@ Theme-aware, responsive clipboard component.
Theme-aware, responsive confetti component.
- Mount: `data-component="Confetti"`
- Props: `label: string = "Celebrate"`, `duration: number = 1200`, `count: number = 24`, `autoStart: boolean = false`, `disabled: boolean = false`, `size: string = "default"`, `color: string = "primary"`, `class: string = ""`
- Props: `label: string = "Celebrate"`, `duration: number = 1200`, `count: number = 80`, `autoStart: boolean = false`, `disabled: boolean = false`, `size: string = "default"`, `color: string = "primary"`, `class: string = ""`
- Slots: `default`
- Outputs: `start({ sourceEvent?: Event; duration: number })`, `complete({ sourceEvent?: Event; duration: number })`
@@ -541,7 +541,7 @@ Theme-aware, responsive drag and drop component.
Present responsive location information and markers with map-ready metadata and movement or marker events.
- Mount: `data-component="Map"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `provider: string = "openstreetmap"`, `apiKey: string = ""`, `query: string = ""`, `latitude: number = 12.9716`, `longitude: number = 77.5946`, `zoomLevel: number = 13`, `mapType: string = "roadmap"`, `imageSrc: string = ""`, `imageAlt: string = "Map background"`, `class: string = ""`
- Slots: `default`
- Outputs: `markerClick({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `zoom({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
@@ -550,7 +550,7 @@ Present responsive location information and markers with map-ready metadata and
Theme-aware, responsive wysiwyg editor component.
- Mount: `data-component="WysiwygEditor"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Wysiwyg Editor"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Wysiwyg Editor"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `value: string = ""`, `readonly: boolean = false`, `placeholder: string = "Write something…"`, `class: string = ""`
- Slots: `default`
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
@@ -862,7 +862,7 @@ Theme-aware, responsive sidebar component.
Theme-aware, responsive stepper component.
- Mount: `data-component="Stepper"`
- Props: `color: string = "primary"`, `size: string = "default"`, `steps: unknown[] = []`, `active: number = 0`, `orientation: string = "horizontal"`, `clickable: boolean = false`, `label: string = "Progress"`, `showPanel: boolean = false`, `controls: boolean = false`, `allowSkip: boolean = false`, `nextDisabled: boolean = false`, `backLabel: string = "Back"`, `nextLabel: string = "Next"`, `skipLabel: string = "Skip"`, `finishLabel: string = "Finish"`, `class: string = ""`
- Props: `color: string = "primary"`, `size: string = "default"`, `steps: unknown[] = []`, `active: number = 0`, `controlled: boolean = false`, `orientation: string = "horizontal"`, `clickable: boolean = false`, `label: string = "Progress"`, `showPanel: boolean = false`, `controls: boolean = false`, `allowSkip: boolean = false`, `nextDisabled: boolean = false`, `backLabel: string = "Back"`, `nextLabel: string = "Next"`, `skipLabel: string = "Skip"`, `finishLabel: string = "Finish"`, `class: string = ""`
- Slots: `step-{index}`, `panel-{index}`, `default`
- Outputs: `change({ index: number; step: object })`, `back({ index: number; step: object })`, `next({ index: number; step: object })`, `skip({ index: number; step: object })`, `finish({ index: number; step: object })`
@@ -900,7 +900,7 @@ Present responsive modal side or bottom content with focus management, backdrop
Open an accessible anchored menu with keyboard navigation, item selection, actions, and responsive placement.
- Mount: `data-component="Dropdown"`
- Props: `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `label: string = "Open menu"`, `ariaLabel: string = ""`, `icon: string = ""`, `showChevron: boolean = true`, `menuLabel: string = "Dropdown menu"`, `placement: string = "bottom-start"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `emptyLabel: string = "No menu items"`, `class: string = ""`
- Props: `value: string | number = ""`, `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `label: string = "Open menu"`, `ariaLabel: string = ""`, `icon: string = ""`, `showChevron: boolean = true`, `menuLabel: string = "Dropdown menu"`, `placement: string = "bottom-start"`, `mobilePanel: string = "sheet"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `emptyLabel: string = "No menu items"`, `class: string = ""`
- Slots: `trigger`, `header`, `default`, `footer`
- Outputs: `toggle({ open: boolean; sourceEvent: Event; reason?: object })`, `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `select({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`, `action({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`
@@ -909,7 +909,7 @@ Open an accessible anchored menu with keyboard navigation, item selection, actio
Present an accessible modal dialog with focus management, confirmation, cancellation, slots, and responsive sizing.
- Mount: `data-component="Modal"`
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `title: string = "Modal"`, `description: string = ""`, `icon: string = ""`, `label: string = "Modal dialog"`, `size: string = "md"`, `placement: string = "center"`, `color: string = "primary"`, `variant: string = "default"`, `showClose: boolean = true`, `closeLabel: string = "Close modal"`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `closeOnCancel: boolean = true`, `closeOnConfirm: boolean = false`, `showFooter: boolean = true`, `cancelLabel: string = "Cancel"`, `cancelIcon: string = ""`, `confirmLabel: string = "Confirm"`, `confirmIcon: string = ""`, `confirmDisabled: boolean = false`, `confirmLoading: boolean = false`, `destructive: boolean = false`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `scrollable: boolean = true`, `scrollBehavior: string = "inside"`, `class: string = ""`
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `title: string = "Modal"`, `description: string = ""`, `icon: string = ""`, `label: string = "Modal dialog"`, `size: string = "md"`, `placement: string = "center"`, `color: string = "primary"`, `variant: string = "default"`, `showHeader: boolean = true`, `showClose: boolean = true`, `closeLabel: string = "Close modal"`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `closeOnCancel: boolean = true`, `closeOnConfirm: boolean = false`, `showFooter: boolean = true`, `cancelLabel: string = "Cancel"`, `cancelIcon: string = ""`, `confirmLabel: string = "Confirm"`, `confirmIcon: string = ""`, `confirmDisabled: boolean = false`, `confirmLoading: boolean = false`, `destructive: boolean = false`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `scrollable: boolean = true`, `scrollBehavior: string = "inside"`, `class: string = ""`
- Slots: `trigger`, `header`, `default`, `footer`
- Outputs: `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `cancel({ sourceEvent: Event })`, `confirm({ sourceEvent: Event })`
+1 -1
View File
@@ -15,7 +15,7 @@ driven by `var(--wrn-*)` theme tokens, so components restyle instantly when the
theme changes. The tiny JS surface (`src/index.ts`) exists only so the toolchain
(CLI build + dev server) can locate the component directory and stylesheet.
The complete PDF-aligned catalog currently contains **85 components**. The
The complete catalog currently contains **102 components**. The
generated `COMPONENTS.md` and `component-reference.json` files document every
mount name, prop, inferred type, default/required status, slot, event, category,
and source file directly from the packaged `.wrn` source.
+245 -7
View File
@@ -1503,6 +1503,13 @@
"default": "\"Badge\"",
"options": []
},
{
"name": "humanize",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "size",
"type": "string",
@@ -2039,6 +2046,13 @@
"default": "\"\"",
"options": []
},
{
"name": "tooltipPlacement",
"type": "string",
"required": false,
"default": "\"top\"",
"options": []
},
{
"name": "autofocus",
"type": "boolean",
@@ -2479,6 +2493,20 @@
"default": "\"\"",
"options": []
},
{
"name": "showHeader",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "showFooter",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "header",
"type": "string",
@@ -2704,7 +2732,7 @@
"options": []
}
],
"slots": ["default"],
"slots": ["header", "default", "footer"],
"outputs": [
{
"name": "click",
@@ -2880,6 +2908,13 @@
"default": "\"Content carousel\"",
"options": []
},
{
"name": "framed",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "variant",
"type": "string",
@@ -2979,6 +3014,20 @@
"default": "[]",
"options": []
},
{
"name": "data",
"type": "unknown[]",
"required": false,
"default": "[]",
"options": []
},
{
"name": "type",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "valueKey",
"type": "string",
@@ -3437,6 +3486,13 @@
"default": "\"\"",
"options": []
},
{
"name": "iconOnly",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "code",
"type": "boolean",
@@ -4144,7 +4200,7 @@
"name": "count",
"type": "number",
"required": false,
"default": "24",
"default": "80",
"options": []
},
{
@@ -4879,21 +4935,21 @@
},
{
"name": "months",
"type": "string",
"type": "{ label: string; value: string }[]",
"required": false,
"default": "[{ label: \"Jan\", value: \"01\" }, { label: \"Feb\", value: \"02\" }, { label: \"Mar\", value: \"03\" }, { label: \"Apr\", value: \"04\" }, { label: \"May\", value: \"05\" }, { label: \"Jun\", value: \"06\" }, { label: \"Jul\", value: \"07\" }, { label: \"Aug\", value: \"08\" }, { label: \"Sep\", value: \"09\" }, { label: \"Oct\", value: \"10\" }, { label: \"Nov\", value: \"11\" }, { label: \"Dec\", value: \"12\" }]",
"options": []
},
{
"name": "days",
"type": "string",
"type": "number[]",
"required": false,
"default": "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]",
"options": []
},
{
"name": "years",
"type": "string",
"type": "number[]",
"required": false,
"default": "[2024, 2025, 2026, 2027, 2028, 2029, 2030]",
"options": []
@@ -5447,6 +5503,13 @@
"category": "overlays",
"purpose": "Open an accessible anchored menu with keyboard navigation, item selection, actions, and responsive placement.",
"props": [
{
"name": "value",
"type": "string | number",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "items",
"type": "unknown[]",
@@ -5510,6 +5573,13 @@
"default": "\"bottom-start\"",
"options": []
},
{
"name": "mobilePanel",
"type": "string",
"required": false,
"default": "\"sheet\"",
"options": []
},
{
"name": "width",
"type": "string",
@@ -6334,6 +6404,13 @@
"default": "\"Retry upload\"",
"options": []
},
{
"name": "showCompleteAction",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "class",
"type": "string",
@@ -8070,6 +8147,13 @@
"default": "\"default\"",
"options": []
},
{
"name": "value",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "class",
"type": "string",
@@ -8140,6 +8224,69 @@
"default": "\"default\"",
"options": []
},
{
"name": "provider",
"type": "string",
"required": false,
"default": "\"openstreetmap\"",
"options": []
},
{
"name": "apiKey",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "query",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "latitude",
"type": "number",
"required": false,
"default": "12.9716",
"options": []
},
{
"name": "longitude",
"type": "number",
"required": false,
"default": "77.5946",
"options": []
},
{
"name": "zoomLevel",
"type": "number",
"required": false,
"default": "13",
"options": []
},
{
"name": "mapType",
"type": "string",
"required": false,
"default": "\"roadmap\"",
"options": []
},
{
"name": "imageSrc",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "imageAlt",
"type": "string",
"required": false,
"default": "\"Map background\"",
"options": []
},
{
"name": "class",
"type": "string",
@@ -8693,7 +8840,7 @@
"name": "hover",
"type": "string",
"required": false,
"default": "\"lift\"",
"default": "\"none\"",
"options": []
},
{
@@ -8913,6 +9060,13 @@
"default": "\"default\"",
"options": []
},
{
"name": "showHeader",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "showClose",
"type": "boolean",
@@ -10914,6 +11068,41 @@
"default": "\"default\"",
"options": []
},
{
"name": "value",
"type": "number",
"required": false,
"default": "0",
"options": []
},
{
"name": "max",
"type": "number",
"required": false,
"default": "5",
"options": []
},
{
"name": "step",
"type": "number",
"required": false,
"default": "1",
"options": []
},
{
"name": "readonly",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "disabled",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "class",
"type": "string",
@@ -12130,6 +12319,13 @@
"default": "0",
"options": []
},
{
"name": "controlled",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "orientation",
"type": "string",
@@ -12517,6 +12713,20 @@
"default": "\"default\"",
"options": []
},
{
"name": "icon",
"type": "string",
"required": false,
"default": "\"icon-[lucide--sparkles]\"",
"options": []
},
{
"name": "label",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "class",
"type": "string",
@@ -12525,7 +12735,7 @@
"options": []
}
],
"slots": ["default"],
"slots": [],
"outputs": [],
"events": [],
"source": "components/StyledIcon.wrn"
@@ -13735,6 +13945,13 @@
"default": "\"Password\"",
"options": []
},
{
"name": "icon",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "name",
"type": "string",
@@ -14260,6 +14477,27 @@
"default": "\"default\"",
"options": []
},
{
"name": "value",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "readonly",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "placeholder",
"type": "string",
"required": false,
"default": "\"Write something…\"",
"options": []
},
{
"name": "class",
"type": "string",
+11 -4
View File
@@ -77,7 +77,7 @@ component Accordion {
wasOpen = isOpen(value)
if (wasOpen) {
openValues = openValues.filter((entry) => entry !== value)
openValues = openValues.slice(0, openValues.indexOf(value)).concat(openValues.slice(openValues.indexOf(value) + 1))
} else if (allowsMultiple()) {
openValues = openValues.concat([value])
} else {
@@ -102,6 +102,7 @@ component Accordion {
data-wrn-accordion
data-variant="{variant}"
data-indicator="{indicator}"
data-indicator-position="{showIndicator ? indicatorPosition : 'none'}"
data-multiple="{allowsMultiple() ? 'true' : 'false'}"
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--accordion wrn-next--accordion-{variant} {bordered ? 'wrn-next--accordion-bordered' : ''} {separated ? 'wrn-next--accordion-separated' : ''} {flush ? 'wrn-next--accordion-flush' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
>
@@ -208,7 +209,7 @@ component Accordion {
style {
.wrn-next--accordion {
--wrn-accordion-accent: var(--wrn-component-color);
--wrn-accordion-accent: var(--wrn-color-primary-text);
display: grid;
width: 100%;
color: var(--wrn-color-text);
@@ -234,7 +235,7 @@ component Accordion {
color: var(--wrn-color-text);
font: inherit;
font-size: 0.88rem;
font-weight: 700;
font-weight: 600;
text-align: left;
cursor: pointer;
}
@@ -319,12 +320,16 @@ component Accordion {
}
.wrn-next__accordion-content {
padding: 0 0 1rem 1.75rem;
padding: 0 0 1rem;
color: var(--wrn-color-muted);
font-size: 0.86rem;
line-height: 1.65;
}
.wrn-next--accordion[data-indicator-position="start"] .wrn-next__accordion-content {
padding-inline-start: 1.75rem;
}
.wrn-next__accordion-content > p {
color: inherit;
}
@@ -369,6 +374,8 @@ component Accordion {
.wrn-next--accordion-separated {
gap: 0.5rem;
border: 0;
background: transparent;
}
.wrn-next--accordion-separated > .wrn-next__accordion-item {
+7 -5
View File
@@ -73,8 +73,9 @@ component AdvancedSelect {
state selectedValues = values
functions {
shared function groupOptions(group) { return group.options || [] }
shared function allOptions() {
return [...groups.flatMap((group) => group.options || []), ...options]
return groups.flatMap(groupOptions).concat(options)
}
shared function searchableText(option) {
@@ -95,7 +96,7 @@ component AdvancedSelect {
}
shared function matchingOptions(list) {
return list.filter((option) => matches(option))
return list.filter(matches)
}
shared function visibleOptions(list) {
@@ -118,7 +119,7 @@ component AdvancedSelect {
}
shared function selectedOptions() {
return allOptions().filter((option) => isSelected(option))
return allOptions().filter(isSelected)
}
shared function selectedCount() {
@@ -475,13 +476,14 @@ component AdvancedSelect {
.wrn-next__select-search .wrn-next__search-icon {
inset-inline-start: 0.8em;
left: auto;
width: 1em;
height: 1em;
pointer-events: none;
}
.wrn-next__select-search input {
.wrn-next--advanced-select .wrn-next__select-search input {
min-height: var(--wrn-control-height, 2.5rem);
font-size: 0.875rem;
padding-inline-start: 2.4em;
padding-inline-end: 0.75em;
}
+2 -2
View File
@@ -37,7 +37,7 @@ badge: string = ""
state dismissed: boolean = false
view {
<aside
<div
{...attrs}
data-ui-component="AnnouncementBar"
data-size='{size}'
@@ -149,7 +149,7 @@ badge: string = ""
{/if}
</div>
</div>
</aside>
</div>
}
style {
+2 -2
View File
@@ -1,6 +1,6 @@
import Button from "./button.wrn"
import Checkbox from "./Checkbox.wrn"
import Input from "./Input.wrn"
import Checkbox from "./checkbox.wrn"
import Input from "./input.wrn"
import PinInput from "./PinInput.wrn"
component AuthForm {
+2 -2
View File
@@ -160,7 +160,7 @@ component AuthSplitLayout {
.wrn-auth-split__message h1 {
max-width: 14ch;
margin: 0.65rem 0 0;
font-size: clamp(2rem, 4vw, 3.75rem);
font-size: clamp(1.75rem, 3vw, 2.5rem);
font-weight: 650;
letter-spacing: -0.045em;
line-height: 1.08;
@@ -221,7 +221,7 @@ component AuthSplitLayout {
}
.wrn-auth-split__form-inner {
width: min(100%, 30rem);
width: min(100%, 24rem);
}
@media (max-width: 800px) {
+6 -1
View File
@@ -63,7 +63,7 @@ component AvatarGroup {
data-color="{item.color || color}"
data-variant="{item.variant || variant}"
tabindex="{showTooltips && (item.tooltip || item.name) ? '0' : '-1'}"
aria-label="{item.name || item.alt || item.initials || 'Group member'}"
aria-label="{item.tooltip || item.name || item.alt || item.initials || 'Group member'}"
>
<span
class="wrn-next__avatar-group-avatar"
@@ -241,6 +241,8 @@ component AvatarGroup {
.wrn-next__avatar-group-avatar,
.wrn-next__avatar-group-overflow-button {
white-space: nowrap;
padding: 0;
display: grid;
width: 100%;
height: 100%;
@@ -264,6 +266,9 @@ component AvatarGroup {
}
.wrn-next__avatar-group-overflow-button {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--wrn-color-text);
background: var(--wrn-color-surface-2);
+1
View File
@@ -136,6 +136,7 @@ label: string = "Breadcrumb"
--breadcrumb-text: var(--wrn-color-primary-text);
min-width: 0;
max-width: 100%;
color: var(--wrn-color-text-muted);
}
+24 -4
View File
@@ -28,7 +28,7 @@ items: unknown[] = []
return item.value || item.label || String(index)
}
client function selectItem(item, index) {
client function selectItem(item, index, previousValue, nextValue) {
previousValue = selectedValue
nextValue = itemValue(item, index)
@@ -99,6 +99,26 @@ items: unknown[] = []
isolation: isolate;
}
.wrn-next--button-group > .wrn-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0 0.85rem;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-sm);
background: var(--wrn-color-surface);
color: var(--wrn-color-text);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.wrn-next--button-group > .wrn-btn:hover { background: var(--wrn-color-surface-2); }
.wrn-next--button-group > .wrn-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.wrn-next--button-group > .wrn-btn .wrn-btn__icon { width: 1rem; height: 1rem; }
.wrn-next--button-group[data-size="sm"] > .wrn-btn { min-height: 2rem; font-size: 0.8rem; }
.wrn-next--button-group[data-orientation="vertical"] {
flex-direction: column;
}
@@ -182,9 +202,9 @@ items: unknown[] = []
}
.wrn-next--button-group[data-selectable="true"] > .wrn-btn[data-selected="true"] {
z-index: 1;
border-color: var(--wrn-component-color);
border-color: var(--wrn-color-primary-text);
background: color-mix(in srgb, var(--wrn-component-color) 16%, var(--wrn-color-surface));
color: var(--wrn-component-color);
color: var(--wrn-color-primary-text);
}
@media (max-width: 480px) {
.wrn-next--button-group[data-responsive="true"] > :is(.wrn-btn, .wrn-action) {
@@ -292,7 +312,7 @@ items: unknown[] = []
}
.wrn-component :is(a, button, input, select, textarea):focus-visible {
outline-color: var(--wrn-component-color);
outline-color: var(--wrn-color-primary-text);
}
.wrn-component p {
+24 -12
View File
@@ -16,6 +16,8 @@ component Card {
subtitleKey: string = ""
description: string = ""
descriptionKey: string = ""
showHeader: boolean = true
showFooter: boolean = true
header: string = ""
footer: string = ""
imageSrc: string = ""
@@ -170,8 +172,8 @@ component Card {
{/if}
<div class="wrn-next__card-content">
{#if header || title || subtitle || headerActions.length > 0 || dismissible}
<header class="wrn-next__card-header">
{#if showHeader}
<header class="wrn-next__card-header"><slot name="header" />
<div class="wrn-next__card-heading">
{#if header}
<p class="wrn-next__card-eyebrow">{header}</p>
@@ -284,8 +286,8 @@ component Card {
{/if}
</div>
{#if footer || actionLabel}
<footer class="wrn-next__card-footer">
{#if showFooter}
<footer class="wrn-next__card-footer"><div class="wrn-next__card-custom-footer"><slot name="footer" /></div>
{#if footer}
<span>{footer}</span>
{/if}
@@ -321,6 +323,8 @@ component Card {
}
style {
.wrn-next__card-custom-footer:empty { display:none; }
.wrn-next__card-header:not(:has(strong,h1,h2,h3,h4,p,button,[data-slot])), .wrn-next__card-footer:not(:has(> span, > a, > .wrn-next__card-custom-footer:not(:empty))) { display:none; }
.wrn-next__card-media--overlay {
aspect-ratio: 16 / 9;
min-height: 18rem;
@@ -340,7 +344,7 @@ component Card {
.wrn-next--card {
--wrn-card-color: var(--wrn-component-color, var(--wrn-color-primary));
--wrn-card-padding: 1.25rem;
--wrn-card-padding: var(--wrn-space-panel, 1.25rem);
display: block;
width: 100%;
color: var(--wrn-color-text);
@@ -354,10 +358,15 @@ component Card {
.wrn-next--card[data-shadow="none"] > .wrn-next__card-panel { box-shadow: none; }
.wrn-next--card[data-equal-height="true"], .wrn-next--card[data-equal-height="true"] > .wrn-next__card-panel { height: 100%; }
.wrn-next--card[data-layout="horizontal"] .wrn-next__card-body { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 0.8rem; }
.wrn-next__card-icon { display: grid; width: 2.35rem; height: 2.35rem; place-items: center; border-radius: 0.7rem; color: var(--wrn-color-primary); background: color-mix(in srgb, var(--wrn-color-primary) 12%, transparent); font-size: 1.15rem; }
.wrn-next__card-icon { display: inline-block; width: 2rem; height: 2rem; color: var(--wrn-card-icon-color, var(--wrn-color-primary-text)); background-color: currentColor; }
.wrn-next--card.wrn-component--color-secondary { --wrn-card-icon-color: var(--wrn-color-secondary-text); }
.wrn-next--card.wrn-component--color-success { --wrn-card-icon-color: var(--wrn-color-success-text); }
.wrn-next--card.wrn-component--color-warning { --wrn-card-icon-color: var(--wrn-color-warning-text); }
.wrn-next--card.wrn-component--color-danger { --wrn-card-icon-color: var(--wrn-color-danger-text); }
.wrn-next--card.wrn-component--color-info { --wrn-card-icon-color: var(--wrn-color-info-text); }
.wrn-next--card[data-size="lg"] {
--wrn-card-padding: 1.75rem;
--wrn-card-padding: 1.5rem;
}
.wrn-next__card-panel {
@@ -370,7 +379,7 @@ component Card {
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-md);
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-1);
box-shadow: none;
transition:
transform var(--wrn-motion-base) var(--wrn-ease-emphasized),
border-color var(--wrn-motion-base) ease,
@@ -384,8 +393,8 @@ component Card {
.wrn-next--card[data-hover="raise"] > .wrn-next__card-panel:hover {
border-color: color-mix(in srgb, var(--wrn-card-color) 38%, var(--wrn-color-border));
box-shadow: var(--wrn-shadow-3);
transform: translateY(-4px);
box-shadow: var(--wrn-shadow-2);
transform: translateY(-2px);
}
.wrn-next__card-header,
@@ -531,7 +540,7 @@ component Card {
.wrn-next__card-subtitle {
color: var(--wrn-color-muted);
font-size: 0.72rem;
font-weight: 700;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -626,9 +635,12 @@ component Card {
min-height: 100%;
}
.wrn-next__card-group .wrn-next__card-content { padding: var(--wrn-card-padding, 1.25rem); display: grid; gap: 0.65rem; }
.wrn-next__card-content h3 { margin: 0; font-size: 1rem; font-weight: 600; }
.wrn-next__card-group {
display: grid;
grid-template-columns: repeat(var(--wrn-card-columns, 3), minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
width: 100%;
}
.wrn-next__card-group > .wrn-next__card-panel {
+24
View File
@@ -34,6 +34,7 @@ component Carousel {
showCounter: boolean = false
thumbnails: string = "none"
ariaLabel: string = "Content carousel"
framed: boolean = false
variant: string = "default"
class: string = ""
}
@@ -316,6 +317,7 @@ component Carousel {
view {
<section
{...attrs}
data-framed="{framed}"
class="wrn-component wrn-next--carousel wrn-component--color-{color} wrn-component--size-{size} wrn-next--variant-{variant} {class}"
data-rtl="{isRTL}"
data-centered="{isCentered}"
@@ -477,6 +479,22 @@ component Carousel {
box-shadow: var(--wrn-shadow-1);
}
.wrn-next--carousel[data-framed="false"] {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.wrn-next__carousel-slide:has(> img)::after {
content: "";
position: absolute;
inset: 0;
background: rgb(0 0 0 / 0.4);
pointer-events: none;
}
.wrn-next__carousel-slide:has(> img) .wrn-next__carousel-slide-content,
.wrn-next__carousel-slide:has(> img) .wrn-next__carousel-slide-content p { color: white; }
.wrn-next__carousel-header h3,
.wrn-next__carousel-header p {
margin: 0;
@@ -515,6 +533,12 @@ component Carousel {
outline: none;
}
.wrn-next--carousel[data-framed="false"] .wrn-next__carousel-viewport {
border: 0;
border-radius: 0;
background: transparent;
}
.wrn-next__carousel-viewport:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-carousel-color) 30%, transparent);
}
+32 -23
View File
@@ -9,6 +9,8 @@ component Chart {
title: string = "Chart"
description: string = ""
items: unknown[] = []
data: unknown[] = []
type: string = ""
valueKey: string = "value"
labelKey: string = "label"
height: number = 240
@@ -23,15 +25,17 @@ component Chart {
state hiddenIndexes = []
functions {
shared function chartData() { return data.length ? data : items }
shared function linePoints() { return chartData().map(function (item, index) { return (barX(index) + barWidth() / 2) + "," + barY(item) }).join(" ") }
shared function numericValue(item) { return Math.max(0, Number(item[valueKey]) || 0) }
shared function maximumValue() {
return Math.max(1, ...items.map(function (item) { return numericValue(item) }))
return Math.max(1, ...chartData().map(function (item) { return numericValue(item) }))
}
shared function isHidden(index) { return hiddenIndexes.includes(index) }
shared function barWidth() { return items.length ? Math.max(4, 80 / items.length) : 80 }
shared function barX(index) { return 10 + index * (80 / Math.max(1, items.length)) }
shared function barHeight(item) { return numericValue(item) / maximumValue() * 72 }
shared function barY(item) { return 84 - barHeight(item) }
shared function barWidth() { return chartData().length ? Math.max(4, 400 / chartData().length) : 400 }
shared function barX(index) { return 50 + index * (500 / Math.max(1, chartData().length)) }
shared function barHeight(item) { return numericValue(item) / maximumValue() * 160 }
shared function barY(item) { return 200 - barHeight(item) }
client function selectPoint(item, index, sourceEvent) {
output.dataPointClick({ item: item, index: index, sourceEvent: sourceEvent })
@@ -58,25 +62,21 @@ component Chart {
{#if description}<span>{description}</span>{/if}
</figcaption>
{/if}
<svg class="wrn-chart__plot" viewBox="0 0 100 100" role="img" aria-label='{title}' style='height:{height}px'>
<line x1="8" y1="84" x2="96" y2="84" class="wrn-chart__axis" />
{#each items as item, index}
<g data-hidden='{isHidden(index) ? "true" : "false"}'>
<rect
x='{barX(index)}' y='{barY(item)}' width='{barWidth()}' height='{barHeight(item)}'
rx="1.5" class="wrn-chart__bar" role="button" tabindex="0"
aria-label='{item[labelKey] + ": " + numericValue(item)}'
@click='selectPoint(item, index, event)'
@keydown='if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectPoint(item, index, event) }'
/>
{#if showValues}<text x='{barX(index) + barWidth() / 2}' y='{barY(item) - 3}' class="wrn-chart__value">{numericValue(item)}</text>{/if}
<text x='{barX(index) + barWidth() / 2}' y="94" class="wrn-chart__label">{item[labelKey]}</text>
</g>
{/each}
</svg>
{#if (type || variant) === "line"}
<svg class="wrn-chart__line" viewBox="0 0 600 240" role="img" aria-label="{title}" style="height:{height}px"><polyline points="{linePoints()}" fill="none" stroke="var(--wrn-color-primary)" stroke-width="3" /></svg>
<div class="wrn-chart__point-labels">{#each chartData() as item, index}<button type="button" @click="selectPoint(item, index, event)">{item[labelKey]} · {numericValue(item)}</button>{/each}</div>
{:else}
<div class="wrn-chart__columns" style="height:{height}px" role="group" aria-label="{title}">
{#each chartData() as item, index}<button type="button" class="wrn-chart__column" data-hidden="{hiddenIndexes.includes(index)}" aria-label="{item[labelKey] + ': ' + numericValue(item)}" @click="selectPoint(item, index, event)">
{#if showValues}<span>{numericValue(item)}</span>{/if}
<svg viewBox="0 0 60 160" preserveAspectRatio="none" aria-hidden="true" style="height:{numericValue(item) / maximumValue() * Math.max(60, height - 60)}px"><rect x="0" y="0" width="60" height="160" rx="3" fill="var(--wrn-color-primary)" /></svg>
<small>{item[labelKey]}</small>
</button>{/each}
</div>
{/if}
{#if showLegend}
<div class="wrn-chart__legend" aria-label="Chart legend">
{#each items as item, index}
{#each chartData() as item, index}
<button type="button" aria-pressed='{isHidden(index) ? "false" : "true"}' @click='toggleLegend(item, index, event)'>
<span aria-hidden="true"></span>{item[labelKey]}
</button>
@@ -88,6 +88,15 @@ component Chart {
}
style {
.wrn-chart__columns { display:flex; align-items:flex-end; gap:clamp(.5rem,2vw,1.5rem); padding:.5rem; border-bottom:1px solid var(--wrn-color-border); }
.wrn-chart__column { display:flex; flex:1; min-width:0; flex-direction:column; align-items:center; gap:.6rem; padding:0; border:0; background:transparent; color:var(--wrn-color-text); font:inherit; font-size:.8rem; cursor:pointer; }
.wrn-chart__column svg { width:100%; max-width:5rem; min-height:1px; }
.wrn-chart__column small { max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--wrn-color-muted); }
.wrn-chart__column[data-hidden="true"] { opacity:.2; }
.wrn-chart__line { width:100%; display:block; }
.wrn-chart__point-labels { display:flex; justify-content:space-around; gap:.5rem; flex-wrap:wrap; }
.wrn-chart__point-labels button { border:0; background:transparent; color:var(--wrn-color-muted); font:inherit; font-size:.8rem; cursor:pointer; }
.wrn-chart { display: grid; gap: 1rem; min-width: 0; margin: 0; padding: 1rem; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius); color: var(--wrn-color-text); background: var(--wrn-color-surface); box-shadow: var(--wrn-shadow-1); }
.wrn-chart__header { display: grid; gap: 0.2rem; }
.wrn-chart__header > span { color: var(--wrn-color-muted); font-size: 0.8rem; }
@@ -97,7 +106,7 @@ component Chart {
.wrn-chart__bar:hover { opacity: 0.82; transform: scaleY(1.02); }
.wrn-chart__bar:focus-visible { outline: 1.5px solid var(--wrn-color-focus); outline-offset: 1px; }
.wrn-chart__plot g[data-hidden="true"] { opacity: 0.18; pointer-events: none; }
.wrn-chart__value, .wrn-chart__label { fill: var(--wrn-color-text); font-size: 3.5px; text-anchor: middle; }
.wrn-chart__value, .wrn-chart__label { fill: var(--wrn-color-text); font-size: 11px; text-anchor: middle; }
.wrn-chart__label { fill: var(--wrn-color-muted); }
.wrn-chart__legend { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.wrn-chart__legend button { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.3rem 0.5rem; border: 1px solid var(--wrn-color-border); border-radius: 999px; color: var(--wrn-color-text); background: transparent; font: inherit; font-size: 0.75rem; cursor: pointer; }
+2 -1
View File
@@ -12,6 +12,7 @@ component Clipboard {
errorLabel: string = "Copy failed"
title: string = "Clipboard"
description: string = ""
iconOnly: boolean = false
code: boolean = true
size: string = "default"
color: string = "primary"
@@ -73,7 +74,7 @@ component Clipboard {
>
<span class="icon-[lucide--copy] wrn-clipboard__icon" data-show='status !== "success"' aria-hidden="true"></span>
<span class="icon-[lucide--check] wrn-clipboard__icon" data-show='status === "success"' aria-hidden="true"></span>
<span>{status === "success" ? copiedLabel : status === "error" ? errorLabel : label}</span>
{#if !iconOnly}<span>{status === "success" ? copiedLabel : status === "error" ? errorLabel : label}</span>{/if}
</button>
</div>
<slot />
+12 -12
View File
@@ -151,10 +151,10 @@ component ColorPicker {
}
.wrn-next--field :where(input, textarea, select) {
width: 100%;
min-height: 2.65rem;
padding: 0.6rem 0.7rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.5rem 0.75rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.6rem;
border-radius: var(--wrn-radius-sm, 6px);
outline: 0;
color: var(--wrn-color-text);
background: var(--wrn-color-bg);
@@ -168,9 +168,9 @@ component ColorPicker {
}
.wrn-auth-form__submit > .wrn-btn {
width: 100%;
min-height: 3rem;
border-radius: 0.75rem;
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
min-height: var(--wrn-control-height, 2.5rem);
border-radius: var(--wrn-radius-sm, 6px);
box-shadow: none;
}
.wrn-next--field :where(input, textarea, select):focus-visible {
border-color: var(--wrn-color-primary);
@@ -212,7 +212,7 @@ component ColorPicker {
.wrn-next__field-hint,
.wrn-next__field-help {
color: var(--wrn-color-muted);
font-size: 0.82em;
font-size: 0.75rem;
font-weight: 500;
}
.wrn-next__field-control {
@@ -226,10 +226,10 @@ component ColorPicker {
background: var(--wrn-color-surface);
font-size: 0.8125rem;
}
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) {
padding-inline-start: 2.5rem;
}
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="end"] > :is(input, textarea, select) {
padding-inline-end: 2.5rem;
}
.wrn-next__field-icon {
@@ -337,7 +337,7 @@ component ColorPicker {
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
@@ -346,7 +346,7 @@ component ColorPicker {
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
display: flex;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--choice-field[data-inline="true"] {
@@ -495,7 +495,7 @@ component ColorPicker {
.wrn-next__time-trigger {
display: flex;
width: 100%;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
justify-content: space-between;
gap: 0.75rem;
+5 -6
View File
@@ -62,8 +62,9 @@ component ComboBox {
state activeIndex: number = -1
functions {
shared function groupOptions(group) { return group.options || [] }
shared function allOptions() {
return [...groups.flatMap((group) => group.options || []), ...options]
return groups.flatMap(groupOptions).concat(options)
}
shared function searchableText(option) {
@@ -84,7 +85,7 @@ component ComboBox {
}
shared function matchingOptions(list) {
return list.filter((option) => matches(option))
return list.filter(matches)
}
shared function visibleOptions(list) {
@@ -103,7 +104,7 @@ component ComboBox {
}
shared function selectedOptions() {
return allOptions().filter((option) => isSelected(option))
return allOptions().filter(isSelected)
}
shared function selectedText() {
@@ -312,8 +313,7 @@ component ComboBox {
role="listbox"
>
{#each groups as group}
{#if visibleOptions(group.options || []).length}
<div class="wrn-next__option-group" role="group" aria-label="{group.label}">
<div class="wrn-next__option-group" role="group" aria-label="{group.label}" data-show="visibleOptions(group.options || []).length > 0">
<div class="wrn-next__group-label">{group.label}</div>
{#each group.options || [] as option}
<button
@@ -336,7 +336,6 @@ component ComboBox {
</button>
{/each}
</div>
{/if}
{/each}
{#each options as option}
+50 -9
View File
@@ -7,7 +7,7 @@ component Confetti {
props {
label: string = "Celebrate"
duration: number = 1200
count: number = 24
count: number = 80
autoStart: boolean = false
disabled: boolean = false
size: string = "default"
@@ -18,9 +18,45 @@ component Confetti {
state running = false
functions {
shared function pieces() { return Array.from({ length: Math.max(1, Math.min(60, count)) }) }
shared function pieces() { return Array.from({ length: Math.max(1, Math.min(200, count)) }) }
shared function pieceStyle(index) {
return "--wrn-confetti-index:" + index + ";--wrn-confetti-x:" + ((index * 37) % 100) + ";--wrn-confetti-delay:" + ((index * 29) % 240) + "ms;"
return "--wrn-confetti-index:" + index + ";--wrn-confetti-x:" + ((index * 37) % 100) + ";--wrn-confetti-delay:" + 0 + "ms;"
}
client function animateFrame(canvas, particles, started) {
var context = canvas.getContext("2d")
var progress = (Date.now() - started) / Math.max(1, duration)
context.clearRect(0, 0, 720, 420)
if (progress >= 1 || !canvas.isConnected) { return }
particles.forEach(function (piece) {
piece.vx = piece.vx * 0.99
piece.vy = piece.vy + 0.19
piece.x = piece.x + piece.vx
piece.y = piece.y + piece.vy
piece.rotation = piece.rotation + piece.spin
context.save()
context.translate(piece.x, piece.y)
context.rotate(piece.rotation)
context.globalAlpha = Math.min(1, (1 - progress) * 4)
context.fillStyle = piece.color
context.fillRect(-3, -4, 6, 8 * Math.cos(piece.rotation))
context.restore()
})
setTimeout(animateFrame, 16, canvas, particles, started)
}
client function animateBurst(sourceEvent) {
var root = sourceEvent.currentTarget.closest(".wrn-confetti")
var canvas = root.querySelector("canvas")
if (!canvas || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { return }
var context = canvas.getContext("2d")
if (!context) { return }
canvas.width = 720
canvas.height = 420
var particles = Array.from({length:Math.min(200, Math.max(1, count))}, function (_, index) {
var angle = (-155 + Math.random() * 130) * Math.PI / 180
var speed = 5 + Math.random() * 9
return {x:360,y:273,vx:Math.cos(angle) * speed,vy:Math.sin(angle) * speed,rotation:Math.random() * 6,spin:(Math.random() - 0.5) * 0.3,color:["#8b5cf6","#14b8a6","#f59e0b","#f472b6","#60a5fa"][index % 5]}
})
animateFrame(canvas, particles, Date.now())
}
client function finish(sourceEvent) {
running = false
@@ -31,6 +67,7 @@ component Confetti {
return
}
running = true
if (sourceEvent) { animateBurst(sourceEvent) }
output.start({ sourceEvent: sourceEvent, duration: duration })
setTimeout(finish, duration, sourceEvent)
}
@@ -41,11 +78,11 @@ component Confetti {
}
view {
<div {...attrs} data-ui-component="Confetti" data-running='{running ? "true" : "false"}' class='wrn-confetti {class}' style='--wrn-confetti-duration:{duration}ms'>
<div {...attrs} data-ui-component="Confetti" data-autostart="{autoStart}" data-running='{running ? "true" : "false"}' class='wrn-confetti {class}' style='--wrn-confetti-duration:{duration}ms'>
<button type="button" class="wrn-confetti__trigger" disabled='{disabled}' @click='start(event)'>
<span class="icon-[lucide--party-popper]" aria-hidden="true"></span><span>{label}</span>
</button>
<div class="wrn-confetti__burst" aria-hidden="true">
<canvas class="wrn-confetti__canvas" aria-hidden="true"></canvas><div class="wrn-confetti__burst" aria-hidden="true">
{#each pieces() as piece, index}<i style='{pieceStyle(index)}'></i>{/each}
</div>
<slot />
@@ -53,17 +90,21 @@ component Confetti {
}
style {
.wrn-confetti__canvas { position:absolute; width:720px; height:420px; left:50%; top:50%; transform:translate(-50%,-65%); pointer-events:none; z-index:5; }
.wrn-confetti__burst { display:none; }
.wrn-confetti[data-autostart="true"] .wrn-confetti__burst { display:block; }
.wrn-confetti { position: relative; display: inline-flex; }
.wrn-confetti__trigger { display: inline-flex; min-height: 2.5rem; align-items: center; gap: 0.5rem; padding: 0.55rem 0.85rem; border: 1px solid var(--wrn-color-primary); border-radius: var(--wrn-radius); color: var(--wrn-color-on-primary); background: var(--wrn-color-primary); font: inherit; font-weight: 700; cursor: pointer; }
.wrn-confetti__trigger > span:first-child { width: 1rem; height: 1rem; }
.wrn-confetti__trigger:focus-visible { outline: 2px solid var(--wrn-color-focus); outline-offset: 2px; }
.wrn-confetti__trigger:disabled { cursor: not-allowed; opacity: 0.55; }
.wrn-confetti__burst { position: absolute; inset: 50% 0 auto; height: 0; pointer-events: none; }
.wrn-confetti__burst > i { position: absolute; left: calc(var(--wrn-confetti-x) * 1%); width: 0.45rem; height: 0.7rem; border-radius: 0.1rem; opacity: 0; background: hsl(calc(var(--wrn-confetti-index) * 47deg) 80% 55%); transform: translate(-50%, 0) rotate(calc(var(--wrn-confetti-index) * 23deg)); }
.wrn-confetti[data-running="true"] .wrn-confetti__burst > i { animation: wrn-confetti-burst var(--wrn-confetti-duration) cubic-bezier(0.2, 0.7, 0.2, 1) var(--wrn-confetti-delay) both; }
.wrn-confetti__burst { position: absolute; inset: 50% 0 auto; z-index:10; height: 0; pointer-events: none; }
.wrn-confetti__burst > i { position: absolute; left:50%; width: 0.45rem; height: 0.7rem; border-radius: 0.1rem; opacity: 0; background: hsl(calc(var(--wrn-confetti-index) * 47deg) 80% 55%); transform: translate(-50%, 0) rotate(calc(var(--wrn-confetti-index) * 23deg)); }
.wrn-confetti[data-running="true"] .wrn-confetti__burst > i { animation: wrn-confetti-burst var(--wrn-confetti-duration) linear var(--wrn-confetti-delay) both; }
@keyframes wrn-confetti-burst {
0% { opacity: 1; transform: translate(-50%, 0) rotate(0); }
100% { opacity: 0; transform: translate(calc(-50% + (var(--wrn-confetti-x) - 50) * 0.9px), 9rem) rotate(540deg); }
30% { opacity: 1; transform: translate(calc(-50% + (var(--wrn-confetti-x) - 50) * 6px), calc(-5rem - var(--wrn-confetti-x) * 1.2px)) rotate(180deg); }
100% { opacity: 0; transform: translate(calc(-50% + (var(--wrn-confetti-x) - 50) * 9px), 14rem) rotate(540deg); }
}
@media (prefers-reduced-motion: reduce) {
.wrn-confetti[data-running="true"] .wrn-confetti__burst > i { animation-duration: 1ms; animation-delay: 0ms; }
+10 -4
View File
@@ -37,6 +37,12 @@ component ContextMenu {
return open || visible
}
client function handleContext(sourceEvent) {
if (trigger === "contextmenu" || trigger === "both") { sourceEvent.preventDefault(); showMenu(sourceEvent) }
}
client function handleClick(sourceEvent) {
if (trigger === "click" || trigger === "both") { toggleMenu(sourceEvent) }
}
client function showMenu(sourceEvent) {
if (disabled) {
return
@@ -146,8 +152,8 @@ component ContextMenu {
tabindex='{disabled ? "-1" : "0"}'
aria-haspopup="menu"
aria-expanded='{open || visible ? "true" : "false"}'
@contextmenu='if (trigger === "contextmenu" || trigger === "both") { showMenu(event) }'
@click='if (trigger === "click" || trigger === "both") { toggleMenu(event) }'
@contextmenu='handleContext(event)'
@click='handleClick(event)'
@keydown='if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggleMenu(event) }'
>
<slot name="trigger"></slot>
@@ -296,7 +302,7 @@ component ContextMenu {
}
.wrn-context-menu__trigger:focus-visible {
border-radius: 0.6rem;
border-radius: var(--wrn-radius-sm, 6px);
outline: 2px solid var(--wrn-color-focus);
outline-offset: 3px;
}
@@ -325,7 +331,7 @@ component ContextMenu {
linear-gradient(145deg, color-mix(in srgb, var(--context-accent) 5%, transparent), transparent 58%),
color-mix(in srgb, var(--wrn-color-surface-raised) 96%, transparent);
border: 1px solid color-mix(in srgb, var(--context-accent) 18%, var(--wrn-color-border));
border-radius: 1rem;
border-radius: var(--wrn-radius-md);
box-shadow:
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
0 24px 64px color-mix(in srgb, black 24%, transparent);
+13 -87
View File
@@ -13,96 +13,22 @@ component DataMap {
variant: string = "default"
class: string = ""
}
state selectedLabel: string = ""
functions {
client function choose(item) { selectedLabel = item.label; output.select({ value: item.value, item: item }); output.change({ value: item.value, item: item }) }
shared function shade(item) { return Math.max(6, Math.min(35, (Number(item.value) || 0) / 3)) + "%" }
}
view {
<section class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--data-map wrn-next--variant-{variant} {class}">
{#if title}<strong>{title}</strong>{/if}
{#if description}<p>{description}</p>{/if}
{#if items}<div class="wrn-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
<slot />
</section>
<section {...attrs} class="wrn-data-map {class}">{#if title}<strong>{title}</strong>{/if}{#if description}<p>{description}</p>{/if}<div class="wrn-data-map__grid" role="group" aria-label="{title}">{#each items as item}<button type="button" aria-pressed="{selectedLabel === item.label}" style="--heat:{shade(item)}" @click="choose(item)"><strong>{item.label}</strong><span>{item.value}</span></button>{/each}</div><small>Lower activity <span class="wrn-data-map__legend"></span> Higher activity</small><slot /></section>
}
style {
.wrn-next--data-map {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
color: var(--wrn-color-text);
background: var(--wrn-color-surface);
}
.wrn-next--data-map > p { margin: 0; color: var(--wrn-color-muted); }
.wrn-next--data-map > .wrn-next__items { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.wrn-next--data-map > .wrn-next__items > span { padding: 0.35rem 0.6rem; border-radius: var(--wrn-radius-sm); background: var(--wrn-color-surface-2); }
/* Shared component foundation. */
.wrn-component {
--wrn-component-color: var(--wrn-color-primary);
--wrn-component-scale: 1;
margin: 0;
color: var(--wrn-color-text);
font-family: var(--wrn-font-sans, inherit);
}
.wrn-component--color-primary {
--wrn-component-color: var(--wrn-color-primary);
}
.wrn-component--color-secondary {
--wrn-component-color: var(--wrn-color-secondary);
}
.wrn-component--color-success {
--wrn-component-color: var(--wrn-color-success);
}
.wrn-component--color-warning {
--wrn-component-color: var(--wrn-color-warning);
}
.wrn-component--color-danger {
--wrn-component-color: var(--wrn-color-danger);
}
.wrn-component--color-info {
--wrn-component-color: var(--wrn-color-info);
}
.wrn-component--size-xs {
--wrn-component-scale: 0.78;
}
.wrn-component--size-sm {
--wrn-component-scale: 0.88;
}
.wrn-component--size-default,
.wrn-component--size-md {
--wrn-component-scale: 1;
}
.wrn-component--size-lg {
--wrn-component-scale: 1.14;
}
.wrn-component--size-xl {
--wrn-component-scale: 1.28;
}
.wrn-component:not(.wrn-btn) {
font-size: calc(1em * var(--wrn-component-scale));
}
.wrn-component :is(a, button, input, select, textarea):focus-visible {
outline-color: var(--wrn-component-color);
}
.wrn-component p {
margin: 0;
color: var(--wrn-color-muted);
line-height: 1.6;
}
.wrn-data-map { display:grid; gap:.85rem; color:var(--wrn-color-text); }
.wrn-data-map p { margin:0; color:var(--wrn-color-muted); font-size:.875rem; }
.wrn-data-map__grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(6rem,1fr)); gap:.4rem; }
.wrn-data-map button { display:grid; gap:.35rem; padding:1rem .65rem; border:1px solid var(--wrn-color-border); border-radius:6px; background:color-mix(in srgb,var(--wrn-color-primary) var(--heat),var(--wrn-color-surface)); color:var(--wrn-color-text); font:inherit; font-size:.8rem; cursor:pointer; }
.wrn-data-map button[aria-pressed="true"] { outline:2px solid var(--wrn-color-primary-text); outline-offset:2px; }
.wrn-data-map small { display:flex; align-items:center; gap:.5rem; color:var(--wrn-color-muted); }
.wrn-data-map__legend { display:inline-block; width:4rem; height:.5rem; background:linear-gradient(90deg,var(--wrn-color-primary-soft),var(--wrn-color-primary)); border-radius:4px; }
}
}
+4 -3
View File
@@ -964,6 +964,7 @@ component DataTable {
size="sm"
placement="top-end"
label=""
value='{perPage}'
items='{pageSizeItems()}'
@select='pickPageSize(payload)'
>
@@ -1053,7 +1054,7 @@ component DataTable {
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-md);
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-1);
box-shadow: none;
}
.wrn-table {
@@ -1174,7 +1175,7 @@ component DataTable {
color: var(--wrn-color-text-muted);
background: var(--wrn-color-surface-soft);
border: 1px solid var(--wrn-color-border);
border-radius: 0.7rem;
border-radius: var(--wrn-radius-sm);
}
.wrn-table__search input {
@@ -1209,7 +1210,7 @@ component DataTable {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--wrn-color-border);
border-radius: 0.9rem;
border-radius: var(--wrn-radius-sm);
}
.wrn-table[data-bordered="false"] .wrn-table__scroll {
+125 -28
View File
@@ -1,3 +1,5 @@
import TimePicker from "./TimePicker.wrn"
component DatePicker {
outputs {
input(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
@@ -20,9 +22,9 @@ size: string = "default"
type: string = "date"
locale: string = "en-US"
firstDayOfWeek: number = 0
months = [{ label: "Jan", value: "01" }, { label: "Feb", value: "02" }, { label: "Mar", value: "03" }, { label: "Apr", value: "04" }, { label: "May", value: "05" }, { label: "Jun", value: "06" }, { label: "Jul", value: "07" }, { label: "Aug", value: "08" }, { label: "Sep", value: "09" }, { label: "Oct", value: "10" }, { label: "Nov", value: "11" }, { label: "Dec", value: "12" }]
days = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
years = [2024, 2025, 2026, 2027, 2028, 2029, 2030]
months: { label: string; value: string }[] = [{ label: "Jan", value: "01" }, { label: "Feb", value: "02" }, { label: "Mar", value: "03" }, { label: "Apr", value: "04" }, { label: "May", value: "05" }, { label: "Jun", value: "06" }, { label: "Jul", value: "07" }, { label: "Aug", value: "08" }, { label: "Sep", value: "09" }, { label: "Oct", value: "10" }, { label: "Nov", value: "11" }, { label: "Dec", value: "12" }]
days: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
years: number[] = [2024, 2025, 2026, 2027, 2028, 2029, 2030]
min: string = ""
max: string = ""
step: string = ""
@@ -37,30 +39,117 @@ size: string = "default"
class: string = ""
}
state currentValue = value
state selectedYear = value ? value.split("-")[0] : ""
state selectedMonth = value ? value.split("-")[1] : ""
state selectedDay = value ? value.split("-")[2] : ""
state selectedYear = value ? value.split("-")[0] : String(new Date().getFullYear())
state selectedMonth = value ? value.split("-")[1] : String(new Date().getMonth() + 1).padStart(2, "0")
state selectedDay = value ? value.slice(8, 10) : ""
state selectedTime = value.includes("T") ? value.split("T")[1].slice(0, 5) : "09:00"
state expanded: boolean = false
functions {
client function openCalendar(sourceEvent) { expanded = true; output.open({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function updateDate(part, nextValue, sourceEvent, nextDate, detail) { if (part === "year") { selectedYear = String(nextValue) } if (part === "month") { selectedMonth = String(nextValue).padStart(2, "0") } if (part === "day") { selectedDay = String(nextValue).padStart(2, "0") } if (!selectedYear || !selectedMonth || !selectedDay) { return } nextDate = selectedYear + "-" + selectedMonth + "-" + selectedDay; if ((min && nextDate < min) || (max && nextDate > max)) { return } currentValue = nextDate; detail = { value: currentValue, year: selectedYear, month: selectedMonth, day: selectedDay, name: name, sourceEvent: sourceEvent }; output.input(detail); output.change(detail) }
client function finishDate(sourceEvent) { if (!currentValue) { return } expanded = false; output.close({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function selectDay(sourceEvent, root, input, nextDate, trigger, detail) { root = sourceEvent.currentTarget.closest(".wrn-next--date-picker"); input = root.querySelector(".wrn-next__picker-value"); nextDate = input.getAttribute("value").slice(0, 5) + root.querySelector("select").getAttribute("value") + "-" + sourceEvent.currentTarget.dataset.value; currentValue = nextDate; input.setAttribute("value", nextDate); trigger = root.querySelector(".wrn-next__date-trigger span"); if (trigger) { trigger.replaceChildren(nextDate) } sourceEvent.currentTarget.setAttribute("aria-pressed", "true"); detail = { value: nextDate, name: name, sourceEvent: sourceEvent }; output.input(detail); output.change(detail) }
client function clearDate(sourceEvent, detail) { if (disabled || readonly) { return } currentValue = ""; detail = { value: currentValue, name: name, sourceEvent: sourceEvent }; output.input(detail); output.change(detail) }
client function updateTime(sourceEvent) {
selectedTime = sourceEvent.currentTarget.value
currentValue = (currentValue ? currentValue.slice(0, 10) : selectedYear + "-" + selectedMonth + "-01") + "T" + selectedTime
output.input({ value: currentValue, name: name, sourceEvent: sourceEvent })
output.change({ value: currentValue, name: name, sourceEvent: sourceEvent })
}
shared function calendarYears() {
if (years.includes(Number(selectedYear))) { return years }
return years.concat(Number(selectedYear))
}
shared function weekdayName(index, first) {
first = ((firstDayOfWeek % 7) + 7) % 7
return Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }).format(Date.UTC(2024, 0, 7 + first + index))
}
shared function weekdays() { return [0, 1, 2, 3, 4, 5, 6].map(weekdayName) }
shared function validDay(day, year, leap, count) {
year = Number(selectedYear)
leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
count = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][Number(selectedMonth) - 1]
return Number(day) >= 1 && Number(day) <= count
}
shared function dayView(day, year, month, first, offset, date, today) {
year = Number(selectedYear)
month = Number(selectedMonth)
first = ((firstDayOfWeek % 7) + 7) % 7
offset = ((Math.floor(Date.UTC(year, month - 1, 1) / 86400000) + 4) % 7 - first + 7) % 7
date = selectedYear + "-" + selectedMonth + "-" + String(day).padStart(2, "0")
today = Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit" }).format(Date.now())
return { day: day, value: date, column: ((offset + Number(day) - 1) % 7) + 1, disabled: disabled || readonly || !!(min && date < min.slice(0, 10)) || !!(max && date > max.slice(0, 10)), today: date === today, label: Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }).format(Date.UTC(year, month - 1, Number(day))) }
}
shared function calendarDays() { return days.filter(validDay).map(dayView) }
client function openCalendar(sourceEvent) {
if (disabled || readonly) { return }
expanded = !expanded
syncCalendarSelectors(sourceEvent)
if (expanded) { output.open({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
else { output.close({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
}
client function updateDate(part, nextValue, sourceEvent) {
if (disabled || readonly) { return }
if (part === "year") { selectedYear = String(nextValue) }
if (part === "month") { selectedMonth = String(nextValue).padStart(2, "0") }
syncCalendarSelectors(sourceEvent)
}
client function syncCalendarSelectors(sourceEvent) {
var root = sourceEvent.currentTarget.closest(".wrn-next--date-picker")
setTimeout(applyCalendarSelectors, 0, root)
}
client function applyCalendarSelectors(root) {
var selectors = root.querySelectorAll(".wrn-next__date-selectors select")
selectors[0].value = selectedMonth
selectors[1].value = selectedYear
}
client function finishDate(sourceEvent) {
var root = sourceEvent.currentTarget.closest(".wrn-next--date-picker")
root.querySelector(".wrn-next__date-trigger").focus()
expanded = false
output.close({ value: currentValue, name: name, sourceEvent: sourceEvent })
}
client function selectDay(sourceEvent) {
if (disabled || readonly || sourceEvent.currentTarget.disabled) { return }
var nextDate = sourceEvent.currentTarget.dataset.value
if ((min && nextDate < min.slice(0, 10)) || (max && nextDate > max.slice(0, 10))) { return }
currentValue = nextDate + (type === "datetime-local" ? "T" + selectedTime : "")
selectedDay = nextDate.slice(8)
syncCalendarSelectors(sourceEvent)
var detail = { value: currentValue, year: selectedYear, month: selectedMonth, day: selectedDay, name: name, sourceEvent: sourceEvent }
output.input(detail)
output.change(detail)
}
client function calendarKeydown(sourceEvent) {
if (sourceEvent.key === "Escape" && expanded) { sourceEvent.preventDefault(); finishDate(sourceEvent); return }
var keys = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 7, ArrowUp: -7 }
if (!keys[sourceEvent.key] || !sourceEvent.target.closest(".wrn-next__calendar-grid")) { return }
sourceEvent.preventDefault()
var root = sourceEvent.currentTarget
var buttons = Array.from(root.querySelectorAll(".wrn-next__calendar-grid button"))
var index = buttons.indexOf(sourceEvent.target) + keys[sourceEvent.key]
while (index >= 0 && index < buttons.length && buttons[index].disabled) { index += keys[sourceEvent.key] > 0 ? 1 : -1 }
if (buttons[index]) { buttons[index].focus() }
}
client function clearDate(sourceEvent) {
if (disabled || readonly) { return }
currentValue = ""
selectedDay = ""
var detail = { value: currentValue, name: name, sourceEvent: sourceEvent }
output.input(detail)
output.change(detail)
}
client function handleFocus(sourceEvent) { output.focus({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function handleBlur(sourceEvent) { output.blur({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function handleInvalid(sourceEvent) { output.invalid({ name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) }
}
view {
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--date-picker {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}" data-expanded="{expanded}">
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--date-picker {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}" data-expanded="{expanded}" @keydown="calendarKeydown(event)">
<div class="wrn-next__field-heading"><label for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
<div class="wrn-next__picker-control"><input id="{id || name}" class="wrn-next__sr-only wrn-next__picker-value" type="text" name="{name}" value="{currentValue}" required="{required}" readonly aria-hidden="true" tabindex="-1" @invalid="handleInvalid(event)" /><button type="button" class="wrn-next__date-trigger" disabled="{disabled || readonly}" aria-haspopup="dialog" aria-expanded="{expanded}" @click="openCalendar(event)" @focus="handleFocus(event)" @blur="handleBlur(event)"><span>{currentValue || placeholder || "Select date"}</span><i class="icon-[lucide--calendar-days]" aria-hidden="true"></i></button>{#if currentValue && !readonly && !disabled}<button type="button" class="wrn-next__picker-clear" aria-label="Clear date" @click="clearDate(event)">×</button>{/if}</div>
<div class="wrn-next__calendar" role="dialog" aria-label="{label}"><div class="wrn-next__date-selectors"><label>Month<select value="{selectedMonth}" @change="updateDate('month', event.currentTarget.value, event)">{#each months as month}<option value="{month.value}" selected="{month.value === selectedMonth}">{month.label}</option>{/each}</select></label><label>Year<select value="{selectedYear}" @change="updateDate('year', event.currentTarget.value, event)">{#each years as year}<option value="{year}" selected="{String(year) === selectedYear}">{year}</option>{/each}</select></label></div><div class="wrn-next__calendar-grid">{#each days as day}<button type="button" data-value="{String(day).padStart(2, '0')}" aria-pressed="{String(day).padStart(2, '0') === selectedDay}" @click="selectDay(event)">{day}</button>{/each}</div><button type="button" class="wrn-next__date-done" disabled="{!currentValue}" @click="finishDate(event)">Done</button></div>
<div class="wrn-next__picker-control"><input id="{id || name}" class="wrn-next__sr-only wrn-next__picker-value" type="text" name="{name}" value="{currentValue}" required="{required}" readonly aria-hidden="true" tabindex="-1" @invalid="handleInvalid(event)" /><button type="button" class="wrn-next__date-trigger" aria-label="{label}" disabled="{disabled || readonly}" aria-haspopup="dialog" aria-expanded="{expanded}" @click="openCalendar(event)" @focus="handleFocus(event)" @blur="handleBlur(event)"><span>{currentValue || placeholder || "Select date"}</span><i class="icon-[lucide--calendar-days]" aria-hidden="true"></i></button>{#if currentValue && !readonly && !disabled}<button type="button" class="wrn-next__picker-clear" aria-label="Clear date" @click="clearDate(event)">×</button>{/if}</div>
<button type="button" class="wrn-picker-dismiss" aria-label="Close calendar" data-show="expanded" @click="finishDate(event)"></button><div data-show="expanded" class="wrn-next__calendar" role="dialog" aria-label="{label}"><div class="wrn-next__date-selectors"><label>Month<select value="{selectedMonth}" @change="updateDate('month', event.currentTarget.value, event)">{#each months as month}<option value="{month.value}" selected="{month.value === selectedMonth}">{month.label}</option>{/each}</select></label><label>Year<select value="{selectedYear}" @change="updateDate('year', event.currentTarget.value, event)">{#each calendarYears() as calendarYear}<option value="{calendarYear}" selected="{String(calendarYear) === selectedYear}" data-text="calendarYear"></option>{/each}</select></label></div><div class="wrn-next__calendar-weekdays" aria-hidden="true">{#each weekdays() as weekday}<span>{weekday}</span>{/each}</div><div class="wrn-next__calendar-grid">{#each calendarDays() as day}<button type="button" data-value="{day.value}" data-today="{day.today}" style="grid-column: {day.column}" disabled="{day.disabled}" aria-label="{day.label}" aria-pressed="{day.value === currentValue.slice(0, 10)}" @click="selectDay(event)">{day.day}</button>{/each}</div>{#if type === "datetime-local"}<TimePicker label="Time" value="{selectedTime}" @change="selectedTime = payload.value; currentValue = (currentValue ? currentValue.slice(0, 10) : selectedYear + '-' + selectedMonth + '-01') + 'T' + selectedTime; output.change({ value: currentValue, name: name })" />{/if}<button type="button" class="wrn-next__date-done" disabled="{!currentValue}" @click="finishDate(event)">Done</button></div>
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}<small class="wrn-next__field-error" data-error="{name}">{error}</small>
</div>
}
style {
.wrn-picker-dismiss { position:fixed; inset:0; z-index:39; opacity:0; cursor:default; border:0; }
.wrn-next__picker-clear {
position: absolute;
right: 2.5rem;
@@ -116,6 +205,10 @@ size: string = "default"
box-shadow: inset 0 0 0 1px var(--wrn-field-color);
}
.wrn-next__calendar-grid button:disabled { opacity: 0.35; cursor: not-allowed; }
.wrn-next__calendar-grid button:not(:disabled):focus-visible { outline: 2px solid var(--wrn-field-color); outline-offset: 1px; }
.wrn-next__calendar-weekdays { padding-block: 0.375rem; }
/* Shared component foundation. */
.wrn-component {
@@ -222,10 +315,10 @@ size: string = "default"
}
.wrn-next--field :where(input, textarea, select) {
width: 100%;
min-height: 2.65rem;
padding: 0.6rem 0.7rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.5rem 0.75rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.6rem;
border-radius: var(--wrn-radius-sm, 6px);
outline: 0;
color: var(--wrn-color-text);
background: var(--wrn-color-bg);
@@ -239,9 +332,9 @@ size: string = "default"
}
.wrn-auth-form__submit > .wrn-btn {
width: 100%;
min-height: 3rem;
border-radius: 0.75rem;
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
min-height: var(--wrn-control-height, 2.5rem);
border-radius: var(--wrn-radius-sm, 6px);
box-shadow: none;
}
.wrn-next--field :where(input, textarea, select):focus-visible {
border-color: var(--wrn-color-primary);
@@ -283,7 +376,7 @@ size: string = "default"
.wrn-next__field-hint,
.wrn-next__field-help {
color: var(--wrn-color-muted);
font-size: 0.82em;
font-size: 0.75rem;
font-weight: 500;
}
.wrn-next__field-control {
@@ -297,10 +390,10 @@ size: string = "default"
background: var(--wrn-color-surface);
font-size: 0.8125rem;
}
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) {
padding-inline-start: 2.5rem;
}
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="end"] > :is(input, textarea, select) {
padding-inline-end: 2.5rem;
}
.wrn-next__field-icon {
@@ -408,7 +501,7 @@ size: string = "default"
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
@@ -417,7 +510,7 @@ size: string = "default"
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
display: flex;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--choice-field[data-inline="true"] {
@@ -566,7 +659,7 @@ size: string = "default"
.wrn-next__time-trigger {
display: flex;
width: 100%;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
justify-content: space-between;
gap: 0.75rem;
@@ -578,9 +671,13 @@ size: string = "default"
font: inherit;
text-align: left;
}
.wrn-next--date-picker, .wrn-next--time-picker { position: relative; }
.wrn-next__calendar,
.wrn-next__time-panel {
z-index: 20;
position: absolute;
inset-block-start: calc(100% + .4rem);
inset-inline-start: 0;
z-index: 40;
display: grid;
width: min(100%, 22rem);
gap: 0.75rem;
@@ -588,7 +685,7 @@ size: string = "default"
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-md);
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-3);
box-shadow: var(--wrn-shadow-2);
}
.wrn-next--date-picker[data-expanded="false"] .wrn-next__calendar,
.wrn-next--time-picker[data-expanded="false"] .wrn-next__time-panel {
+1
View File
@@ -53,6 +53,7 @@ component Divider {
style {
.wrn-divider {
border: 0;
--divider-tone: var(--wrn-color-border);
--divider-thickness: 1px;
display: flex;
+23 -87
View File
@@ -17,96 +17,32 @@ component DragAndDrop {
variant: string = "default"
class: string = ""
}
state orderedItems = items
state draggedIndex: number = -1
functions {
client function startDrag(index, sourceEvent) { draggedIndex = index; sourceEvent.dataTransfer.setData("text/plain", String(index)); output.dragStart({ index: index }) }
client function enter(sourceEvent) { sourceEvent.preventDefault(); output.dragEnter({ sourceEvent: sourceEvent }) }
client function leave(sourceEvent) { output.dragLeave({ sourceEvent: sourceEvent }) }
client function finish(sourceEvent) { draggedIndex = -1; output.dragEnd({ sourceEvent: sourceEvent }) }
client function move(from, to, sourceEvent, next, moved) {
if (from < 0 || to < 0 || to >= orderedItems.length || from === to) { return }
next = orderedItems.slice(); moved = next.splice(from, 1)[0]; next.splice(to, 0, moved); orderedItems = next
output.change({ value: orderedItems, sourceEvent: sourceEvent })
}
client function dropAt(index, sourceEvent) { sourceEvent.preventDefault(); move(draggedIndex, index, sourceEvent); draggedIndex = -1; output.drop({ index: index, items: orderedItems }) }
}
view {
<section class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--drag-and-drop wrn-next--variant-{variant} {class}">
{#if title}<strong>{title}</strong>{/if}
{#if description}<p>{description}</p>{/if}
{#if items}<div class="wrn-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
<slot />
</section>
<section {...attrs} class="wrn-sortable {class}">{#if title}<strong>{title}</strong>{/if}{#if description}<p>{description}</p>{/if}<ol>{#each orderedItems as item, index}<li draggable="true" @dragstart="startDrag(index, event)" @dragover="enter(event)" @dragleave="leave(event)" @dragend="finish(event)" @drop="dropAt(index, event)"><span class="icon-[lucide--grip-vertical]" aria-hidden="true"></span><span>{item.label}</span><button type="button" aria-label="Move {item.label} up" disabled="{index === 0}" @click="move(index, index - 1, event)">↑</button><button type="button" aria-label="Move {item.label} down" disabled="{index === orderedItems.length - 1}" @click="move(index, index + 1, event)">↓</button></li>{/each}</ol><slot /></section>
}
style {
.wrn-next--drag-and-drop {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
color: var(--wrn-color-text);
background: var(--wrn-color-surface);
}
.wrn-next--drag-and-drop > p { margin: 0; color: var(--wrn-color-muted); }
.wrn-next--drag-and-drop > .wrn-next__items { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.wrn-next--drag-and-drop > .wrn-next__items > span { padding: 0.35rem 0.6rem; border-radius: var(--wrn-radius-sm); background: var(--wrn-color-surface-2); }
/* Shared component foundation. */
.wrn-component {
--wrn-component-color: var(--wrn-color-primary);
--wrn-component-scale: 1;
margin: 0;
color: var(--wrn-color-text);
font-family: var(--wrn-font-sans, inherit);
}
.wrn-component--color-primary {
--wrn-component-color: var(--wrn-color-primary);
}
.wrn-component--color-secondary {
--wrn-component-color: var(--wrn-color-secondary);
}
.wrn-component--color-success {
--wrn-component-color: var(--wrn-color-success);
}
.wrn-component--color-warning {
--wrn-component-color: var(--wrn-color-warning);
}
.wrn-component--color-danger {
--wrn-component-color: var(--wrn-color-danger);
}
.wrn-component--color-info {
--wrn-component-color: var(--wrn-color-info);
}
.wrn-component--size-xs {
--wrn-component-scale: 0.78;
}
.wrn-component--size-sm {
--wrn-component-scale: 0.88;
}
.wrn-component--size-default,
.wrn-component--size-md {
--wrn-component-scale: 1;
}
.wrn-component--size-lg {
--wrn-component-scale: 1.14;
}
.wrn-component--size-xl {
--wrn-component-scale: 1.28;
}
.wrn-component:not(.wrn-btn) {
font-size: calc(1em * var(--wrn-component-scale));
}
.wrn-component :is(a, button, input, select, textarea):focus-visible {
outline-color: var(--wrn-component-color);
}
.wrn-component p {
margin: 0;
color: var(--wrn-color-muted);
line-height: 1.6;
}
.wrn-sortable { display:grid; gap:.75rem; color:var(--wrn-color-text); }
.wrn-sortable p { margin:0; color:var(--wrn-color-muted); font-size:.875rem; }
.wrn-sortable ol { display:grid; gap:.5rem; list-style:none; margin:0; padding:0; }
.wrn-sortable li { display:flex; align-items:center; gap:.6rem; padding:.65rem; background:var(--wrn-color-surface); border:1px solid var(--wrn-color-border); border-radius:var(--wrn-radius-sm); font-size:.875rem; cursor:grab; }
.wrn-sortable li > span:nth-child(2) { flex:1; }
.wrn-sortable li > span:first-child { width:1rem; height:1rem; color:var(--wrn-color-muted); }
.wrn-sortable button { border:1px solid var(--wrn-color-border); background:transparent; color:inherit; border-radius:4px; width:1.75rem; height:1.75rem; cursor:pointer; }
.wrn-sortable button:disabled { opacity:.3; }
}
}
+2 -2
View File
@@ -209,12 +209,12 @@ open: boolean = false
.wrn-drawer__trigger {
appearance: none;
min-height: 2.55rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.65rem 1rem;
color: var(--wrn-color-primary-contrast);
background: var(--drawer-accent);
border: 0;
border-radius: 0.8rem;
border-radius: var(--wrn-radius-sm);
font: inherit;
font-size: 0.85rem;
font-weight: 650;
+10 -9
View File
@@ -8,6 +8,7 @@ component Dropdown {
}
props {
value: string | number = ""
items: unknown[] = []
open: boolean = false
defaultOpen: boolean = false
@@ -215,7 +216,7 @@ items: unknown[] = []
role="menuitem"
data-dropdown-item
data-danger='{item.danger ? "true" : "false"}'
data-selected='{item.selected || item.checked ? "true" : "false"}'
data-selected='{(value !== "" ? String(item.value) === String(value) : item.selected || item.checked) ? "true" : "false"}'
aria-disabled='{item.disabled ? "true" : "false"}'
tabindex='{item.disabled ? "-1" : "0"}'
class="wrn-dropdown__item"
@@ -234,7 +235,7 @@ items: unknown[] = []
<span class="wrn-dropdown__badge">{item.badge}</span>
{:else if item.shortcut}
<kbd>{item.shortcut}</kbd>
{:else if item.checked || item.selected}
{:else if value !== "" ? String(item.value) === String(value) : item.checked || item.selected}
<span class="icon-[lucide--check] wrn-dropdown__status" aria-hidden="true"></span>
{:else if item.external}
<span class="icon-[lucide--arrow-up-right] wrn-dropdown__status" aria-hidden="true"></span>
@@ -246,7 +247,7 @@ items: unknown[] = []
role="menuitem"
data-dropdown-item
data-danger='{item.danger ? "true" : "false"}'
data-selected='{item.selected || item.checked ? "true" : "false"}'
data-selected='{(value !== "" ? String(item.value) === String(value) : item.selected || item.checked) ? "true" : "false"}'
disabled='{item.disabled}'
class="wrn-dropdown__item"
@click='chooseItem(item, itemIndex, event)'
@@ -264,7 +265,7 @@ items: unknown[] = []
<span class="wrn-dropdown__badge">{item.badge}</span>
{:else if item.shortcut}
<kbd>{item.shortcut}</kbd>
{:else if item.checked || item.selected}
{:else if value !== "" ? String(item.value) === String(value) : item.checked || item.selected}
<span class="icon-[lucide--check] wrn-dropdown__status" aria-hidden="true"></span>
{/if}
</button>
@@ -320,12 +321,12 @@ items: unknown[] = []
align-items: center;
justify-content: center;
gap: 0.55rem;
min-height: 2.55rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.65rem 0.9rem;
color: var(--wrn-color-text);
background: var(--wrn-color-surface-raised);
border: 1px solid var(--wrn-color-border);
border-radius: 0.78rem;
border-radius: var(--wrn-radius-sm);
font: inherit;
font-size: 0.84rem;
font-weight: 600;
@@ -362,13 +363,13 @@ items: unknown[] = []
}
.wrn-dropdown[data-size="sm"] .wrn-dropdown__trigger {
min-height: 2.2rem;
min-height: var(--wrn-control-height-sm, 2rem);
padding: 0.5rem 0.72rem;
font-size: 0.78rem;
}
.wrn-dropdown[data-size="lg"] .wrn-dropdown__trigger {
min-height: 2.9rem;
min-height: var(--wrn-control-height-lg, 2.75rem);
padding: 0.78rem 1.05rem;
font-size: 0.9rem;
}
@@ -397,7 +398,7 @@ items: unknown[] = []
linear-gradient(145deg, color-mix(in srgb, var(--dropdown-accent) 5%, transparent), transparent 60%),
color-mix(in srgb, var(--wrn-color-surface-raised) 97%, transparent);
border: 1px solid color-mix(in srgb, var(--dropdown-accent) 18%, var(--wrn-color-border));
border-radius: 1rem;
border-radius: var(--wrn-radius-md);
box-shadow:
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
0 24px 64px color-mix(in srgb, black 22%, transparent);
-1
View File
@@ -49,7 +49,6 @@ component FeatureCard {
data-disabled='{disabled ? "true" : "false"}'
data-stretched-link='{stretchedLink ? "true" : "false"}'
class='wrn-feature-card {class}'
role="listitem"
aria-disabled='{disabled ? "true" : "false"}'
>
{#if image}
+1 -1
View File
@@ -38,7 +38,7 @@ component FeatureGrid {
style='--wrn-feature-grid-columns: {columns}; --wrn-feature-grid-tablet-columns: {tabletColumns}; --wrn-feature-grid-mobile-columns: {mobileColumns}; --wrn-feature-grid-min-item-width: {minItemWidth || "17rem"};'
>
<div class="wrn-feature-grid__inner">
<div class="wrn-feature-grid__grid" role="list">
<div class="wrn-feature-grid__grid">
{#if items.length > 0}
{#each items as item, itemIndex}
<FeatureCard
+23 -19
View File
@@ -36,10 +36,10 @@ size: string = "default"
functions {
client function handleChange(sourceEvent) {
sourceEvent.stopPropagation()
selectedName = sourceEvent.currentTarget.files && sourceEvent.currentTarget.files.length ? sourceEvent.currentTarget.files[0].name : ""
output.input({ files: sourceEvent.currentTarget.files, name: name, sourceEvent: sourceEvent })
output.change({ files: sourceEvent.currentTarget.files, name: name, sourceEvent: sourceEvent })
if (selectedName) output.select({ files: sourceEvent.currentTarget.files, name: name, sourceEvent: sourceEvent })
selectedName = sourceEvent.currentTarget.files && sourceEvent.currentTarget.files.length ? Array.from(sourceEvent.currentTarget.files).map(function (file) { return file.name }).join(", ") : ""
output.input({ files: Array.from(sourceEvent.currentTarget.files || []), name: name, sourceEvent: sourceEvent })
output.change({ files: Array.from(sourceEvent.currentTarget.files || []), name: name, sourceEvent: sourceEvent })
if (selectedName) output.select({ files: Array.from(sourceEvent.currentTarget.files || []), name: name, sourceEvent: sourceEvent })
else output.clear({ name: name, sourceEvent: sourceEvent })
}
client function emitField(nameEvent, sourceEvent) { sourceEvent.stopPropagation(); output[nameEvent]({ name: name, sourceEvent: sourceEvent }) }
@@ -48,15 +48,19 @@ size: string = "default"
view {
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--file-input {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}">
<div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
<div class="wrn-next__file-control" data-icon-position="{iconPosition}">{#if icon}<span class="{icon}" aria-hidden="true"></span>{/if}<span>{selectedName || value || placeholder}</span><input id="{id || name}" type="file" name="{name}" accept="{accept}" multiple="{multiple}" disabled="{disabled}" required="{required}" aria-readonly="{readonly}" aria-invalid="{error ? 'true' : 'false'}" @click="preventReadonly(event)" @input="handleChange(event)" @change="handleChange(event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @invalid="emitField('invalid', event)" /></div>
<div class="wrn-next__file-control" data-icon-position="{iconPosition}">{#if icon}<span class="{icon}" aria-hidden="true"></span>{/if}<span>{selectedName || value || placeholder}</span><input id="{id || name}" type="file" name="{name}" accept="{accept}" multiple="{multiple}" disabled="{disabled}" required="{required}" aria-readonly="{readonly}" aria-invalid="{error ? 'true' : 'false'}" @click="preventReadonly(event)" @change="handleChange(event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @invalid="emitField('invalid', event)" /></div>
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}<small class="wrn-next__field-error" data-error="{name}">{error}</small>
</div>
}
style {
.wrn-next__file-control > span:last-of-type { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.wrn-next__file-control > span:first-child:not(:last-of-type) { flex:none; width:1rem; height:1rem; }
.wrn-next__file-control:focus-within { outline:2px solid var(--wrn-color-focus); outline-offset:2px; }
.wrn-next__file-control {
position: relative;
min-height: 2.75rem;
display:flex; align-items:center; gap:.6rem; min-width:0;
min-height: 2.5rem;
padding: 0.65rem 0.85rem;
border: 1px dashed var(--wrn-color-border);
border-radius: var(--wrn-radius-sm);
@@ -67,7 +71,7 @@ size: string = "default"
position: absolute;
cursor: pointer;
opacity: 0;
inset: 0;
inset: 0; width:100%; height:100%;
}
/* Shared component foundation. */
@@ -176,10 +180,10 @@ size: string = "default"
}
.wrn-next--field :where(input, textarea, select) {
width: 100%;
min-height: 2.65rem;
padding: 0.6rem 0.7rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.5rem 0.75rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.6rem;
border-radius: var(--wrn-radius-sm, 6px);
outline: 0;
color: var(--wrn-color-text);
background: var(--wrn-color-bg);
@@ -193,9 +197,9 @@ size: string = "default"
}
.wrn-auth-form__submit > .wrn-btn {
width: 100%;
min-height: 3rem;
border-radius: 0.75rem;
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
min-height: var(--wrn-control-height, 2.5rem);
border-radius: var(--wrn-radius-sm, 6px);
box-shadow: none;
}
.wrn-next--field :where(input, textarea, select):focus-visible {
border-color: var(--wrn-color-primary);
@@ -237,7 +241,7 @@ size: string = "default"
.wrn-next__field-hint,
.wrn-next__field-help {
color: var(--wrn-color-muted);
font-size: 0.82em;
font-size: 0.75rem;
font-weight: 500;
}
.wrn-next__field-control {
@@ -251,10 +255,10 @@ size: string = "default"
background: var(--wrn-color-surface);
font-size: 0.8125rem;
}
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) {
padding-inline-start: 2.5rem;
}
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="end"] > :is(input, textarea, select) {
padding-inline-end: 2.5rem;
}
.wrn-next__field-icon {
@@ -362,7 +366,7 @@ size: string = "default"
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
@@ -371,7 +375,7 @@ size: string = "default"
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
display: flex;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--choice-field[data-inline="true"] {
@@ -520,7 +524,7 @@ size: string = "default"
.wrn-next__time-trigger {
display: flex;
width: 100%;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
justify-content: space-between;
gap: 0.75rem;
+14 -13
View File
@@ -18,6 +18,7 @@ size: string = "default"
status: string = "uploading"
cancelLabel: string = "Cancel upload"
retryLabel: string = "Retry upload"
showCompleteAction: boolean = false
class: string = ""
}
state currentValue = value
@@ -34,7 +35,7 @@ size: string = "default"
<div class="wrn-next__row"><span>{#if fileName}<strong>{fileName}</strong><small>{#if uploadedSize || fileSize}{uploadedSize}{#if uploadedSize && fileSize} of {/if}{fileSize}{:else}{label}{/if}</small>{:else}<strong>{label}</strong>{/if}</span>{#if showValue}<strong>{percent()}%</strong>{/if}</div>
<progress value="{currentValue}" max="{max}" aria-label="{label}"></progress>
<div class="wrn-next__upload-status"><span class="wrn-next__upload-status-icon" aria-hidden="true"></span><span>{#if currentStatus === "uploading"}Uploading securely…{:else if currentStatus === "complete"}Upload complete{:else if currentStatus === "error"}Upload failed{:else}Upload cancelled{/if}</span></div>
<div class="wrn-next__upload-actions">{#if currentStatus === "uploading"}<button type="button" @click="cancelUpload(event)">{cancelLabel}</button><button type="button" @click="completeUpload(event)">Complete</button>{/if}{#if currentStatus === "error" || currentStatus === "cancelled"}<button type="button" @click="retryUpload(event)">{retryLabel}</button>{/if}</div>
<div class="wrn-next__upload-actions">{#if currentStatus === "uploading"}<button type="button" @click="cancelUpload(event)">{cancelLabel}</button>{#if showCompleteAction}<button type="button" @click="completeUpload(event)">Complete</button>{/if}{/if}{#if currentStatus === "error" || currentStatus === "cancelled"}<button type="button" @click="retryUpload(event)">{retryLabel}</button>{/if}</div>
</div>
}
@@ -190,10 +191,10 @@ size: string = "default"
}
.wrn-next--field :where(input, textarea, select) {
width: 100%;
min-height: 2.65rem;
padding: 0.6rem 0.7rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.5rem 0.75rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.6rem;
border-radius: var(--wrn-radius-sm, 6px);
outline: 0;
color: var(--wrn-color-text);
background: var(--wrn-color-bg);
@@ -207,9 +208,9 @@ size: string = "default"
}
.wrn-auth-form__submit > .wrn-btn {
width: 100%;
min-height: 3rem;
border-radius: 0.75rem;
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
min-height: var(--wrn-control-height, 2.5rem);
border-radius: var(--wrn-radius-sm, 6px);
box-shadow: none;
}
.wrn-next--field :where(input, textarea, select):focus-visible {
border-color: var(--wrn-color-primary);
@@ -251,7 +252,7 @@ size: string = "default"
.wrn-next__field-hint,
.wrn-next__field-help {
color: var(--wrn-color-muted);
font-size: 0.82em;
font-size: 0.75rem;
font-weight: 500;
}
.wrn-next__field-control {
@@ -265,10 +266,10 @@ size: string = "default"
background: var(--wrn-color-surface);
font-size: 0.8125rem;
}
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) {
padding-inline-start: 2.5rem;
}
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="end"] > :is(input, textarea, select) {
padding-inline-end: 2.5rem;
}
.wrn-next__field-icon {
@@ -376,7 +377,7 @@ size: string = "default"
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
@@ -385,7 +386,7 @@ size: string = "default"
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
display: flex;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--choice-field[data-inline="true"] {
@@ -534,7 +535,7 @@ size: string = "default"
.wrn-next__time-trigger {
display: flex;
width: 100%;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
justify-content: space-between;
gap: 0.75rem;
+1 -1
View File
@@ -1,4 +1,4 @@
import Badge from "./Badge.wrn"
import Badge from "./badge.wrn"
component Hero {
props {
+14 -12
View File
@@ -53,6 +53,8 @@ component InputGroup {
}
style {
.wrn-next__input-group-control > .wrn-next__field-icon { position:static; transform:none; flex:0 0 1rem; width:1rem; height:1rem; margin-inline:.75rem 0; }
.wrn-next__input-group-control > .wrn-next__field-icon[data-position="end"] { order:2; margin-inline:0 .75rem; }
.wrn-next__input-group-control {
overflow: hidden;
border: 1px solid var(--wrn-color-border);
@@ -189,10 +191,10 @@ component InputGroup {
}
.wrn-next--field :where(input, textarea, select) {
width: 100%;
min-height: 2.65rem;
padding: 0.6rem 0.7rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.5rem 0.75rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.6rem;
border-radius: var(--wrn-radius-sm, 6px);
outline: 0;
color: var(--wrn-color-text);
background: var(--wrn-color-bg);
@@ -206,9 +208,9 @@ component InputGroup {
}
.wrn-auth-form__submit > .wrn-btn {
width: 100%;
min-height: 3rem;
border-radius: 0.75rem;
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
min-height: var(--wrn-control-height, 2.5rem);
border-radius: var(--wrn-radius-sm, 6px);
box-shadow: none;
}
.wrn-next--field :where(input, textarea, select):focus-visible {
border-color: var(--wrn-color-primary);
@@ -250,7 +252,7 @@ component InputGroup {
.wrn-next__field-hint,
.wrn-next__field-help {
color: var(--wrn-color-muted);
font-size: 0.82em;
font-size: 0.75rem;
font-weight: 500;
}
.wrn-next__field-control {
@@ -264,10 +266,10 @@ component InputGroup {
background: var(--wrn-color-surface);
font-size: 0.8125rem;
}
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) {
padding-inline-start: 2.5rem;
}
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="end"] > :is(input, textarea, select) {
padding-inline-end: 2.5rem;
}
.wrn-next__field-icon {
@@ -375,7 +377,7 @@ component InputGroup {
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
@@ -384,7 +386,7 @@ component InputGroup {
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
display: flex;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--choice-field[data-inline="true"] {
@@ -533,7 +535,7 @@ component InputGroup {
.wrn-next__time-trigger {
display: flex;
width: 100%;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
justify-content: space-between;
gap: 0.75rem;
+18 -5
View File
@@ -13,16 +13,29 @@ component LegendIndicator {
variant: string = "default"
class: string = ""
}
state hiddenItems = []
functions {
client function toggleItem(item, index, sourceEvent) {
hiddenItems = hiddenItems.includes(index) ? hiddenItems.filter(function (value) { return value !== index }) : hiddenItems.concat(index)
output.toggle({ item: item, index: index, hidden: hiddenItems.includes(index), sourceEvent: sourceEvent })
output.select({ value: item.value || item.label, sourceEvent: sourceEvent })
}
}
view {
<section class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--legend-indicator wrn-next--variant-{variant} {class}">
<div class="wrn-legend {class}">
{#if title}<strong>{title}</strong>{/if}
{#if description}<p>{description}</p>{/if}
{#if items}<div class="wrn-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
<slot />
</section>
<div class="wrn-legend__items">{#each items as item, index}<button type="button" aria-pressed="{!hiddenItems.includes(index)}" @click="toggleItem(item, index, event)"><i style="background:{item.color || 'var(--wrn-color-primary)'}" aria-hidden="true"></i><span>{item.label}</span>{#if item.value}<small>{item.value}</small>{/if}</button>{/each}</div><slot />
</div>
}
style {
.wrn-legend { display:grid; gap:.65rem; }
.wrn-legend p { margin:0; color:var(--wrn-color-muted); }
.wrn-legend__items { display:flex; flex-wrap:wrap; gap:.75rem; }
.wrn-legend button { display:flex; align-items:center; gap:.4rem; border:0; background:transparent; color:var(--wrn-color-text); font:inherit; font-size:.875rem; cursor:pointer; }
.wrn-legend i { width:.65rem; height:.65rem; border-radius:50%; }
.wrn-legend button[aria-pressed="false"] { opacity:.45; text-decoration:line-through; }
.wrn-next--legend-indicator {
display: grid;
gap: 0.75rem;
+23 -86
View File
@@ -11,98 +11,35 @@ component ListGroup {
description: string = ""
items: unknown[] = []
variant: string = "default"
value: string = ""
class: string = ""
}
state selectedValue = value
functions {
client function choose(item, index) {
if (item.disabled) { return }
selectedValue = String(item.value || index)
output.select({ value: selectedValue, item: item, index: index })
output.change({ value: selectedValue, item: item, index: index })
}
}
view {
<section class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--list-group wrn-next--variant-{variant} {class}">
{#if title}<strong>{title}</strong>{/if}
{#if description}<p>{description}</p>{/if}
{#if items}<div class="wrn-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
<slot />
<section {...attrs} class="wrn-list-group {class}">
{#if title}<strong>{title}</strong>{/if}{#if description}<p>{description}</p>{/if}
<div class="wrn-list-group__items" role="group" aria-label="{title}">{#each items as item, index}<button type="button" disabled="{item.disabled}" aria-pressed="{selectedValue === String(item.value || index)}" @click="choose(item, index)">{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}<span><strong>{item.label}</strong>{#if item.description}<small>{item.description}</small>{/if}</span>{#if item.badge}<span class="wrn-list-group__badge">{item.badge}</span>{/if}</button>{/each}</div><slot />
</section>
}
style {
.wrn-next--list-group {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
color: var(--wrn-color-text);
background: var(--wrn-color-surface);
}
.wrn-next--list-group > p { margin: 0; color: var(--wrn-color-muted); }
.wrn-next--list-group > .wrn-next__items { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.wrn-next--list-group > .wrn-next__items > span { padding: 0.35rem 0.6rem; border-radius: var(--wrn-radius-sm); background: var(--wrn-color-surface-2); }
/* Shared component foundation. */
.wrn-component {
--wrn-component-color: var(--wrn-color-primary);
--wrn-component-scale: 1;
margin: 0;
color: var(--wrn-color-text);
font-family: var(--wrn-font-sans, inherit);
}
.wrn-component--color-primary {
--wrn-component-color: var(--wrn-color-primary);
}
.wrn-component--color-secondary {
--wrn-component-color: var(--wrn-color-secondary);
}
.wrn-component--color-success {
--wrn-component-color: var(--wrn-color-success);
}
.wrn-component--color-warning {
--wrn-component-color: var(--wrn-color-warning);
}
.wrn-component--color-danger {
--wrn-component-color: var(--wrn-color-danger);
}
.wrn-component--color-info {
--wrn-component-color: var(--wrn-color-info);
}
.wrn-component--size-xs {
--wrn-component-scale: 0.78;
}
.wrn-component--size-sm {
--wrn-component-scale: 0.88;
}
.wrn-component--size-default,
.wrn-component--size-md {
--wrn-component-scale: 1;
}
.wrn-component--size-lg {
--wrn-component-scale: 1.14;
}
.wrn-component--size-xl {
--wrn-component-scale: 1.28;
}
.wrn-component:not(.wrn-btn) {
font-size: calc(1em * var(--wrn-component-scale));
}
.wrn-component :is(a, button, input, select, textarea):focus-visible {
outline-color: var(--wrn-component-color);
}
.wrn-component p {
margin: 0;
color: var(--wrn-color-muted);
line-height: 1.6;
}
.wrn-list-group { display:grid; gap:.75rem; color:var(--wrn-color-text); }
.wrn-list-group > p { margin:0; font-size:.875rem; color:var(--wrn-color-muted); }
.wrn-list-group__items { border:1px solid var(--wrn-color-border); border-radius:var(--wrn-radius-md); overflow:hidden; }
.wrn-list-group button { display:flex; align-items:center; gap:.75rem; width:100%; padding:.85rem 1rem; text-align:start; border:0; color:inherit; background:var(--wrn-color-surface); font:inherit; font-size:.875rem; cursor:pointer; }
.wrn-list-group button + button { border-top:1px solid var(--wrn-color-border); }
.wrn-list-group button[aria-pressed="true"] { background:var(--wrn-color-primary-soft); color:var(--wrn-color-primary-text); }
.wrn-list-group button > [class*="icon-"] { width:1.1rem; height:1.1rem; flex:none; }
.wrn-list-group small { display:block; margin-top:.2rem; color:var(--wrn-color-muted); font-size:.8rem; }
.wrn-list-group__badge { margin-inline-start:auto; font-size:.75rem; }
.wrn-list-group button:disabled { opacity:.5; cursor:not-allowed; }
}
}
+37 -4
View File
@@ -14,9 +14,30 @@ component Map {
description: string = ""
items: unknown[] = []
variant: string = "default"
provider: string = "openstreetmap"
apiKey: string = ""
query: string = ""
latitude: number = 12.9716
longitude: number = 77.5946
zoomLevel: number = 13
mapType: string = "roadmap"
imageSrc: string = ""
imageAlt: string = "Map background"
class: string = ""
}
state imageZoom: number = 1
state selectedLabel: string = ""
functions {
shared function mapUrl() {
if (provider === "google") { return "https://www.google.com/maps/embed/v1/place?key=" + encodeURIComponent(apiKey) + "&q=" + encodeURIComponent(query || latitude + "," + longitude) + "&zoom=" + Math.max(1, Math.min(21, zoomLevel)) + "&maptype=" + (mapType === "satellite" ? "satellite" : "roadmap") }
var span = 360 / Math.pow(2, Math.max(1, Math.min(18, zoomLevel)))
return "https://www.openstreetmap.org/export/embed.html?bbox=" + (longitude - span) + "," + (latitude - span / 2) + "," + (longitude + span) + "," + (latitude + span / 2) + "&layer=mapnik&marker=" + latitude + "," + longitude
}
client function changeZoom(direction) { imageZoom = Math.max(1, Math.min(3, imageZoom + direction * .25)); output.zoom({ level: imageZoom, direction: direction > 0 ? "in" : "out" }) }
client function chooseMarker(item) { selectedLabel = item.label || item.title || "Location"; output.markerClick(item); output.select(item) }
}
view {
<section
data-ui-component="Map"
@@ -42,6 +63,10 @@ component Map {
class="wrn-map__viewport"
data-size='{size}'
>
{#if !imageSrc}
{#if provider === "google" && !apiKey}<p class="wrn-map__selection">Add your Google Maps Embed API key to display this map.</p>{:else}<iframe title="{title || 'Location map'}" src="{mapUrl()}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen style="width:100%;height:100%;min-height:320px;border:0"></iframe>{/if}
{:else}<div class="wrn-map__canvas" style="transform:scale({imageZoom})">
{#if imageSrc}<img class="wrn-map__image" src="{imageSrc}" alt="{imageAlt}" />{/if}
<div
class="wrn-map__grid"
style="background-image: linear-gradient(var(--wrn-color-border) 1px, transparent 1px), linear-gradient(90deg, var(--wrn-color-border) 1px, transparent 1px); background-size: 32px 32px;"
@@ -59,19 +84,21 @@ component Map {
aria-label='{item.label || item.title || "Map marker"}'
class="wrn-map__marker"
style='left: {item.x || (20 + index * 12)}%; top: {item.y || (30 + (index % 3) * 18)}%;'
@click='output.markerClick(item); output.select(item)'
@click='chooseMarker(item)'
>
<span class='{item.icon || "icon-[lucide--map-pin]"}' aria-hidden="true"></span>
</button>
{/each}
{/if}
</div>
{#if selectedLabel}<output class="wrn-map__selection">{selectedLabel}</output>{/if}
<div class="wrn-map__controls">
<button
type="button"
aria-label="Zoom in"
class="wrn-map__control"
@click='output.zoom({ direction: "in" })'
@click='changeZoom(1)'
>
<span class="icon-[lucide--plus] wrn-map__control-icon" aria-hidden="true"></span>
</button>
@@ -79,16 +106,22 @@ component Map {
type="button"
aria-label="Zoom out"
class="wrn-map__control"
@click='output.zoom({ direction: "out" })'
@click='changeZoom(-1)'
>
<span class="icon-[lucide--minus] wrn-map__control-icon" aria-hidden="true"></span>
</button>
</div>
</div>{/if}
</div>
</section>
}
style {
.wrn-map__canvas { position:absolute; inset:0; transition:transform 160ms ease; }
.wrn-map__image { width:100%; height:100%; object-fit:cover; }
.wrn-map__canvas:has(.wrn-map__image) .wrn-map__grid { display:none; }
.wrn-map__selection { position:absolute; inset-inline-start:1rem; bottom:1rem; padding:.5rem .75rem; border-radius:6px; background:var(--wrn-color-surface); color:var(--wrn-color-text); font-size:.875rem; }
.wrn-map__marker > span { width:1.1rem; height:1.1rem; }
.wrn-map { overflow: hidden; border: 1px solid var(--wrn-color-border); border-radius: calc(var(--wrn-radius) * 1.5); background: var(--wrn-color-surface-raised); box-shadow: var(--wrn-shadow-1); }
.wrn-map__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1.25rem; border-bottom: 1px solid var(--wrn-color-border); }
.wrn-map__title { color: var(--wrn-color-text); font-size: 1.125rem; font-weight: 700; }
+2 -2
View File
@@ -193,12 +193,12 @@ component MegaMenu {
class="wrn-mega__trigger"
aria-haspopup="true"
aria-expanded='{visible}'
aria-label='{label}'
aria-label='{label || "Menu"}'
@click='activatePanel(event)'
@mouseenter='hoverPanel(event)'
>
<span class='wrn-mega__trigger-icon {icon}' data-show="icon" aria-hidden="true"></span>
<span class="wrn-mega__trigger-label" data-label='{label}' aria-hidden="true"></span>
<span class="wrn-mega__trigger-label" data-label='{label || "Menu"}' aria-hidden="true"></span>
<span class="wrn-mega__arrow" aria-hidden="true">&#8250;</span>
</button>
+14 -22
View File
@@ -31,7 +31,7 @@ component MetricCard {
size: string = "default"
color: string = "primary"
variant: string = "default"
hover: string = "lift"
hover: string = "none"
align: string = "left"
class: string = ""
}
@@ -211,7 +211,7 @@ component MetricCard {
--metric-accent-soft: var(--wrn-color-primary-soft);
--metric-accent-muted: var(--wrn-color-primary-muted);
--metric-contrast: var(--wrn-color-primary-contrast);
--metric-border: color-mix(in srgb, var(--metric-accent) 16%, var(--wrn-color-border));
--metric-border: var(--wrn-color-border);
position: relative;
isolation: isolate;
@@ -220,20 +220,12 @@ component MetricCard {
width: 100%;
min-width: 0;
height: 100%;
padding: 1.5rem;
padding: var(--wrn-space-panel, 1.25rem);
color: var(--wrn-color-text);
background:
radial-gradient(
circle at 100% 0%,
color-mix(in srgb, var(--metric-accent) 8%, transparent),
transparent 36%
),
var(--wrn-color-surface-raised);
background: var(--wrn-color-surface);
border: 1px solid var(--metric-border);
border-radius: 1.25rem;
box-shadow:
0 1px 0 color-mix(in srgb, white 4%, transparent) inset,
0 14px 36px color-mix(in srgb, black 10%, transparent);
border-radius: var(--wrn-radius-md);
box-shadow: none;
overflow: hidden;
transition:
transform 180ms ease,
@@ -288,8 +280,8 @@ component MetricCard {
}
.wrn-metric-card[data-size="lg"] {
padding: 1.9rem;
border-radius: 1.5rem;
padding: 1.5rem;
border-radius: var(--wrn-radius-lg);
}
.wrn-metric-card[data-variant="soft"] {
@@ -464,7 +456,7 @@ component MetricCard {
align-items: baseline;
gap: 0.2rem;
width: 100%;
margin-top: 1.15rem;
margin-top: 0.75rem;
}
.wrn-metric-card[data-align="center"] .wrn-metric-card__value-row {
@@ -477,7 +469,7 @@ component MetricCard {
.wrn-metric-card__value {
color: var(--wrn-color-text);
font-size: clamp(2rem, 5vw, 2.8rem);
font-size: clamp(1.75rem, 3vw, 2rem);
font-weight: 700;
line-height: 0.98;
letter-spacing: -0.045em;
@@ -486,11 +478,11 @@ component MetricCard {
}
.wrn-metric-card[data-size="sm"] .wrn-metric-card__value {
font-size: clamp(1.65rem, 4vw, 2.15rem);
font-size: 1.5rem;
}
.wrn-metric-card[data-size="lg"] .wrn-metric-card__value {
font-size: clamp(2.45rem, 6vw, 3.5rem);
font-size: clamp(2rem, 4vw, 2.5rem);
}
.wrn-metric-card[data-variant="solid"] .wrn-metric-card__value {
@@ -513,8 +505,8 @@ component MetricCard {
max-width: 38rem;
margin: 0.8rem 0 0;
color: var(--wrn-color-text-muted);
font-size: 0.86rem;
line-height: 1.7;
font-size: 0.8125rem;
line-height: 1.5;
}
.wrn-metric-card__progress-block {
+18 -24
View File
@@ -18,6 +18,7 @@ component Modal {
placement: string = "center"
color: string = "primary"
variant: string = "default"
showHeader: boolean = true
showClose: boolean = true
closeLabel: string = "Close modal"
closeOnBackdrop: boolean = true
@@ -98,7 +99,7 @@ component Modal {
<div
{...attrs}
data-ui-component="Modal"
data-open='{isOpen() ? "true" : "false"}'
data-open='{(open || visible) ? "true" : "false"}'
data-size='{size}'
data-placement='{placement}'
data-color='{color}'
@@ -107,6 +108,7 @@ component Modal {
data-scroll='{scrollBehavior}'
data-destructive='{destructive ? "true" : "false"}'
class='wrn-modal {class}'
@click="if (event.target.closest('[data-modal-close]')) { hideModal('custom-button', event) }"
@wrnexus:modal:close='hideModal("api", event)'
>
{#if triggerLabel}
@@ -114,7 +116,7 @@ component Modal {
type="button"
class="wrn-modal__trigger"
aria-haspopup="dialog"
aria-expanded='{isOpen() ? "true" : "false"}'
aria-expanded='{(open || visible) ? "true" : "false"}'
@click='showModal(event)'
>
{#if triggerIcon}
@@ -140,7 +142,7 @@ component Modal {
<div
class="wrn-modal__layer"
data-show="isOpen()"
data-show="(open || visible)"
role="presentation"
@keydown='handleKeydown(event)'
>
@@ -159,7 +161,7 @@ component Modal {
aria-label='{label || title}'
tabindex="-1"
>
<header
{#if showHeader}<header
class="wrn-modal__header"
>
<div
@@ -212,7 +214,7 @@ component Modal {
</svg>
</button>
{/if}
</header>
</header>{/if}
<div
class="wrn-modal__body"
@@ -343,12 +345,12 @@ component Modal {
.wrn-modal__trigger {
appearance: none;
min-height: 2.55rem;
min-height: var(--wrn-control-height);
padding: 0.65rem 1rem;
color: var(--modal-contrast);
background: var(--modal-accent);
border: 0;
border-radius: 0.8rem;
border-radius: var(--wrn-radius-sm);
font: inherit;
font-size: 0.85rem;
font-weight: 650;
@@ -431,18 +433,10 @@ component Modal {
width: min(32rem, calc(100vw - 2rem));
max-height: min(88vh, 52rem);
color: var(--wrn-color-text);
background:
radial-gradient(
circle at 100% 0%,
color-mix(in srgb, var(--modal-accent) 9%, transparent),
transparent 34%
),
var(--wrn-color-surface-raised);
border: 1px solid color-mix(in srgb, var(--modal-accent) 18%, var(--wrn-color-border));
border-radius: 1.35rem;
box-shadow:
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
0 40px 110px color-mix(in srgb, black 38%, transparent);
background: var(--wrn-color-surface-raised);
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-lg);
box-shadow: var(--wrn-shadow-lg);
overflow: hidden;
opacity: 0;
transform: scale(0.96) translateY(10px);
@@ -502,7 +496,7 @@ component Modal {
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.4rem 1.4rem 1.15rem;
padding: 1.25rem;
border-bottom: 1px solid var(--wrn-color-border);
}
@@ -631,7 +625,7 @@ component Modal {
.wrn-modal__body {
flex: 1 1 auto;
min-height: 0;
padding: 1.4rem;
padding: 1.25rem;
color: var(--wrn-color-text);
}
@@ -659,7 +653,7 @@ component Modal {
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.4rem 1.4rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--wrn-color-border);
}
@@ -681,9 +675,9 @@ component Modal {
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.55rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.65rem 1rem;
border-radius: 0.78rem;
border-radius: var(--wrn-radius-sm);
font: inherit;
font-size: 0.83rem;
font-weight: 650;
+1 -1
View File
@@ -639,7 +639,7 @@ size: string = "default"
padding: 0.65rem;
align-items: flex-start;
gap: 0.65rem;
border-radius: 0.6rem;
border-radius: var(--wrn-radius-sm, 6px);
font-size: 0.8rem;
font-weight: 450;
}
+15 -14
View File
@@ -387,6 +387,7 @@ current: true
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.35), transparent 78%);
}
.wrn-page-header[data-variant="default"] .wrn-page-header__decoration,
.wrn-page-header[data-variant="minimal"] .wrn-page-header__decoration,
.wrn-page-header[data-compact="true"] .wrn-page-header__grid-pattern {
display: none;
@@ -404,7 +405,7 @@ current: true
width: 100%;
max-width: 80rem;
margin-inline: auto;
padding: 1.25rem 1rem 2.75rem;
padding: 1.25rem 1rem;
}
.wrn-page-header[data-max-width="compact"] .wrn-page-header__inner,
@@ -423,11 +424,11 @@ current: true
.wrn-page-header[data-compact="true"] .wrn-page-header__inner {
padding-top: 1rem;
padding-bottom: 1.75rem;
padding-bottom: 1rem;
}
.wrn-page-header[data-size="lg"] .wrn-page-header__inner {
padding-bottom: 3.5rem;
padding-bottom: 2rem;
}
.wrn-page-header__breadcrumb {
@@ -518,9 +519,9 @@ current: true
margin: 0;
color: var(--page-header-accent-text);
font-size: 0.72rem;
font-weight: 800;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.16em;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -538,27 +539,27 @@ current: true
max-width: 54rem;
margin: 0;
color: var(--wrn-color-text);
font-size: clamp(2rem, 4vw, 3.75rem);
font-weight: 800;
line-height: 1.08;
font-size: clamp(1.5rem, 2.2vw, 1.75rem);
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.035em;
text-wrap: balance;
}
.wrn-page-header[data-compact="true"] .wrn-page-header__title {
font-size: clamp(1.75rem, 3vw, 2.65rem);
font-size: 1.375rem;
}
.wrn-page-header[data-size="lg"] .wrn-page-header__title {
font-size: clamp(2.35rem, 5vw, 4.5rem);
font-size: clamp(1.75rem, 3vw, 2.25rem);
}
.wrn-page-header__description {
max-width: 48rem;
margin: 0.9rem 0 0;
margin: 0.5rem 0 0;
color: var(--wrn-color-text-muted);
font-size: 1rem;
line-height: 1.75;
font-size: 0.875rem;
line-height: 1.6;
text-wrap: pretty;
}
@@ -663,7 +664,7 @@ current: true
.wrn-page-header__layout {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 2.5rem;
gap: 1.5rem;
}
.wrn-page-header[data-align="center"] .wrn-page-header__layout,
+3
View File
@@ -262,6 +262,9 @@ component Pagination {
.wrn-pagination__pages {
display: flex;
flex-wrap: wrap;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 0.25rem;
margin: 0;
+12 -9
View File
@@ -165,6 +165,9 @@ component PinInput {
.wrn-next__pin-cells {
display: flex;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
align-items: center;
gap: clamp(0.35rem, 1.5vw, 0.65rem);
@@ -174,18 +177,18 @@ component PinInput {
}
.wrn-next--pin-input .wrn-next__pin-cells input {
flex: 0 0 clamp(2.4rem, 8vw, 3.25rem);
width: clamp(2.4rem, 8vw, 3.25rem);
height: clamp(2.75rem, 9vw, 3.5rem);
min-width: clamp(2.4rem, 8vw, 3.25rem);
flex: 0 1 2.5rem;
width: 2.5rem;
height: var(--wrn-control-height, 2.5rem);
min-width: 0;
padding: 0;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-sm);
color: var(--wrn-color-text);
background: var(--wrn-color-bg);
font: inherit;
font-size: clamp(1rem, 3vw, 1.25rem);
font-weight: 750;
font-size: 1rem;
font-weight: 600;
text-align: center;
caret-color: var(--wrn-component-color);
transition:
@@ -229,9 +232,9 @@ component PinInput {
}
.wrn-next--pin-input .wrn-next__pin-cells input {
flex-basis: min(2.7rem, 12vw);
width: min(2.7rem, 12vw);
min-width: min(2.7rem, 12vw);
height: min(3rem, 14vw);
width: min(2.5rem, 10vw);
min-width: 0;
height: var(--wrn-control-height, 2.5rem);
}
}
+5 -5
View File
@@ -248,12 +248,12 @@ component Popover {
align-items: center;
justify-content: center;
gap: 0.55rem;
min-height: 2.55rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.65rem 0.9rem;
color: var(--wrn-color-text);
background: var(--wrn-color-surface-raised);
border: 1px solid var(--wrn-color-border);
border-radius: 0.78rem;
border-radius: var(--wrn-radius-sm);
font: inherit;
font-size: 0.84rem;
font-weight: 600;
@@ -276,13 +276,13 @@ component Popover {
}
.wrn-popover[data-size="sm"] .wrn-popover__trigger {
min-height: 2.2rem;
min-height: var(--wrn-control-height-sm, 2rem);
padding: 0.5rem 0.72rem;
font-size: 0.78rem;
}
.wrn-popover[data-size="lg"] .wrn-popover__trigger {
min-height: 2.9rem;
min-height: var(--wrn-control-height-lg, 2.75rem);
padding: 0.78rem 1.05rem;
font-size: 0.9rem;
}
@@ -309,7 +309,7 @@ component Popover {
linear-gradient(145deg, color-mix(in srgb, var(--popover-accent) 5%, transparent), transparent 62%),
color-mix(in srgb, var(--wrn-color-surface-raised) 97%, transparent);
border: 1px solid color-mix(in srgb, var(--popover-accent) 18%, var(--wrn-color-border));
border-radius: 1rem;
border-radius: var(--wrn-radius-md);
box-shadow:
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
0 24px 64px color-mix(in srgb, black 22%, transparent);
+10 -15
View File
@@ -121,20 +121,15 @@ component PortalDashboard {
.wrn-portal-dashboard__hero {
display: flex;
padding: clamp(1.25rem, 3vw, 2rem);
padding: 0 0 1.25rem;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--wrn-color-border);
border-radius: 1rem;
background:
radial-gradient(
circle at 85% 0%,
color-mix(in srgb, var(--wrn-color-primary) 20%, transparent),
transparent 34%
),
var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-sm);
border: 0;
border-bottom: 1px solid var(--wrn-color-border);
border-radius: 0;
background: transparent;
box-shadow: none;
}
.wrn-portal-dashboard__eyebrow,
@@ -148,7 +143,7 @@ component PortalDashboard {
.wrn-portal-dashboard__hero h1 {
margin: 0.35rem 0 0;
font-size: clamp(1.4rem, 3vw, 2.25rem);
font-size: clamp(1.375rem, 2vw, 1.75rem);
font-weight: 650;
letter-spacing: -0.035em;
}
@@ -177,7 +172,7 @@ component PortalDashboard {
border: 1px solid var(--wrn-color-border);
border-radius: 0.85rem;
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-sm);
box-shadow: none;
}
.wrn-portal-dashboard__metric-icon {
@@ -221,9 +216,9 @@ component PortalDashboard {
min-width: 0;
padding: 1.1rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.9rem;
border-radius: var(--wrn-radius-md);
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-sm);
box-shadow: none;
}
.wrn-portal-dashboard__panel--actions {
@@ -113,7 +113,7 @@ size: string = "default"
justify-content: center;
gap: 0.4rem;
border: 1px solid transparent;
border-radius: 0.6rem;
border-radius: var(--wrn-radius-sm, 6px);
color: var(--wrn-color-muted);
cursor: pointer;
list-style: none;
+56 -21
View File
@@ -36,20 +36,52 @@ component RangeSlider {
}
state currentValue = value
functions {
shared function snapValue(raw) {
var increment = Math.max(0.0001, Number(step))
var snapped = Number(min) + Math.round((Number(raw) - Number(min)) / increment) * increment
var bounded = Math.min(Number(max), Math.max(Number(min), snapped))
return Math.round(bounded * 1000000) / 1000000
}
shared function percent() { if (Number(max) === Number(min)) { return 0 } return (Number(currentValue) - Number(min)) / (Number(max) - Number(min)) * 100 }
client function setValue(nextValue, sourceEvent, commit, detail, root, slider, input, outputElement, progressValue) { if (readonly || disabled) { return } currentValue = Number(nextValue); root = sourceEvent.currentTarget.closest(".wrn-next--range-slider"); if (root) { slider = root.querySelector("[role=slider]"); input = root.querySelector("input[name='" + name + "']"); outputElement = root.querySelector("output"); progressValue = (currentValue - Number(min)) / (Number(max) - Number(min)) * 100; if (slider) { slider.setAttribute("aria-valuenow", String(currentValue)); slider.style.setProperty("--wrn-range-progress", progressValue + "%") } if (input) { input.value = String(currentValue) } if (outputElement) { outputElement.textContent = String(currentValue) } } detail = { value: currentValue, min: min, max: max, step: step, name: name, sourceEvent: sourceEvent }; output.input(detail); if (commit) { output.change(detail) } }
client function setValue(nextValue, sourceEvent, commit, detail, root, slider, input, outputElement, progressValue) { if (sourceEvent.currentTarget.closest(".wrn-next--range-slider").dataset.readonly === "true" || sourceEvent.currentTarget.closest(".wrn-next--range-slider").dataset.disabled === "true") { return } currentValue = Number(nextValue); root = sourceEvent.currentTarget.closest(".wrn-next--range-slider"); if (root) { slider = root.querySelector("[role=slider]"); input = root.querySelector("input[name='" + name + "']"); outputElement = root.querySelector("output"); progressValue = (currentValue - Number(min)) / (Number(max) - Number(min)) * 100; if (slider) { slider.setAttribute("aria-valuenow", String(currentValue)); slider.style.setProperty("--wrn-range-progress", progressValue + "%") } if (input) { input.value = String(currentValue) } if (outputElement) { outputElement.textContent = String(currentValue) } } detail = { value: currentValue, min: min, max: max, step: step, name: name, sourceEvent: sourceEvent }; output.input(detail); if (commit) { output.change(detail) } }
client function valueFromPointer(sourceEvent, rect, ratio) { rect = sourceEvent.currentTarget.getBoundingClientRect(); ratio = Math.max(0, Math.min(1, (sourceEvent.clientX - rect.left) / rect.width)); return Number(min) + ratio * (Number(max) - Number(min)) }
client function beginSlide(sourceEvent, rect, ratio, rawValue) { if (readonly || disabled) { return } sourceEvent.currentTarget.setPointerCapture(sourceEvent.pointerId); rect = sourceEvent.currentTarget.getBoundingClientRect(); ratio = Math.max(0, Math.min(1, (sourceEvent.clientX - rect.left) / rect.width)); rawValue = Number(min) + ratio * (Number(max) - Number(min)); currentValue = Number(min) + Math.round((rawValue - Number(min)) / Number(step)) * Number(step); output.input({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function moveSlide(sourceEvent, rect, ratio, rawValue) { if (!sourceEvent.currentTarget.hasPointerCapture(sourceEvent.pointerId)) { return } rect = sourceEvent.currentTarget.getBoundingClientRect(); ratio = Math.max(0, Math.min(1, (sourceEvent.clientX - rect.left) / rect.width)); rawValue = Number(min) + ratio * (Number(max) - Number(min)); currentValue = Number(min) + Math.round((rawValue - Number(min)) / Number(step)) * Number(step); output.input({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function endSlide(sourceEvent, rect, ratio, rawValue) { rect = sourceEvent.currentTarget.getBoundingClientRect(); ratio = Math.max(0, Math.min(1, (sourceEvent.clientX - rect.left) / rect.width)); rawValue = Number(min) + ratio * (Number(max) - Number(min)); currentValue = Number(min) + Math.round((rawValue - Number(min)) / Number(step)) * Number(step); if (sourceEvent.currentTarget.hasPointerCapture(sourceEvent.pointerId)) { sourceEvent.currentTarget.releasePointerCapture(sourceEvent.pointerId) } output.change({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function selectMark(sourceEvent, root, slider, input, outputElement, nextValue, progressValue, detail) { if (readonly || disabled) { return } root = sourceEvent.currentTarget.closest(".wrn-next--range-slider"); slider = root.querySelector(".wrn-next__custom-slider"); input = root.querySelector(".wrn-next__range-value"); outputElement = root.querySelector("output"); nextValue = Number(sourceEvent.currentTarget.dataset.value); currentValue = nextValue; progressValue = (nextValue - Number(min)) / (Number(max) - Number(min)) * 100; slider.setAttribute("aria-valuenow", String(nextValue)); slider.style.setProperty("--wrn-range-progress", progressValue + "%"); input.setAttribute("value", String(nextValue)); outputElement.replaceChildren(String(nextValue)); detail = { value: nextValue, min: min, max: max, step: step, name: name, sourceEvent: sourceEvent }; output.input(detail); output.change(detail) }
client function handleKey(sourceEvent) { if (sourceEvent.key === "ArrowRight") { sourceEvent.preventDefault(); currentValue = Math.min(Number(max), Number(currentValue) + Number(step)); output.input({ value: currentValue, name: name, sourceEvent: sourceEvent }); output.change({ value: currentValue, name: name, sourceEvent: sourceEvent }) } if (sourceEvent.key === "ArrowLeft") { sourceEvent.preventDefault(); currentValue = Math.max(Number(min), Number(currentValue) - Number(step)); output.input({ value: currentValue, name: name, sourceEvent: sourceEvent }); output.change({ value: currentValue, name: name, sourceEvent: sourceEvent }) } if (sourceEvent.key === "Home") { sourceEvent.preventDefault(); currentValue = Number(min); output.change({ value: currentValue, name: name, sourceEvent: sourceEvent }) } if (sourceEvent.key === "End") { sourceEvent.preventDefault(); currentValue = Number(max); output.change({ value: currentValue, name: name, sourceEvent: sourceEvent }) } }
client function beginSlide(sourceEvent, rect, ratio, rawValue) { if (sourceEvent.currentTarget.closest(".wrn-next--range-slider").dataset.readonly === "true" || sourceEvent.currentTarget.closest(".wrn-next--range-slider").dataset.disabled === "true") { return } sourceEvent.currentTarget.setPointerCapture(sourceEvent.pointerId); rect = sourceEvent.currentTarget.getBoundingClientRect(); ratio = Math.max(0, Math.min(1, (sourceEvent.clientX - rect.left) / rect.width)); rawValue = Number(min) + ratio * (Number(max) - Number(min)); currentValue = snapValue(rawValue); output.input({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function moveSlide(sourceEvent, rect, ratio, rawValue) { if (!sourceEvent.currentTarget.hasPointerCapture(sourceEvent.pointerId)) { return } rect = sourceEvent.currentTarget.getBoundingClientRect(); ratio = Math.max(0, Math.min(1, (sourceEvent.clientX - rect.left) / rect.width)); rawValue = Number(min) + ratio * (Number(max) - Number(min)); currentValue = snapValue(rawValue); output.input({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function endSlide(sourceEvent, rect, ratio, rawValue) { rect = sourceEvent.currentTarget.getBoundingClientRect(); ratio = Math.max(0, Math.min(1, (sourceEvent.clientX - rect.left) / rect.width)); rawValue = Number(min) + ratio * (Number(max) - Number(min)); currentValue = snapValue(rawValue); if (sourceEvent.currentTarget.hasPointerCapture(sourceEvent.pointerId)) { sourceEvent.currentTarget.releasePointerCapture(sourceEvent.pointerId) } output.change({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function selectMark(sourceEvent, root, slider, input, outputElement, nextValue, progressValue, detail) { if (sourceEvent.currentTarget.closest(".wrn-next--range-slider").dataset.readonly === "true" || sourceEvent.currentTarget.closest(".wrn-next--range-slider").dataset.disabled === "true") { return } root = sourceEvent.currentTarget.closest(".wrn-next--range-slider"); slider = root.querySelector(".wrn-next__custom-slider"); input = root.querySelector(".wrn-next__range-value"); outputElement = root.querySelector("output"); nextValue = Number(sourceEvent.currentTarget.dataset.value); currentValue = nextValue; progressValue = (nextValue - Number(min)) / (Number(max) - Number(min)) * 100; slider.setAttribute("aria-valuenow", String(nextValue)); slider.style.setProperty("--wrn-range-progress", progressValue + "%"); input.setAttribute("value", String(nextValue)); outputElement.replaceChildren(String(nextValue)); detail = { value: nextValue, min: min, max: max, step: step, name: name, sourceEvent: sourceEvent }; output.input(detail); output.change(detail) }
client function handleKey(sourceEvent) {
if (readonly || disabled) { return }
var next = Number(currentValue)
if (sourceEvent.key === "ArrowRight" || sourceEvent.key === "ArrowUp") { next = next + Number(step) }
else if (sourceEvent.key === "ArrowLeft" || sourceEvent.key === "ArrowDown") { next = next - Number(step) }
else if (sourceEvent.key === "Home") { next = Number(min) }
else if (sourceEvent.key === "End") { next = Number(max) }
else { return }
sourceEvent.preventDefault()
currentValue = snapValue(next)
output.input({ value: currentValue, name: name, sourceEvent: sourceEvent })
output.change({ value: currentValue, name: name, sourceEvent: sourceEvent })
}
client function emitFocus(nameEvent, sourceEvent) { output[nameEvent]({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function decreaseValue(sourceEvent) { currentValue = Math.max(Number(min), Number(currentValue) - Number(step)); output.input({ value: currentValue, name: name, sourceEvent: sourceEvent }); output.change({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function increaseValue(sourceEvent) { currentValue = Math.min(Number(max), Number(currentValue) + Number(step)); output.input({ value: currentValue, name: name, sourceEvent: sourceEvent }); output.change({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
client function decreaseValue(sourceEvent) {
if (readonly || disabled) { return }
var next = Math.max(Number(min), Number(currentValue) - Number(step))
currentValue = Math.round(next * 1000000) / 1000000
output.input({ value: currentValue, name: name, sourceEvent: sourceEvent })
output.change({ value: currentValue, name: name, sourceEvent: sourceEvent })
}
client function increaseValue(sourceEvent) {
if (readonly || disabled) { return }
var next = Math.min(Number(max), Number(currentValue) + Number(step))
currentValue = Math.round(next * 1000000) / 1000000
output.input({ value: currentValue, name: name, sourceEvent: sourceEvent })
output.change({ value: currentValue, name: name, sourceEvent: sourceEvent })
}
}
view {
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--range-slider {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}">
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--range-slider {class}" data-readonly="{readonly}" data-disabled="{disabled}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}">
<div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
<div class="wrn-next__range-control" data-variant="{variant}" data-icon-position="{iconPosition}" data-show-steps="{showSteps}">
{#if icon}<span class="{icon}" aria-hidden="true"></span>{/if}
@@ -70,7 +102,10 @@ component RangeSlider {
}
style {
.wrn-next__range-control { align-items:center; padding-bottom:1.5rem; }
.wrn-next__range-bounds { position:absolute; top:calc(100% + .25rem); left:0; right:0; }
.wrn-next__range-track {
position:relative;
display: grid;
flex: 1;
min-width: 0;
@@ -311,10 +346,10 @@ component RangeSlider {
}
.wrn-next--field :where(input, textarea, select) {
width: 100%;
min-height: 2.65rem;
padding: 0.6rem 0.7rem;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0.5rem 0.75rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.6rem;
border-radius: var(--wrn-radius-sm, 6px);
outline: 0;
color: var(--wrn-color-text);
background: var(--wrn-color-bg);
@@ -328,9 +363,9 @@ component RangeSlider {
}
.wrn-auth-form__submit > .wrn-btn {
width: 100%;
min-height: 3rem;
border-radius: 0.75rem;
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
min-height: var(--wrn-control-height, 2.5rem);
border-radius: var(--wrn-radius-sm, 6px);
box-shadow: none;
}
.wrn-next--field :where(input, textarea, select):focus-visible {
border-color: var(--wrn-color-primary);
@@ -372,7 +407,7 @@ component RangeSlider {
.wrn-next__field-hint,
.wrn-next__field-help {
color: var(--wrn-color-muted);
font-size: 0.82em;
font-size: 0.75rem;
font-weight: 500;
}
.wrn-next__field-control {
@@ -386,10 +421,10 @@ component RangeSlider {
background: var(--wrn-color-surface);
font-size: 0.8125rem;
}
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) {
padding-inline-start: 2.5rem;
}
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="end"] > :is(input, textarea, select) {
padding-inline-end: 2.5rem;
}
.wrn-next__field-icon {
@@ -497,7 +532,7 @@ component RangeSlider {
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
@@ -506,7 +541,7 @@ component RangeSlider {
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
display: flex;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
}
.wrn-next--choice-field[data-inline="true"] {
@@ -655,7 +690,7 @@ component RangeSlider {
.wrn-next__time-trigger {
display: flex;
width: 100%;
min-height: 2.65rem;
min-height: var(--wrn-control-height, 2.5rem);
align-items: center;
justify-content: space-between;
gap: 0.75rem;
+37 -85
View File
@@ -11,98 +11,50 @@ component Rating {
description: string = ""
items: unknown[] = []
variant: string = "default"
value: number = 0
max: number = 5
step: number = 1
readonly: boolean = false
disabled: boolean = false
class: string = ""
}
state selectedValue = value
functions {
shared function stars() { return Array.from({ length: Math.max(1, Math.min(10, max)) }) }
client function choose(index, sourceEvent) {
if (readonly || disabled) { return }
var rect = sourceEvent.currentTarget.getBoundingClientRect()
var fraction = sourceEvent.detail > 0 && rect.width ? Math.max(0, Math.min(1, (sourceEvent.clientX - rect.left) / rect.width)) : 1
var increment = Math.max(0.01, Number(step))
var next = Math.ceil((index + fraction) / increment) * increment
selectedValue = Number(Math.min(max, Math.max(0, next)).toFixed(2))
output.input({ value: selectedValue, sourceEvent: sourceEvent })
output.change({ value: selectedValue, sourceEvent: sourceEvent })
}
}
view {
<section class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--rating wrn-next--variant-{variant} {class}">
<div {...attrs} class="wrn-rating {class}">
{#if title}<strong>{title}</strong>{/if}
{#if description}<p>{description}</p>{/if}
{#if items}<div class="wrn-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
<slot />
</section>
<div role="group" aria-label="{title || 'Rating'}" class="wrn-rating__stars">
{#each stars() as star, index}<button type="button" aria-label="Rate {index + 1} of {max}" aria-pressed="{selectedValue === index + 1}" disabled="{disabled || readonly}" data-filled="{index < selectedValue}" style="--fill:{Math.max(0, Math.min(1, selectedValue - index)) * 100}%" @click="choose(index, event)"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9Z" /></svg><svg class="wrn-rating__fill" viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9Z" /></svg></button>{/each}
<input class="wrn-rating__precise" type="range" aria-label="Precise rating" min="0" max="{max}" step="{step}" value="{selectedValue}" disabled="{disabled || readonly}" @input="selectedValue = Number(event.currentTarget.value); output.input({ value: selectedValue })" @change="output.change({ value: selectedValue })" /><output aria-live="polite">{selectedValue} / {max}</output>
</div><slot />
</div>
}
style {
.wrn-next--rating {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
color: var(--wrn-color-text);
background: var(--wrn-color-surface);
}
.wrn-next--rating > p { margin: 0; color: var(--wrn-color-muted); }
.wrn-next--rating > .wrn-next__items { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.wrn-next--rating > .wrn-next__items > span { padding: 0.35rem 0.6rem; border-radius: var(--wrn-radius-sm); background: var(--wrn-color-surface-2); }
/* Shared component foundation. */
.wrn-component {
--wrn-component-color: var(--wrn-color-primary);
--wrn-component-scale: 1;
margin: 0;
color: var(--wrn-color-text);
font-family: var(--wrn-font-sans, inherit);
}
.wrn-component--color-primary {
--wrn-component-color: var(--wrn-color-primary);
}
.wrn-component--color-secondary {
--wrn-component-color: var(--wrn-color-secondary);
}
.wrn-component--color-success {
--wrn-component-color: var(--wrn-color-success);
}
.wrn-component--color-warning {
--wrn-component-color: var(--wrn-color-warning);
}
.wrn-component--color-danger {
--wrn-component-color: var(--wrn-color-danger);
}
.wrn-component--color-info {
--wrn-component-color: var(--wrn-color-info);
}
.wrn-component--size-xs {
--wrn-component-scale: 0.78;
}
.wrn-component--size-sm {
--wrn-component-scale: 0.88;
}
.wrn-component--size-default,
.wrn-component--size-md {
--wrn-component-scale: 1;
}
.wrn-component--size-lg {
--wrn-component-scale: 1.14;
}
.wrn-component--size-xl {
--wrn-component-scale: 1.28;
}
.wrn-component:not(.wrn-btn) {
font-size: calc(1em * var(--wrn-component-scale));
}
.wrn-component :is(a, button, input, select, textarea):focus-visible {
outline-color: var(--wrn-component-color);
}
.wrn-component p {
margin: 0;
color: var(--wrn-color-muted);
line-height: 1.6;
}
.wrn-rating__precise { position:absolute; opacity:0; width:1px; height:1px; }
.wrn-rating__precise:focus-visible { position:static; opacity:1; width:5rem; height:auto; accent-color:var(--wrn-color-primary); }
.wrn-rating { display:grid; gap:.6rem; color:var(--wrn-color-text); }
.wrn-rating p { margin:0; color:var(--wrn-color-muted); font-size:.875rem; }
.wrn-rating__stars { display:flex; align-items:center; gap:.25rem; }
.wrn-rating button { display:grid; place-items:center; width:2rem; height:2rem; padding:.2rem; border:0; background:transparent; color:var(--wrn-color-muted); cursor:pointer; }
.wrn-rating svg { width:1.5rem; height:1.5rem; fill:none; stroke:currentColor; stroke-width:1.5; }
.wrn-rating button[data-filled="true"] { color:var(--wrn-color-warning-text); }
.wrn-rating svg { grid-area:1 / 1; }
.wrn-rating .wrn-rating__fill { fill:currentColor; clip-path:inset(0 calc(100% - var(--fill)) 0 0); }
.wrn-rating output { margin-inline-start:.5rem; font-size:.8rem; color:var(--wrn-color-muted); }
.wrn-rating button:focus-visible { outline:2px solid var(--wrn-color-focus); outline-offset:2px; border-radius:4px; }
}
}
+1
View File
@@ -82,6 +82,7 @@ component SearchBox {
}
style {
input[type="search"]::-webkit-search-cancel-button { -webkit-appearance:none; appearance:none; }
.wrn-search-box { width: 100%; }
.wrn-search-box__label {
display: block;
+9 -9
View File
@@ -50,7 +50,7 @@ component SectionHeader {
style {
.wrn-section-header {
--section-header-accent: var(--wrn-color-primary);
--section-header-title: 1.875rem;
--section-header-title: 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
@@ -131,7 +131,7 @@ component SectionHeader {
color: var(--section-header-accent);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.18em;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -149,9 +149,9 @@ component SectionHeader {
}
.wrn-section-header__description {
margin: 1rem 0 0;
margin: 0.5rem 0 0;
color: var(--wrn-color-text-muted);
font-size: 1rem;
font-size: 0.875rem;
line-height: 1.7;
}
@@ -185,24 +185,24 @@ component SectionHeader {
* split only becomes a row once there is width for two columns.
*/
.wrn-section-header[data-size="sm"] {
--section-header-title: 1.5rem;
--section-header-title: 1.125rem;
}
.wrn-section-header[data-size="lg"] {
--section-header-title: 2.25rem;
--section-header-title: 1.5rem;
}
@media (min-width: 640px) {
.wrn-section-header {
--section-header-title: 2.25rem;
--section-header-title: 1.25rem;
}
.wrn-section-header[data-size="sm"] {
--section-header-title: 1.75rem;
--section-header-title: 1.125rem;
}
.wrn-section-header[data-size="lg"] {
--section-header-title: 3rem;
--section-header-title: 1.75rem;
}
}
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff. Show more