Refresh UI components and showcase; release updated framework packages
This commit is contained in:
1 parent
d800228585
commit
c77f5e1cec
293 files changed
+22544
-35778
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
} from "./http/index.ts";
|
||||
export {
|
||||
authPlugin,
|
||||
configureAuthRuntime,
|
||||
authComponentsDir,
|
||||
validateProductionAuthConfig,
|
||||
type AuthConfig,
|
||||
|
||||
+21
-21
@@ -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);
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.70",
|
||||
"version": "0.8.71",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.8.15",
|
||||
"version": "0.8.16",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.40",
|
||||
"version": "0.8.41",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.65",
|
||||
"version": "0.8.66",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/payment",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.8.11",
|
||||
"version": "0.8.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.8.22",
|
||||
"version": "0.8.23",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.8.15",
|
||||
"version": "0.8.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 })`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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; }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -53,6 +53,7 @@ component Divider {
|
||||
|
||||
style {
|
||||
.wrn-divider {
|
||||
border: 0;
|
||||
--divider-tone: var(--wrn-color-border);
|
||||
--divider-thickness: 1px;
|
||||
display: flex;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import Badge from "./Badge.wrn"
|
||||
import Badge from "./badge.wrn"
|
||||
|
||||
component Hero {
|
||||
props {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">›</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user